feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
+7
View File
@@ -228,3 +228,10 @@ frontend/lib/services/<name>/
- 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。
- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。
### 国际化 (i18n)
- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。
- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。
- 文案放在 `frontend/messages/{locale}.json`。组件内用户可见文案必须 `useTranslations` / `getTranslations`,禁止新增中英硬编码 UI 字符串(后端 `error_msg`、日志除外)。
- 新增或修改文案必须同步 `zh-CN.json` 与 `en.json`。设计见 `docs/superpowers/specs/2026-07-24-frontend-i18n-design.md`。
+3
View File
@@ -10,6 +10,9 @@ sidebar: false
## [Unreleased]
### 新增
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。
### 🛠 修复
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
+1
View File
@@ -33,6 +33,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) |
| **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) |
| **日志存储** | 访问日志与可观测时序走可切换日志主库(随业务主库或 ClickHouse);关闭 ClickHouse 后仍可写可查 | [日志存储解耦](./logstore.md) |
| **控制台双语** | 无 URL 前缀的 zh-CN / en,cookie `NEXT_LOCALE` 优先,兼容静态导出 | [前端 i18n 设计](../superpowers/specs/2026-07-24-frontend-i18n-design.md) |
---
@@ -0,0 +1,253 @@
# Frontend i18n Design
Date: 2026-07-24
Status: Implemented in OpenFlare (ported from Wavelet 1625cfb, extended to product console)
Scope: Frontend UI only
## 1. Goals
Add bilingual UI support for Wavelet frontend:
- Languages: `zh-CN` and `en`
- Default locale: `zh-CN`
- Locale resolution: explicit user choice → browser language → default
- Phase 1: infrastructure + core paths only (layout / auth / settings)
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
### Non-goals (Phase 1)
- Backend API error / message localization
- Email / push notification localization
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
- Full translation of all admin business pages
## 2. Context
Current state:
- Root layout hardcodes `lang='zh-CN'`
- UI copy is mostly Chinese string literals across many TSX files
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
- No i18n library is installed
- Frontend supports both normal Next rewrites mode and static export embed mode
## 3. Approach
Use **next-intl in non-routing / provider mode**.
Why this approach:
- Mature App Router integration and clear `useTranslations` API
- ICU message format ready when needed
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
- Cookie + browser detection matches product preference without SEO path requirements
Rejected alternatives:
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
## 4. Architecture
```
RootLayout
html lang={locale}
ThemeProvider
CustomThemeProvider
AppQueryProvider
NextIntlClientProvider(locale, messages)
existing User / Notification / Bell providers
pages + components
```
### Key files
| Path | Responsibility |
| --- | --- |
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
| `frontend/messages/zh-CN.json` | Chinese messages |
| `frontend/messages/en.json` | English messages |
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
### Runtime flow
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
2. Load `messages/{locale}.json`
3. Provide locale + messages through `NextIntlClientProvider`
4. Components call `useTranslations('<namespace>')`
5. Language switcher writes cookie and refreshes locale/messages
6. Update `document.documentElement.lang`
## 5. Locale Resolution
Supported locales: `zh-CN`, `en`
Normalization:
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
- anything else → `zh-CN`
Priority:
1. User explicit choice stored in cookie `NEXT_LOCALE`
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
3. Default `zh-CN`
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
## 6. Static Export Compatibility
Constraints:
- No locale-segment routes
- No middleware-based locale rewriting required for correctness
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
Behavior:
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
## 7. Message Organization
Single catalog files with nested namespaces:
```json
{
"common": {
"save": "保存",
"cancel": "取消",
"loading": "加载中..."
},
"layout": {
"nav": {
"home": "首页",
"myFiles": "我的文件"
},
"userMenu": {
"settings": "设置",
"logout": "退出登录"
}
},
"auth": {
"login": {
"title": "登录",
"submit": "登录"
}
},
"settings": {
"appearance": {
"language": "语言",
"languageDesc": "选择界面显示语言"
}
}
}
```
Conventions:
- Keys use camelCase and hierarchical grouping
- Prefer complete phrases as values; avoid assembling sentences in components
- Use ICU only when needed (`{name}`, plural forms)
- Backend `error_msg` values are shown as-is in Phase 1
- Frontend-owned toast / validation copy is translated
Both locale files must keep the same key tree. A key-alignment check script is recommended.
## 8. Language Switcher UX
Placement:
- Header toolbar near theme controls
- Appearance settings page as an explicit preference row
UI labels for language options use native names and do not themselves translate:
- `中文`
- `English`
On change:
1. Persist `NEXT_LOCALE`
2. Apply new locale/messages (via refresh or controlled provider update)
3. Sync `document.documentElement.lang`
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
## 9. Phase 1 Migration Scope
### In scope
- Install and wire `next-intl`
- Message catalogs for core namespaces
- Locale resolution + persistence
- `LanguageSwitcher`
- Translate:
- layout shell: sidebar nav/user menu, header accessible labels / titles
- auth: login / register / OTP labels, buttons, validation messages
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
- Replace date/number hardcoding only where touched by the above paths
- Ensure `html lang` reflects active locale
### Out of scope
- Remaining admin pages and deep business modules
- Backend localization
- Route prefixing / SEO alternate links
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
## 10. Formatting Helpers
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
- `formatDateTime(value, locale)`
- `formatNumber(value, locale)`
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
## 11. Error Handling & Fallbacks
| Case | Behavior |
| --- | --- |
| Missing message key | Dev warning; do not crash; show key or fallback language value |
| Unsupported cookie locale | Normalize to supported locale / default |
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
| Backend error strings | Display raw `error_msg` |
## 12. Testing & Acceptance
Manual:
1. No cookie + browser Chinese → Chinese UI
2. No cookie + browser English → English UI
3. Manual switch to English survives refresh
4. Manual switch back to Chinese survives refresh
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
6. `pnpm build` and `pnpm build:embed` both succeed
7. Language switch does not break theme, session, or sidebar state
Automated (recommended):
- Unit tests for `normalizeLocale` / resolution priority
- Script or test asserting `zh-CN.json` and `en.json` key parity
## 13. Rollout Plan (high level)
1. Add i18n infrastructure and empty/core message files
2. Mount provider and language switcher
3. Migrate layout shell copy
4. Migrate auth copy
5. Migrate settings copy + appearance language control
6. Verify SSR and static-export builds
7. Document how later pages should adopt `useTranslations`
## 14. Open Implementation Notes
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
- Keep `internal/util` and backend packages untouched
- After implementation, follow repo frontend conventions and existing provider composition style
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
@@ -60,6 +61,7 @@ export function AccessLogDetailDialog({
item: AccessLogItem | null;
onOpenChange: (open: boolean) => void;
}) {
const t = useTranslations('accessLogs');
// Keep last selected item while the dialog closes to avoid empty-state flash.
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
if (item && item !== displayItem) {
@@ -71,21 +73,23 @@ export function AccessLogDetailDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>访问日志详情</DialogTitle>
<DialogDescription>
本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。
</DialogDescription>
<DialogTitle>{t('detail.title')}</DialogTitle>
<DialogDescription>{t('detail.description')}</DialogDescription>
</DialogHeader>
{activeItem ? (
<div className='grid gap-4 sm:grid-cols-2'>
<DetailField label='日志 ID' value={activeItem.id || '—'} mono />
<DetailField
label='请求时间'
label={t('detail.logId')}
value={activeItem.id || '—'}
mono
/>
<DetailField
label={t('detail.requestTime')}
value={formatDateTime(activeItem.logged_at)}
/>
<DetailField
label='入库时间'
label={t('detail.storedAt')}
value={
activeItem.created_at
? formatDateTime(activeItem.created_at)
@@ -93,23 +97,29 @@ export function AccessLogDetailDialog({
}
/>
<DetailField
label='节点'
label={t('detail.node')}
value={activeItem.node_name || activeItem.node_id || '—'}
/>
<DetailField
label='节点 ID'
label={t('detail.nodeId')}
value={activeItem.node_id || '—'}
mono
/>
<DetailField
label='客户端 IP'
label={t('detail.clientIp')}
value={activeItem.remote_addr || '—'}
mono
/>
<DetailField label='地区' value={activeItem.region || '—'} />
<DetailField label='域名' value={activeItem.host || '—'} />
<DetailField
label='状态码'
label={t('detail.region')}
value={activeItem.region || '—'}
/>
<DetailField
label={t('detail.host')}
value={activeItem.host || '—'}
/>
<DetailField
label={t('detail.status')}
value={
<Badge variant='outline' className='text-[10px]'>
{activeItem.status_code}
@@ -117,12 +127,13 @@ export function AccessLogDetailDialog({
}
/>
<DetailField
label='缓存'
label={t('detail.cache')}
value={
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline' className='text-[10px]'>
{cacheOutcomeLabel(
resolveCacheOutcome(activeItem.cache_status),
(key) => t(key),
)}
</Badge>
<span className='font-mono text-xs text-muted-foreground'>
@@ -132,19 +143,19 @@ export function AccessLogDetailDialog({
}
/>
<DetailField
label='出站流量'
label={t('detail.bytesSent')}
value={formatBytes(activeItem.bytes_sent ?? 0)}
/>
<DetailField
label='入站流量'
label={t('detail.requestLength')}
value={formatBytes(activeItem.request_length ?? 0)}
/>
<DetailField
label='请求耗时'
label={t('detail.duration')}
value={formatRequestTimeMs(activeItem.request_time_ms)}
/>
<DetailField
label='路径'
label={t('detail.path')}
value={activeItem.path || '—'}
full
mono
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { format } from 'date-fns';
import { CalendarIcon, ChevronDown, Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
@@ -91,6 +92,7 @@ function DateTimePicker({
value: string;
onChange: (value: string) => void;
}) {
const t = useTranslations('accessLogs.filters');
const [open, setOpen] = useState(false);
const current = value ? new Date(value) : undefined;
@@ -121,7 +123,7 @@ function DateTimePicker({
{current ? (
format(current, 'yyyy-MM-dd HH:mm')
) : (
<span className='text-muted-foreground'>选择时间</span>
<span className='text-muted-foreground'>{t('pickTime')}</span>
)}
</Button>
</PopoverTrigger>
@@ -153,12 +155,13 @@ export function AccessLogFilters({
onSearch,
onReset,
}: AccessLogFiltersProps) {
const t = useTranslations('accessLogs.filters');
const [moreOpen, setMoreOpen] = useState(false);
return (
<div className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
<FilterField label='来源 IP'>
<FilterField label={t('remoteAddr')}>
<Input
value={draft.remoteAddr}
onChange={(e) =>
@@ -167,22 +170,22 @@ export function AccessLogFilters({
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按 IP 搜索'
placeholder={t('remoteAddrPlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
<FilterField label='访问域名'>
<FilterField label={t('host')}>
<Input
value={draft.host}
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按域名搜索'
placeholder={t('hostPlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
<FilterField label='状态码'>
<FilterField label={t('statusCode')}>
<Input
value={draft.statusCode}
onChange={(e) =>
@@ -194,7 +197,7 @@ export function AccessLogFilters({
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='输入状态码,如 404'
placeholder={t('statusCodePlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
@@ -207,7 +210,7 @@ export function AccessLogFilters({
size='sm'
className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground'
>
更多筛选
{t('more')}
<ChevronDown
className={`size-3.5 transition-transform ${
moreOpen ? 'rotate-180' : ''
@@ -217,7 +220,7 @@ export function AccessLogFilters({
</CollapsibleTrigger>
<CollapsibleContent>
<div className='grid gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3'>
<FilterField label='节点 ID'>
<FilterField label={t('nodeId')}>
<div className='relative'>
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
<Input
@@ -228,12 +231,12 @@ export function AccessLogFilters({
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按 node_id 搜索'
placeholder={t('nodeIdPlaceholder')}
className='pl-8 h-9 text-xs'
/>
</div>
</FilterField>
<FilterField label='请求路径'>
<FilterField label={t('path')}>
<Input
value={draft.path}
onChange={(e) =>
@@ -242,11 +245,11 @@ export function AccessLogFilters({
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按路径搜索'
placeholder={t('pathPlaceholder')}
className='h-9 text-xs'
/>
</FilterField>
<FilterField label='时间范围'>
<FilterField label={t('timeRange')}>
<div className='grid grid-cols-2 gap-2'>
<DateTimePicker
value={draft.since}
@@ -268,7 +271,9 @@ export function AccessLogFilters({
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
<p className='text-xs font-medium text-muted-foreground'>
{t('pageSize')}
</p>
<Select
value={String(pageSize)}
onValueChange={(value) =>
@@ -281,7 +286,7 @@ export function AccessLogFilters({
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} 条
{t('pageSizeItem', { count: option })}
</SelectItem>
))}
</SelectContent>
@@ -289,10 +294,10 @@ export function AccessLogFilters({
</div>
<div className='flex gap-2'>
<Button size='sm' onClick={onSearch}>
筛选
{t('apply')}
</Button>
<Button variant='outline' size='sm' onClick={onReset}>
清空
{t('clear')}
</Button>
</div>
</div>
@@ -19,43 +19,43 @@ export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
export const OVERVIEW_RANGE_OPTIONS: {
value: OverviewRangeHours;
label: string;
labelKey: `range.h${OverviewRangeHours}`;
}[] = [
{ value: 24, label: '24 小时' },
{ value: 168, label: '7 天' },
{ value: 360, label: '15 天' },
{ value: 720, label: '30 天' },
{ value: 24, labelKey: 'range.h24' },
{ value: 168, labelKey: 'range.h168' },
{ value: 360, labelKey: 'range.h360' },
{ value: 720, labelKey: 'range.h720' },
];
export const RATE_LIMIT_RANGE_OPTIONS: {
value: RateLimitRangeHours;
label: string;
labelKey: 'range.h24' | 'range.h72';
}[] = [
{ value: 24, label: '24 小时' },
{ value: 72, label: '3 天' },
{ value: 24, labelKey: 'range.h24' },
{ value: 72, labelKey: 'range.h72' },
];
export const DETAIL_SORT_OPTIONS = [
{ value: 'logged_at:desc', label: '时间从新到旧' },
{ value: 'logged_at:asc', label: '时间从旧到新' },
{ value: 'status_code:desc', label: '状态码从高到低' },
{ value: 'status_code:asc', label: '状态码从低到高' },
{ value: 'remote_addr:asc', label: 'IP 正序' },
{ value: 'remote_addr:desc', label: 'IP 倒序' },
];
{ value: 'logged_at:desc', labelKey: 'sort.logged_at:desc' },
{ value: 'logged_at:asc', labelKey: 'sort.logged_at:asc' },
{ value: 'status_code:desc', labelKey: 'sort.status_code:desc' },
{ value: 'status_code:asc', labelKey: 'sort.status_code:asc' },
{ value: 'remote_addr:asc', labelKey: 'sort.remote_addr:asc' },
{ value: 'remote_addr:desc', labelKey: 'sort.remote_addr:desc' },
] as const;
export const IP_SORT_OPTIONS = [
{ value: 'total_requests:desc', label: '请求数从高到低' },
{ value: 'total_requests:asc', label: '请求数从低到高' },
{ value: 'request_length:desc', label: '入站从高到低' },
{ value: 'request_length:asc', label: '入站从低到高' },
{ value: 'bytes_sent:desc', label: '出站从高到低' },
{ value: 'bytes_sent:asc', label: '出站从低到高' },
{ value: 'success_ratio:desc', label: '2xx 比例从高到低' },
{ value: 'success_ratio:asc', label: '2xx 比例从低到高' },
{ value: 'last_seen_at:desc', label: '最后访问从新到旧' },
{ value: 'last_seen_at:asc', label: '最后访问从旧到新' },
];
{ value: 'total_requests:desc', labelKey: 'sort.total_requests:desc' },
{ value: 'total_requests:asc', labelKey: 'sort.total_requests:asc' },
{ value: 'request_length:desc', labelKey: 'sort.request_length:desc' },
{ value: 'request_length:asc', labelKey: 'sort.request_length:asc' },
{ value: 'bytes_sent:desc', labelKey: 'sort.bytes_sent:desc' },
{ value: 'bytes_sent:asc', labelKey: 'sort.bytes_sent:asc' },
{ value: 'success_ratio:desc', labelKey: 'sort.success_ratio:desc' },
{ value: 'success_ratio:asc', labelKey: 'sort.success_ratio:asc' },
{ value: 'last_seen_at:desc', labelKey: 'sort.last_seen_at:desc' },
{ value: 'last_seen_at:asc', labelKey: 'sort.last_seen_at:asc' },
] as const;
export function parseSortValue(value: string) {
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
@@ -72,10 +72,16 @@ export function formatCompactNumber(value: number) {
}).format(value);
}
export function formatOverviewRangeHint(hours: number) {
if (hours <= 24) return '近 24 小时';
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
return `近 ${hours} 小时`;
export function formatOverviewRangeHint(
hours: number,
t: (
key: 'rangeHint.hours24' | 'rangeHint.days' | 'rangeHint.hours',
values?: { count: number },
) => string,
) {
if (hours <= 24) return t('rangeHint.hours24');
if (hours % 24 === 0) return t('rangeHint.days', { count: hours / 24 });
return t('rangeHint.hours', { count: hours });
}
export function formatOverviewTrendLabel(value: string, hours: number) {
@@ -112,13 +118,9 @@ export function resolveCacheOutcome(
return 'uncached';
}
export function cacheOutcomeLabel(outcome: CacheOutcome) {
switch (outcome) {
case 'hit':
return '命中';
case 'origin':
return '回源';
default:
return '未缓存';
}
export function cacheOutcomeLabel(
outcome: CacheOutcome,
t: (key: `cache.${CacheOutcome}`) => string,
) {
return t(`cache.${outcome}`);
}
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
AlertDialog,
@@ -38,6 +39,8 @@ export function CleanupDialog({
onConfirm,
loading,
}: CleanupDialogProps) {
const t = useTranslations('accessLogs.cleanupDialog');
const tCommon = useTranslations('common');
const [mode, setMode] = useState<string>('7');
const [customDays, setCustomDays] = useState('14');
const [error, setError] = useState<string | null>(null);
@@ -49,7 +52,7 @@ export function CleanupDialog({
: Number.parseInt(mode, 10);
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
setError('保留天数必须大于 0');
setError(t('invalidDays'));
return;
}
@@ -61,15 +64,13 @@ export function CleanupDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理访问日志</AlertDialogTitle>
<AlertDialogDescription>
删除早于指定保留天数的访问日志记录,操作不可恢复。
</AlertDialogDescription>
<AlertDialogTitle>{t('title')}</AlertDialogTitle>
<AlertDialogDescription>{t('description')}</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-3'>
<div className='space-y-1.5'>
<Label>保留策略</Label>
<Label>{t('policy')}</Label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger>
<SelectValue />
@@ -77,17 +78,17 @@ export function CleanupDialog({
<SelectContent>
{CLEANUP_PRESETS.map((days) => (
<SelectItem key={days} value={String(days)}>
保留最近 {days} 天
{t('keepDays', { days })}
</SelectItem>
))}
<SelectItem value='custom'>自定义天数</SelectItem>
<SelectItem value='custom'>{t('custom')}</SelectItem>
</SelectContent>
</Select>
</div>
{mode === 'custom' ? (
<div className='space-y-1.5'>
<Label htmlFor='customDays'>自定义保留天数</Label>
<Label htmlFor='customDays'>{t('customDays')}</Label>
<Input
id='customDays'
type='number'
@@ -103,7 +104,9 @@ export function CleanupDialog({
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={loading}>
{tCommon('cancel')}
</AlertDialogCancel>
<Button
variant='destructive'
onClick={handleConfirm}
@@ -112,10 +115,10 @@ export function CleanupDialog({
{loading ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
清理中...
{t('cleaning')}
</>
) : (
'确认清理'
t('confirm')
)}
</Button>
</AlertDialogFooter>
@@ -2,6 +2,7 @@
import { useState } from 'react';
import { Eye } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
@@ -65,9 +66,12 @@ function PaginationBar({
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('accessLogs.detail');
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
<p className='text-xs text-muted-foreground'>
{t('page', { page: page + 1 })}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
@@ -75,7 +79,7 @@ function PaginationBar({
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
{t('prev')}
</Button>
<Button
variant='outline'
@@ -83,7 +87,7 @@ function PaginationBar({
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
{t('next')}
</Button>
</div>
</div>
@@ -113,6 +117,7 @@ export function DetailTab({
onNextPage: () => void;
isFetching: boolean;
}) {
const t = useTranslations('accessLogs');
const [selected, setSelected] = useState<AccessLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
@@ -120,7 +125,7 @@ export function DetailTab({
<>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>日志明细</p>
<p className='text-sm font-medium'>{t('detail.listTitle')}</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
@@ -128,7 +133,7 @@ export function DetailTab({
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -137,25 +142,25 @@ export function DetailTab({
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
message={error.message || t('loadFailed')}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
<EmptyStateWithBorder title={t('detail.empty')} />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>{t('detail.time')}</TableHead>
<TableHead className='text-xs'>{t('detail.node')}</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>缓存</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
<TableHead className='text-xs'>{t('detail.host')}</TableHead>
<TableHead className='text-xs'>{t('detail.path')}</TableHead>
<TableHead className='text-xs'>{t('detail.cache')}</TableHead>
<TableHead className='text-xs'>{t('detail.status')}</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
@@ -190,7 +195,7 @@ export function DetailTab({
className='text-[10px]'
title={item.cache_status || undefined}
>
{cacheOutcomeLabel(outcome)}
{cacheOutcomeLabel(outcome, (key) => t(key))}
</Badge>
</TableCell>
<TableCell>
@@ -214,7 +219,7 @@ export function DetailTab({
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
{t('detail.view')}
</TooltipContent>
</Tooltip>
</TableCell>
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { RankChart } from '@/components/data/rank-chart';
@@ -50,9 +51,12 @@ import {
type OverviewRangeHours,
} from './access-log-utils';
const trendChartConfig = {
requests: { label: '请求数', color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
function useTrendChartConfig() {
const t = useTranslations('accessLogs');
return {
requests: { label: t('ip.requests'), color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
}
function resolveBucketMinutes(hours: number) {
if (hours <= 24) return 30;
@@ -104,6 +108,7 @@ function MiniRankCard({
items: { label: string; value: number }[];
color: string;
}) {
const t = useTranslations('accessLogs.analysis');
return (
<div className='rounded-lg border border-dashed p-3'>
<p className='mb-2 text-sm font-medium'>{title}</p>
@@ -111,7 +116,7 @@ function MiniRankCard({
items={items}
color={color}
className='!h-[220px]'
emptyMessage={`暂无 ${title} 数据`}
emptyMessage={t('emptySeries', { title })}
/>
</div>
);
@@ -126,6 +131,7 @@ function AddToIPGroupPanel({
open: boolean;
onClose: () => void;
}) {
const t = useTranslations('accessLogs.analysis');
const queryClient = useQueryClient();
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
@@ -179,7 +185,7 @@ function AddToIPGroupPanel({
const groupId = Number.parseInt(selectedGroupId, 10);
const group = addableGroups.find((item) => item.id === groupId);
if (!group) {
toast.error('请选择要加入的 IP 组');
toast.error(t('selectGroup'));
return;
}
try {
@@ -187,10 +193,10 @@ function AddToIPGroupPanel({
group,
nextList: [...(group.ip_list ?? []), ip.trim()],
});
toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`);
toast.success(t('added', { ip, name: group.name }));
setSelectedGroupId('');
} catch (error) {
toast.error(error instanceof Error ? error.message : '加入 IP 组失败');
toast.error(error instanceof Error ? error.message : t('addFailed'));
}
};
@@ -202,9 +208,9 @@ function AddToIPGroupPanel({
(entry) => entry.trim() !== ip.trim(),
),
});
toast.success(`已从 IP 组「${group.name}」移除 ${ip}`);
toast.success(t('removed', { name: group.name, ip }));
} catch (error) {
toast.error(error instanceof Error ? error.message : '移除失败');
toast.error(error instanceof Error ? error.message : t('removeFailed'));
}
};
@@ -220,21 +226,21 @@ function AddToIPGroupPanel({
>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
<DialogTitle>{t('joinTitle')}</DialogTitle>
<DialogDescription>
目标 IP:
{t('targetIp')}
<span className='font-mono text-foreground'>{ip}</span>
</DialogDescription>
</DialogHeader>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder title='加载 IP 组' />
<LoadingStateWithBorder title={t('loadingGroups')} />
) : groupsQuery.isError ? (
<ErrorInline
message={
groupsQuery.error instanceof Error
? groupsQuery.error.message
: '加载 IP 组失败'
: t('loadGroupsFailed')
}
onRetry={() => void groupsQuery.refetch()}
/>
@@ -243,10 +249,10 @@ function AddToIPGroupPanel({
{matchedGroups.length > 0 ? (
<div className='space-y-2 rounded-lg border border-dashed p-3'>
<p className='text-sm font-medium text-foreground'>
该 IP 已存在于以下 IP 组
{t('alreadyIn')}
</p>
<p className='text-xs text-muted-foreground'>
可选择删除,或继续添加到其他 IP 组。
{t('alreadyInHint')}
</p>
<div className='space-y-2'>
{matchedGroups.map((group) => (
@@ -259,7 +265,10 @@ function AddToIPGroupPanel({
{group.name}
</p>
<p className='text-[11px] text-muted-foreground'>
{group.type} · {group.ip_list?.length ?? 0} 条
{t('entryCount', {
type: group.type,
count: group.ip_list?.length ?? 0,
})}
</p>
</div>
{group.type === 'manual' ? (
@@ -275,13 +284,13 @@ function AddToIPGroupPanel({
) : (
<>
<Trash2 className='mr-1 size-3.5' />
删除
{t('delete')}
</>
)}
</Button>
) : (
<Badge variant='outline' className='text-[10px]'>
不可手动删除
{t('cannotDelete')}
</Badge>
)}
</div>
@@ -290,15 +299,15 @@ function AddToIPGroupPanel({
</div>
) : (
<p className='text-sm text-muted-foreground'>
该 IP 尚未加入任何 IP 组。
{t('notInAny')}
</p>
)}
<div className='space-y-2'>
<p className='text-sm font-medium'>添加到其他 IP 组</p>
<p className='text-sm font-medium'>{t('addToOther')}</p>
{addableGroups.length === 0 ? (
<p className='text-xs text-muted-foreground'>
没有可写入的手动 IP 组(或已全部包含该 IP)。
{t('noWritable')}
</p>
) : (
<Select
@@ -306,7 +315,7 @@ function AddToIPGroupPanel({
onValueChange={setSelectedGroupId}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue placeholder='选择手动 IP 组' />
<SelectValue placeholder={t('selectManual')} />
</SelectTrigger>
<SelectContent>
{addableGroups.map((group) => (
@@ -323,7 +332,7 @@ function AddToIPGroupPanel({
<DialogFooter>
<Button variant='outline' onClick={onClose}>
关闭
{t('close')}
</Button>
<Button
onClick={() => void handleAdd()}
@@ -336,10 +345,10 @@ function AddToIPGroupPanel({
{updateMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
处理中...
{t('processing')}
</>
) : (
'加入所选 IP 组'
t('joinSelected')
)}
</Button>
</DialogFooter>
@@ -361,6 +370,8 @@ export function IpAnalysisPanel({
*/
initialHours?: number;
}) {
const t = useTranslations('accessLogs');
const trendChartConfig = useTrendChartConfig();
const [ipGroupOpen, setIpGroupOpen] = useState(false);
const [rangeHours, setRangeHours] = useState(() =>
clampAnalysisHours(initialHours),
@@ -368,8 +379,8 @@ export function IpAnalysisPanel({
const bucketMinutes = resolveBucketMinutes(rangeHours);
const rangeHint = isOverviewPreset(rangeHours)
? formatOverviewRangeHint(rangeHours)
: `近 ${rangeHours} 小时`;
? formatOverviewRangeHint(rangeHours, (key, values) => t(key, values))
: t('analysis.recentHours', { hours: rangeHours });
const trendQuery = useQuery({
queryKey: [
@@ -411,7 +422,7 @@ export function IpAnalysisPanel({
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
if (!ip) {
return <EmptyStateWithBorder description='没有有效 IP。' />;
return <EmptyStateWithBorder description={t('analysis.invalidIp')} />;
}
return (
@@ -423,7 +434,8 @@ export function IpAnalysisPanel({
variant='outline'
onClick={() => setIpGroupOpen(true)}
>
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
<ShieldPlus className='mr-1 size-3.5' />
{t('analysis.addToGroup')}
</Button>
<ToggleGroup
type='single'
@@ -441,14 +453,14 @@ export function IpAnalysisPanel({
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
{t(option.labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{isLoadingIP ? (
<LoadingStateWithBorder title='加载 IP 分析' />
<LoadingStateWithBorder title={t('analysis.loading')} />
) : (
<div className='space-y-4'>
{analysisQuery.isError ? (
@@ -456,34 +468,34 @@ export function IpAnalysisPanel({
message={
analysisQuery.error instanceof Error
? analysisQuery.error.message
: '加载 IP 分析失败'
: t('analysis.loadFailed')
}
onRetry={() => void analysisQuery.refetch()}
/>
) : analysis ? (
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<MetricCard
label='总请求'
label={t('analysis.totalRequests')}
value={formatCompactNumber(analysis.summary.total_requests)}
/>
<MetricCard
label='错误数'
label={t('analysis.errors')}
value={formatCompactNumber(analysis.summary.error_count)}
/>
<MetricCard
label='已提供带宽'
label={t('analysis.bandwidth')}
value={formatBytes(analysis.summary.bandwidth_served)}
/>
<MetricCard
label='接收数据'
label={t('analysis.received')}
value={formatBytes(analysis.summary.bytes_received)}
/>
<MetricCard
label='独立域名'
label={t('analysis.uniqueHosts')}
value={formatCompactNumber(analysis.summary.unique_hosts)}
/>
<MetricCard
label='独立路径'
label={t('analysis.uniquePaths')}
value={formatCompactNumber(analysis.summary.unique_paths)}
/>
</div>
@@ -492,9 +504,13 @@ export function IpAnalysisPanel({
<div className='space-y-3 rounded-lg border border-dashed p-4'>
<div className='flex items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>IP 请求趋势</p>
<p className='text-sm font-medium'>{t('analysis.trend')}</p>
<p className='text-xs text-muted-foreground'>
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
{t('analysis.trendMeta', {
ip,
range: rangeHint,
minutes: bucketMinutes,
})}
</p>
</div>
<Button
@@ -506,7 +522,7 @@ export function IpAnalysisPanel({
void analysisQuery.refetch();
}}
>
刷新
{t('analysis.refresh')}
</Button>
</div>
@@ -515,13 +531,13 @@ export function IpAnalysisPanel({
message={
trendQuery.error instanceof Error
? trendQuery.error.message
: '加载趋势失败'
: t('analysis.trendFailed')
}
onRetry={() => void trendQuery.refetch()}
/>
) : trendChartData.every((point) => point.requests === 0) ? (
<EmptyStateWithBorder
description={`该 IP 在${rangeHint}内没有访问记录。`}
description={t('analysis.trendEmpty', { range: rangeHint })}
/>
) : (
<ChartContainer
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
@@ -31,19 +32,20 @@ export function IpDetailDialog({
setDisplayAddr(remoteAddr);
setDisplayRegion(region);
}
const t = useTranslations('accessLogs.ip');
const ip = remoteAddr ?? displayAddr ?? '';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>IP 详情</DialogTitle>
<DialogTitle>{t('dialogTitle')}</DialogTitle>
<DialogDescription>
<span className='font-mono text-foreground'>{ip || '—'}</span>
{displayRegion ? (
<span className='text-muted-foreground'> · {displayRegion}</span>
) : null}
。查看该 IP 的访问趋势、分布与 WAF IP 组操作。
{t('dialogDesc')}
</DialogDescription>
</DialogHeader>
<IpAnalysisPanel
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Eye } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
@@ -72,20 +73,21 @@ const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
export function validateCustomTimeRange(
sinceLocal: string,
untilLocal: string,
t?: (key: 'ip.needRange' | 'ip.invalidTime' | 'ip.endAfterStart' | 'ip.maxRange') => string,
): string | null {
if (!sinceLocal.trim() || !untilLocal.trim()) {
return '请填写开始与结束时间';
return t ? t('ip.needRange') : 'needRange';
}
const sinceMs = new Date(sinceLocal).getTime();
const untilMs = new Date(untilLocal).getTime();
if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
return '时间格式无效';
return t ? t('ip.invalidTime') : 'invalidTime';
}
if (untilMs <= sinceMs) {
return '结束时间必须晚于开始时间';
return t ? t('ip.endAfterStart') : 'endAfterStart';
}
if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
return '时间范围不能超过 30 天';
return t ? t('ip.maxRange') : 'maxRange';
}
return null;
}
@@ -128,11 +130,12 @@ function PaginationBar({
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('accessLogs.ip');
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>
当前第 {page + 1} 页
{typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}
{t('page', { page: page + 1 })}
{typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}
</p>
<div className='flex gap-2'>
<Button
@@ -141,7 +144,7 @@ function PaginationBar({
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
{t('prev')}
</Button>
<Button
variant='outline'
@@ -149,7 +152,7 @@ function PaginationBar({
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
{t('next')}
</Button>
</div>
</div>
@@ -201,6 +204,7 @@ export function IpTab({
onNextPage: () => void;
isFetching: boolean;
}) {
const t = useTranslations('accessLogs');
const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [draftSince, setDraftSince] = useState(customSince);
@@ -232,13 +236,15 @@ export function IpTab({
const rangeHint =
timeMode === 'custom' && customSince && customUntil
? '自定义区间'
? t('ip.customRange')
: timeMode === 'custom'
? '自定义(未应用)'
: formatOverviewRangeHint(hours);
? t('ip.customPending')
: formatOverviewRangeHint(hours, (key, values) => t(key, values));
const handleApplyCustom = () => {
const message = validateCustomTimeRange(draftSince, draftUntil);
const message = validateCustomTimeRange(draftSince, draftUntil, (key) =>
t(key),
);
if (message) {
setCustomError(message);
return;
@@ -253,11 +259,11 @@ export function IpTab({
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='space-y-1'>
<p className='text-sm font-medium'>时间范围</p>
<p className='text-sm font-medium'>{t('ip.timeRange')}</p>
<p className='text-xs text-muted-foreground'>
当前:{rangeHint}
{t('ip.current', { range: rangeHint })}
{data?.total_ip != null
? ` · ${formatCompactNumber(data.total_ip)} 个 IP`
? t('ip.total', { count: data.total_ip })
: ''}
</p>
</div>
@@ -281,7 +287,7 @@ export function IpTab({
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
{t(option.labelKey)}
</ToggleGroupItem>
))}
</ToggleGroup>
@@ -291,7 +297,7 @@ export function IpTab({
className='h-8 text-xs'
onClick={() => onTimeModeChange('custom')}
>
自定义
{t('ip.custom')}
</Button>
</div>
</div>
@@ -300,7 +306,7 @@ export function IpTab({
<div className='space-y-2'>
<div className='flex flex-wrap items-end gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>开始</p>
<p className='text-xs text-muted-foreground'>{t('ip.start')}</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
@@ -312,7 +318,7 @@ export function IpTab({
/>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>结束</p>
<p className='text-xs text-muted-foreground'>{t('ip.end')}</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
@@ -328,14 +334,14 @@ export function IpTab({
className='h-9 text-xs'
onClick={handleApplyCustom}
>
应用
{t('ip.apply')}
</Button>
</div>
{customError ? (
<p className='text-xs text-destructive'>{customError}</p>
) : (
<p className='text-xs text-muted-foreground'>
修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。
{t('ip.applyHint')}
</p>
)}
</div>
@@ -343,7 +349,7 @@ export function IpTab({
<div className='flex flex-wrap items-center gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>排序</p>
<p className='text-xs text-muted-foreground'>{t('ip.sort')}</p>
<Select value={sort} onValueChange={onSortChange}>
<SelectTrigger className='h-8 w-48 text-xs'>
<SelectValue />
@@ -351,14 +357,14 @@ export function IpTab({
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>每页</p>
<p className='text-xs text-muted-foreground'>{t('ip.pageSize')}</p>
<Select
value={String(pageSize)}
onValueChange={(value) =>
@@ -382,30 +388,38 @@ export function IpTab({
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>IP 明细</p>
<p className='text-sm font-medium'>{t('ip.title')}</p>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
message={error.message || t('loadFailed')}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无 IP 数据' />
<EmptyStateWithBorder title={t('ip.empty')} />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>地区</TableHead>
<TableHead className='text-xs text-right'>请求数</TableHead>
<TableHead className='text-xs text-right'>2xx 比例</TableHead>
<TableHead className='text-xs text-right'>入站</TableHead>
<TableHead className='text-xs text-right'>出站</TableHead>
<TableHead className='text-xs'>最后访问</TableHead>
<TableHead className='text-xs'>{t('ip.region')}</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.requests')}
</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.successRatio')}
</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.inbound')}
</TableHead>
<TableHead className='text-xs text-right'>
{t('ip.outbound')}
</TableHead>
<TableHead className='text-xs'>{t('ip.lastSeen')}</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
@@ -453,7 +467,7 @@ export function IpTab({
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看 IP 详情
{t('ip.view')}
</TooltipContent>
</Tooltip>
</TableCell>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
@@ -278,6 +279,7 @@ export function OverviewTab({
onHostsChange: (hosts: string[]) => void;
onRetry: () => void;
}) {
const t = useTranslations('accessLogs.overview');
return (
<div className='space-y-6'>
<OverviewToolbar
@@ -289,18 +291,18 @@ export function OverviewTab({
{loading ? (
<LoadingStateWithBorder
title='加载访问概览'
description='正在聚合请求量、访问量与带宽趋势...'
title={t('loadingTitle')}
description={t('loadingDesc')}
/>
) : error ? (
<ErrorInline
message={error.message || '加载访问概览失败'}
message={error.message || t('loadFailed')}
onRetry={onRetry}
/>
) : !data ? (
<EmptyStateWithBorder
title='暂无概览数据'
description='当前时间范围内没有可展示的访问统计。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<OverviewContent data={data} hours={hours} />
@@ -316,13 +318,14 @@ function OverviewContent({
data: AccessLogOverview;
hours: number;
}) {
const t = useTranslations('accessLogs');
const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours),
);
const requestValues = data.trends.requests.map((point) => point.value);
const visitValues = data.trends.visits.map((point) => point.value);
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
const hint = formatOverviewRangeHint(hours);
const hint = formatOverviewRangeHint(hours, (key, values) => t(key, values));
return (
<>
@@ -339,7 +342,7 @@ function OverviewContent({
<SparklineMetricCard
title='Total Visits'
value={formatCompactNumber(data.summary.total_visits)}
hint={`${hint} · 独立访客`}
hint={t('overview.uniqueVisitors', { hint })}
color='#38bdf8'
fillColor='rgba(56, 189, 248, 0.16)'
labels={requestLabels}
@@ -348,7 +351,7 @@ function OverviewContent({
<SparklineMetricCard
title='Bandwidth Served'
value={formatBytes(data.summary.bandwidth_served)}
hint={`${hint} · 已提供数据`}
hint={t('overview.served', { hint })}
color='#34d399'
fillColor='rgba(52, 211, 153, 0.16)'
labels={requestLabels}
@@ -363,7 +366,7 @@ function OverviewContent({
Requests over time
</CardTitle>
<CardDescription className='text-xs'>
观察请求量是否出现异常抬升或回落。
{t('overview.requestsHint')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -373,7 +376,7 @@ function OverviewContent({
showSummary={false}
series={[
{
label: '请求量',
label: t('overview.requests'),
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
@@ -387,32 +390,32 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title='Requests by device type'
description='按设备类型统计请求占比。'
description={t('overview.deviceDesc')}
items={data.device_types ?? []}
emptyMessage='暂无设备类型数据'
emptyMessage={t('overview.deviceEmpty')}
/>
<PieDistributionCard
title='Status code'
description='按 HTTP 状态码统计请求占比。'
description={t('overview.statusDesc')}
items={data.status_codes ?? []}
emptyMessage='暂无状态码分布数据'
emptyMessage={t('overview.statusEmpty')}
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
description='访问量最高的请求路径。'
description={t('overview.topPathsDesc')}
items={toRankItems(data.top_paths)}
/>
<RankCard
title='Top Hosts'
description='流量集中的访问域名。'
description={t('overview.topHostsDesc')}
items={toRankItems(data.top_hosts)}
/>
<RankCard
title='Top IPs'
description='请求次数最多的来源 IP。'
description={t('overview.topIpsDesc')}
items={toRankItems(data.top_ips)}
/>
</div>
@@ -420,17 +423,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top browsers'
description='按浏览器聚合的请求排行。'
description={t('overview.browsersDesc')}
items={toRankItems(data.top_browsers)}
/>
<RankCard
title='Top Operating System'
description='按操作系统聚合的请求排行。'
description={t('overview.osDesc')}
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title='Top User-Agent'
description='原始 User-Agent 请求排行。'
description={t('overview.uaDesc')}
items={toRankItems(data.top_user_agents)}
/>
</div>
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -71,6 +72,7 @@ export function OverviewHostFilter({
hosts: string[];
onHostsChange: (hosts: string[]) => void;
}) {
const t = useTranslations('accessLogs.toolbar');
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
@@ -160,10 +162,14 @@ export function OverviewHostFilter({
hosts.length > 0 ? 'border-primary text-primary' : undefined,
)}
title={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
hosts.length > 0
? t('hostsFiltered', { count: hosts.length })
: t('filterByHost')
}
aria-label={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
hosts.length > 0
? t('hostsFiltered', { count: hosts.length })
: t('filterByHost')
}
>
<Filter className='size-3.5' />
@@ -171,7 +177,7 @@ export function OverviewHostFilter({
</PopoverTrigger>
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>筛选域名</p>
<p className='text-sm font-medium'>{t('filterHosts')}</p>
{hosts.length > 0 ? (
<Button
type='button'
@@ -181,25 +187,25 @@ export function OverviewHostFilter({
onClick={() => onHostsChange([])}
>
<X className='mr-1 size-3' />
清除
{t('clear')}
</Button>
) : null}
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='搜索 Zone 或域名'
placeholder={t('searchPlaceholder')}
className='h-8 text-xs'
/>
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
{zonesQuery.isLoading ? (
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
加载域名…
{t('loadingHosts')}
</div>
) : zonesQuery.isError ? (
<div className='space-y-2 py-2'>
<p className='text-xs text-destructive'>加载域名失败</p>
<p className='text-xs text-destructive'>{t('loadHostsFailed')}</p>
<Button
type='button'
variant='outline'
@@ -207,12 +213,12 @@ export function OverviewHostFilter({
className='h-7 text-xs'
onClick={() => void zonesQuery.refetch()}
>
重试
{t('retry')}
</Button>
</div>
) : filteredZones.length === 0 ? (
<p className='py-6 text-center text-xs text-muted-foreground'>
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'}
{zones.length === 0 ? t('noRegistered') : t('noMatch')}
</p>
) : (
filteredZones.map((zone) => {
@@ -247,7 +253,7 @@ export function OverviewHostFilter({
onCheckedChange={(checked) =>
toggleZone(zone.domains, checked)
}
aria-label={`选择 Zone ${zone.zoneName}`}
aria-label={t('selectZone', { name: zone.zoneName })}
/>
<CollapsibleTrigger asChild>
<button
@@ -286,7 +292,7 @@ export function OverviewHostFilter({
onCheckedChange={(checked) =>
toggleHost(domain, checked)
}
aria-label={`选择域名 ${domain}`}
aria-label={t('selectHost', { name: domain })}
/>
<span className='min-w-0 flex-1 truncate font-mono'>
{domain}
@@ -311,7 +317,7 @@ export function OverviewToolbar({
hosts,
onHoursChange,
onHostsChange,
rangeOptions = OVERVIEW_RANGE_OPTIONS,
rangeOptions,
}: {
hours: OverviewRangeHours | RateLimitRangeHours;
hosts: string[];
@@ -319,11 +325,20 @@ export function OverviewToolbar({
onHostsChange: (hosts: string[]) => void;
rangeOptions?: ReadonlyArray<{ value: number; label: string }>;
}) {
const t = useTranslations('accessLogs');
const options =
rangeOptions ??
OVERVIEW_RANGE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}));
return (
<div className='flex flex-wrap items-center justify-end gap-2'>
{hosts.length > 0 ? (
<Badge variant='secondary' className='max-w-[260px] truncate'>
{hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`}
{hosts.length === 1
? hosts[0]
: t('toolbar.selectedCount', { count: hosts.length })}
</Badge>
) : null}
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
@@ -338,7 +353,7 @@ export function OverviewToolbar({
size='sm'
className='justify-end'
>
{rangeOptions.map((option) => (
{options.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
+16 -13
View File
@@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { LoadingStateWithBorder } from '@/components/layout/loading';
@@ -45,6 +46,7 @@ function resolveTab(value: string | null): AccessLogTab {
}
function AccessLogsPageContent() {
const t = useTranslations('accessLogs');
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
@@ -152,14 +154,14 @@ function AccessLogsPageContent() {
mutationFn: (retentionDays: number) =>
AccessLogService.cleanup({ retention_days: retentionDays }),
onSuccess: async (result) => {
toast.success(`已清理 ${result.deleted_count} 条日志`);
toast.success(t('cleaned', { count: result.deleted_count }));
setCleanupOpen(false);
await queryClient.invalidateQueries({
queryKey: ['openflare', 'access-logs'],
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '清理失败');
toast.error(error instanceof Error ? error.message : t('cleanupFailed'));
},
});
@@ -213,10 +215,10 @@ function AccessLogsPageContent() {
<div className='flex items-center gap-2'>
<ScrollText className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
<p className='text-sm text-muted-foreground'>
查看访问概览、IP 明细与请求日志。
</p>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
</div>
</div>
<div className='flex gap-2'>
@@ -229,7 +231,7 @@ function AccessLogsPageContent() {
<RefreshCw
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
/>
刷新
{t('refresh')}
</Button>
<Button
variant='destructive'
@@ -237,16 +239,16 @@ function AccessLogsPageContent() {
onClick={() => setCleanupOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
清理日志
{t('cleanup')}
</Button>
</div>
</div>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-lg grid-cols-3'>
<TabsTrigger value='overview'>概览</TabsTrigger>
<TabsTrigger value='ips'>IP 明细</TabsTrigger>
<TabsTrigger value='list'>日志明细</TabsTrigger>
<TabsTrigger value='overview'>{t('tabOverview')}</TabsTrigger>
<TabsTrigger value='ips'>{t('tabIps')}</TabsTrigger>
<TabsTrigger value='list'>{t('tabList')}</TabsTrigger>
</TabsList>
<TabsContent value='overview' className='mt-4'>
@@ -343,12 +345,13 @@ function AccessLogsPageContent() {
}
export default function AccessLogsPage() {
const t = useTranslations('accessLogs');
return (
<Suspense
fallback={
<LoadingStateWithBorder
title='加载访问日志'
description='正在准备页面...'
title={t('loadingTitle')}
description={t('loadingDesc')}
/>
}
>
@@ -2,6 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
@@ -52,6 +53,7 @@ interface CacheManagerProps {
}
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const t = useTranslations('admin.database.cache');
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
const [loadingCache, setLoadingCache] = useState<boolean>(true);
const [savingConfig, setSavingConfig] = useState<boolean>(false);
@@ -73,13 +75,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
setTtlMinutes(data.ttl_minutes.toString());
setLruEnabled(data.lru_enabled);
} catch (err) {
toast.error('获取磁盘缓存状态失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('fetchCacheStatusFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingCache(false);
}
}, []);
}, [t]);
// 保存配置
const handleSaveConfig = async (e: React.FormEvent) => {
@@ -87,14 +89,14 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const size = parseInt(maxSizeMB, 10);
const ttl = parseInt(ttlMinutes, 10);
if (isNaN(size) || size < 1) {
toast.error('保存失败', {
description: '最大容量限制必须是大于等于 1 的整数',
toast.error(t('saveFailed'), {
description: t('maxSizeMustBePositive'),
});
return;
}
if (isNaN(ttl) || ttl < 0) {
toast.error('保存失败', {
description: '默认过期时间必须是大于等于 0 的整数',
toast.error(t('saveFailed'), {
description: t('ttlMustBeNonNegative'),
});
return;
}
@@ -105,11 +107,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
ttl_minutes: ttl,
lru_enabled: lruEnabled,
});
toast.success('保存成功', { description: '磁盘缓存策略已热更新' });
toast.success(t('saveSuccess'), {
description: t('cachePolicyHotUpdated'),
});
await fetchCacheStatus(true);
} catch (err) {
toast.error('保存配置失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('saveConfigFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setSavingConfig(false);
@@ -121,12 +125,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
setClearingCache(true);
try {
await services.adminCache.clearCache();
toast.success('清空成功', { description: '缓存数据已全部清除' });
toast.success(t('clearSuccess'), { description: t('cacheDataCleared') });
setShowClearConfirm(false);
await fetchCacheStatus(true);
} catch (err) {
toast.error('清空缓存失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('clearCacheFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setClearingCache(false);
@@ -149,10 +153,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<div className='space-y-0.5'>
<div className='flex items-center gap-2'>
<HardDrive className='size-4 text-primary animate-pulse' />
<CardTitle className='text-sm font-semibold'>缓存管理</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('cacheManagement')}
</CardTitle>
</div>
<CardDescription className='text-[11px]'>
管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略
{t('cacheManagementDesc')}
</CardDescription>
</div>
@@ -166,7 +172,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<RefreshCw
className={`size-3 ${loadingCache ? 'animate-spin' : ''}`}
/>
刷新状态
{t('refreshStatus')}
</Button>
</CardHeader>
<CardContent className='pt-4'>
@@ -181,13 +187,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* 左边:状态区 (2/5 cols) */}
<div className='lg:col-span-2 space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
运行状态
{t('runtimeStatus')}
</h4>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
{/* 已占空间 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
已用空间
{t('usedSpace')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatBytes(cacheStatus.total_size)}
@@ -196,12 +202,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* Key数量 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
缓存键数量
{t('cacheKeysCount')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatNumber(cacheStatus.keys_count)}{' '}
<span className='text-xs text-muted-foreground font-normal'>
个文件
{t('files')}
</span>
</p>
</div>
@@ -210,7 +216,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* 存储路径 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1.5'>
缓存基准目录
{t('cacheBaseDirectory')}
</p>
<code
className='text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate'
@@ -225,20 +231,20 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
className='h-8 text-xs font-medium w-full'
onClick={() => setShowClearConfirm(true)}
>
立即清空缓存
{t('clearCacheNow')}
</Button>
</div>
{/* 右边:配置区 (3/5 cols) */}
<div className='lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
策略配置
{t('policyConfig')}
</h4>
<form onSubmit={handleSaveConfig} className='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label htmlFor='maxSizeMB' className='text-xs font-medium'>
最大容量限制 (MB)
{t('maxSizeLimit')}
</Label>
<Input
id='maxSizeMB'
@@ -247,17 +253,17 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
value={maxSizeMB}
onChange={(e) => setMaxSizeMB(e.target.value)}
className='h-8 text-xs bg-background/50 border-border/40'
placeholder='例如 100'
placeholder={t('maxSizePlaceholder')}
required
/>
<p className='text-[9px] text-muted-foreground'>
当总大小超出该值时,自动触发淘汰算法
{t('maxSizeHint')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='ttlMinutes' className='text-xs font-medium'>
生存时间限制 (分钟)
{t('ttlLimit')}
</Label>
<Input
id='ttlMinutes'
@@ -266,11 +272,11 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
value={ttlMinutes}
onChange={(e) => setTtlMinutes(e.target.value)}
className='h-8 text-xs bg-background/50 border-border/40'
placeholder='例如 60,0 表示永不过期'
placeholder={t('ttlPlaceholder')}
required
/>
<p className='text-[9px] text-muted-foreground'>
缓存项的最长存活时间,超期后将失效被清理
{t('ttlHint')}
</p>
</div>
</div>
@@ -281,10 +287,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
htmlFor='lruEnabled'
className='text-xs font-semibold block cursor-pointer'
>
启用 LRU 淘汰机制
{t('enableLru')}
</Label>
<span className='text-[10px] text-muted-foreground block'>
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
{t('enableLruDesc')}
</span>
</div>
<Switch
@@ -300,7 +306,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
disabled={savingConfig}
className='h-8 text-xs px-4'
>
{savingConfig ? '正在保存...' : '保存设置'}
{savingConfig ? t('saving') : t('saveSettings')}
</Button>
</div>
</form>
@@ -309,7 +315,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<HardDrive className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未加载到缓存状态信息</span>
<span className='text-xs'>{t('noCacheStatus')}</span>
</div>
)}
</CardContent>
@@ -320,11 +326,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<DialogHeader>
<DialogTitle className='text-sm font-semibold flex items-center gap-2 text-destructive'>
<Trash2 className='size-4' />
确认清空所有缓存?
{t('confirmClearAllCache')}
</DialogTitle>
<DialogDescription className='text-xs text-muted-foreground pt-1'>
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为
0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
{t('confirmClearDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter className='flex gap-2 justify-end mt-4'>
@@ -335,7 +340,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={() => setShowClearConfirm(false)}
disabled={clearingCache}
>
取消
{t('cancel')}
</Button>
<Button
variant='destructive'
@@ -344,7 +349,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={handleClearCache}
disabled={clearingCache}
>
{clearingCache ? '清理中...' : '确认清空'}
{clearingCache ? t('clearing') : t('confirmClear')}
</Button>
</DialogFooter>
</DialogContent>
@@ -6,6 +6,7 @@ import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
import { sql } from '@codemirror/lang-sql';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -49,6 +50,7 @@ interface SQLConsoleProps {
}
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
const t = useTranslations('admin.database.sql');
// SQL 控制台状态
const [sqlQuery, setSqlQuery] = useState<string>('');
const [executingSQL, setExecutingSQL] = useState<boolean>(false);
@@ -97,10 +99,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
try {
const result = await DbManageService.executeSQL(sqlQuery);
setSqlResult(result);
toast.success('SQL 执行成功');
toast.success(t('sqlExecSuccess'));
} catch (err) {
setSqlError(err instanceof Error ? err.message : '未知执行错误');
toast.error('SQL 执行失败');
setSqlError(err instanceof Error ? err.message : t('unknownExecError'));
toast.error(t('sqlExecFailed'));
} finally {
setExecutingSQL(false);
}
@@ -134,7 +136,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<Terminal className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
SQL 查询终端
{t('sqlQueryTerminal')}
</h1>
</div>
</div>
@@ -153,34 +155,34 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<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'>
<Terminal className='size-4 text-primary' />
<span className='text-xs font-semibold'>SQL 编辑器</span>
<span className='text-xs font-semibold'>{t('sqlEditor')}</span>
</div>
<div className='flex items-center gap-3 flex-wrap'>
{/* 快速模板 */}
<div className='flex items-center gap-1.5'>
<span className='text-[11px] text-muted-foreground'>
快速模板:
{t('quickTemplates')}
</span>
<Select onValueChange={handlePresetSQLChange}>
<SelectTrigger className='h-7 w-[180px] text-[11px] bg-background'>
<SelectValue placeholder='选择预设 SQL' />
<SelectValue placeholder={t('selectPresetSQL')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='SELECT * FROM users LIMIT 10;'>
查询用户 (SELECT users)
{t('queryUsers')}
</SelectItem>
<SelectItem value='SELECT * FROM uploads LIMIT 10;'>
查询文件 (SELECT uploads)
{t('queryFiles')}
</SelectItem>
<SelectItem value='SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;'>
查询任务流水 (SELECT task_executions)
{t('queryTaskExecutions')}
</SelectItem>
<SelectItem value='SELECT sqlite_version();'>
SQLite 版本 (SQLite only)
{t('sqliteVersion')}
</SelectItem>
<SelectItem value='SELECT version();'>
PostgreSQL 版本 (Postgres only)
{t('postgresVersion')}
</SelectItem>
</SelectContent>
</Select>
@@ -193,7 +195,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
size='icon'
className='h-7 w-7 text-muted-foreground hover:text-foreground'
onClick={() => setSqlQuery('')}
title='清空编辑器'
title={t('clearEditor')}
>
<Trash2 className='size-3.5' />
</Button>
@@ -204,7 +206,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
disabled={executingSQL || !sqlQuery.trim()}
>
<Play className='size-3' />
{executingSQL ? '运行中...' : '运行'}
{executingSQL ? t('running') : t('run')}
</Button>
</div>
</div>
@@ -236,7 +238,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div
onMouseDown={handleMouseDown}
className='h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
title='拖动调整大小'
title={t('dragResize')}
>
<div className='w-8 h-1 rounded bg-muted-foreground/30' />
</div>
@@ -245,10 +247,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex-1 min-h-[100px] 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'>
<span className='font-semibold'>执行输出</span>
<span className='font-semibold'>{t('executionOutput')}</span>
{sqlResult && (
<span>
类型: {sqlResult.type.toUpperCase()} | 耗时:{' '}
{t('type')}: {sqlResult.type.toUpperCase()} | {t('duration')}:{' '}
{sqlResult.execution_time_ms} ms
</span>
)}
@@ -260,14 +262,14 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex flex-col items-center justify-center h-full py-10 space-y-2'>
<RefreshCw className='size-5 text-primary animate-spin' />
<span className='text-xs text-muted-foreground'>
正在在数据库执行查询,请稍候...
{t('executingQuery')}
</span>
</div>
)}
{sqlError && (
<div className='bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]'>
<p className='font-semibold mb-1'>SQL 执行报错 (Error):</p>
<p className='font-semibold mb-1'>{t('execErrorTitle')}</p>
<pre className='whitespace-pre-wrap'>{sqlError}</pre>
</div>
)}
@@ -319,7 +321,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
colSpan={sqlResult.columns.length}
className='text-center py-10 text-muted-foreground'
>
查询结果为空
{t('queryResultEmpty')}
</TableCell>
</TableRow>
)}
@@ -331,11 +333,13 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{sqlResult.type === 'exec' && (
<div className='bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto'>
<p className='text-foreground text-sm font-semibold'>
SQL 执行成功
{t('sqlExecSuccessTitle')}
</p>
<p className='text-muted-foreground mt-2'>
受影响行数: {sqlResult.affected_rows} 行,耗时{' '}
{sqlResult.execution_time_ms} 毫秒。
{t('affectedRows', {
rows: sqlResult.affected_rows,
ms: sqlResult.execution_time_ms,
})}
</p>
</div>
)}
@@ -346,7 +350,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60'>
<Terminal className='size-8 mb-2' />
<span className='text-xs'>
编辑器就绪,请在上方编写 SQL 并运行查看结果
{t('editorReady')}
</span>
</div>
)}
@@ -2,6 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
@@ -67,6 +68,7 @@ export function TableBrowser({
loadingTables,
refreshTrigger,
}: TableBrowserProps) {
const t = useTranslations('admin.database.tableBrowser');
const [selectedTable, setSelectedTable] = useState<string>('');
const [tableData, setTableData] = useState<TableDataResponse | null>(null);
const [page, setPage] = useState<number>(1);
@@ -86,14 +88,14 @@ export function TableBrowser({
});
setTableData(data);
} catch (err) {
toast.error(`获取数据表 ${tableName} 数据失败`, {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('fetchTableFailed', { table: tableName }), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingData(false);
}
},
[],
[t],
);
// 默认选择第一张表
@@ -125,9 +127,11 @@ export function TableBrowser({
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
<CardHeader className='pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>数据表浏览器</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('tableBrowser')}
</CardTitle>
<CardDescription className='text-[11px]'>
浏览数据库中的物理数据表详情及内容
{t('tableBrowserDesc')}
</CardDescription>
</div>
@@ -137,7 +141,7 @@ export function TableBrowser({
) : (
<Select value={selectedTable} onValueChange={handleTableChange}>
<SelectTrigger className='h-8 w-[200px] text-xs bg-background border-border/40'>
<SelectValue placeholder='选择数据表' />
<SelectValue placeholder={t('selectTable')} />
</SelectTrigger>
<SelectContent className='max-h-[300px]'>
{tables.map((t) => (
@@ -202,7 +206,7 @@ export function TableBrowser({
colSpan={tableData.columns.length}
className='text-center py-10 text-muted-foreground'
>
表中无记录数据
{t('noRecordsInTable')}
</TableCell>
</TableRow>
)}
@@ -213,11 +217,11 @@ export function TableBrowser({
{/* 分页控制 */}
<div className='flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed'>
<div>
总行数:{' '}
{t('totalRows')}:{' '}
<span className='font-mono text-foreground font-semibold'>
{formatNumber(tableData.total)}
</span>{' '}
条记录
{t('records')}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -227,11 +231,13 @@ export function TableBrowser({
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || loadingData}
>
上一页
{t('prevPage')}
</Button>
<span className='text-xs px-2 font-mono'>
第 {page} /{' '}
{Math.max(1, Math.ceil(tableData.total / pageSize))} 页
{t('pageIndicator', {
current: page,
total: Math.max(1, Math.ceil(tableData.total / pageSize)),
})}
</span>
<Button
variant='outline'
@@ -242,7 +248,7 @@ export function TableBrowser({
page >= Math.ceil(tableData.total / pageSize) || loadingData
}
>
下一页
{t('nextPage')}
</Button>
</div>
</div>
@@ -250,7 +256,7 @@ export function TableBrowser({
) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<Layers className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未选择数据表或表结构无法加载</span>
<span className='text-xs'>{t('noTableSelected')}</span>
</div>
)}
</CardContent>
@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
Activity,
@@ -57,6 +58,7 @@ const formatNumber = (num: number | string) => {
};
export function DatabasePageClient() {
const t = useTranslations('admin.database');
// 核心状态
const [overview, setOverview] = useState<DBOverview | null>(null);
const [tables, setTables] = useState<string[]>([]);
@@ -75,13 +77,13 @@ export function DatabasePageClient() {
const data = await services.dbManage.getOverview();
setOverview(data);
} catch (err) {
toast.error('获取数据库概览失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('fetchOverviewFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingOverview(false);
}
}, []);
}, [t]);
// 2. 获取表列表
const fetchTables = useCallback(async () => {
@@ -90,13 +92,13 @@ export function DatabasePageClient() {
const data = await services.dbManage.listTables();
setTables(data);
} catch (err) {
toast.error('获取数据库数据表列表失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('fetchTablesFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingTables(false);
}
}, []);
}, [t]);
// 3. 协调刷新
const handleRefreshAll = () => {
@@ -118,12 +120,12 @@ export function DatabasePageClient() {
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success('数据库导出成功', {
description: `已下载归档文件: ${filename}`,
toast.success(t('exportSuccess'), {
description: t('exportSuccessDesc', { filename }),
});
} catch (err) {
toast.error('数据库导出失败', {
description: err instanceof Error ? err.message : '导出异常',
toast.error(t('exportFailed'), {
description: err instanceof Error ? err.message : t('exportException'),
});
} finally {
setExporting(false);
@@ -170,7 +172,9 @@ export function DatabasePageClient() {
<div className='flex items-center gap-2'>
<Database className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>数据管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
<Button
@@ -183,7 +187,7 @@ export function DatabasePageClient() {
<RefreshCw
className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`}
/>
刷新数据
{t('refreshData')}
</Button>
</div>
@@ -196,7 +200,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
数据库类型
{t('dbType')}
</CardDescription>
<Server className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -211,7 +215,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
版本信息
{t('versionInfo')}
</CardDescription>
<Cpu className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -229,7 +233,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
名称/路径
{t('nameOrPath')}
</CardDescription>
<Database className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -249,7 +253,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
数据库大小
{t('dbSize')}
</CardDescription>
<HardDrive className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -262,7 +266,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
物理数据表
{t('physicalTables')}
</CardDescription>
<Layers className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -277,7 +281,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
活跃连接数
{t('activeConnections')}
</CardDescription>
<Activity className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -303,9 +307,11 @@ export function DatabasePageClient() {
{/* 4. 底部功能卡片区 */}
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
<CardHeader className='pb-3 border-b border-dashed'>
<CardTitle className='text-sm font-semibold'>功能区</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('featureArea')}
</CardTitle>
<CardDescription className='text-[11px]'>
数据库导出及自定义高级 SQL 执行终端
{t('featureAreaDesc')}
</CardDescription>
</CardHeader>
<CardContent className='pt-4'>
@@ -315,11 +321,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'>
<Download className='size-4 text-primary' />
数据库备份导出
{t('dbBackupExport')}
</p>
<p className='text-[10px] text-muted-foreground'>
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL
导出为打包的 .sql 文本文件)
{t('dbBackupExportDesc')}
</p>
</div>
<Button
@@ -332,7 +337,7 @@ export function DatabasePageClient() {
<RefreshCw
className={`size-3 ${exporting ? 'animate-spin' : ''}`}
/>
{exporting ? '正在准备...' : '开始导出'}
{exporting ? t('preparing') : t('startExport')}
</Button>
</div>
@@ -341,11 +346,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'>
<Terminal className='size-4 text-primary' />
SQL 查询控台
{t('sqlQueryConsole')}
</p>
<p className='text-[10px] text-muted-foreground'>
打开在线 SQL 控制终端,执行原生的 SQL
进行数据筛选、更新、调试或性能优化
{t('sqlQueryConsoleDesc')}
</p>
</div>
<Button
@@ -356,7 +360,7 @@ export function DatabasePageClient() {
}}
>
<FileText className='size-3.5' />
进入控台
{t('enterConsole')}
</Button>
</div>
</div>
+31 -29
View File
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
import {
@@ -21,6 +22,7 @@ import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() {
const t = useTranslations('admin.demo');
const [activeTab, setActiveTab] = React.useState('dashboard');
return (
@@ -30,7 +32,7 @@ export default function HeaderDemoPage() {
<Code className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
开发规范示例
{t('pageTitle')}
</h1>
</div>
</div>
@@ -40,28 +42,28 @@ export default function HeaderDemoPage() {
<CardHeader className='pb-3'>
<div className='flex items-center gap-2'>
<Code className='size-4 text-primary' />
<CardTitle className='text-base font-semibold'>规范说明</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('specTitle')}
</CardTitle>
</div>
<CardDescription>
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
</CardDescription>
<CardDescription>{t('specDescription')}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<h3 className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
核心规范细节与编写原则:
{t('coreSpecTitle')}
</h3>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
<strong>结构专注</strong>
:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。
<strong>{t('rules.structureFocus.title')}</strong>
{t('rules.structureFocus.description')}
</li>
<li>
<strong>容器与上边距对齐</strong>:使用{' '}
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
作为标题行基础容器(不含下边框),外层统一使用{' '}
{t('rules.containerAlignment.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1
</code>{' '}
@@ -69,53 +71,53 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6
</code>
) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。
) {t('rules.containerAlignment.description2')}
</li>
<li>
<strong>图标呈现</strong>:直接将 Lucide
图标组件嵌套于标题容器中,应用{' '}
<strong>{t('rules.iconPresentation.title')}</strong>:直接将
Lucide 图标组件嵌套于标题容器中,应用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary
</code>{' '}
样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。
{t('rules.iconPresentation.description')}
</li>
<li>
<strong>文本样式标准</strong>:文本使用且仅使用{' '}
<strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
,禁用字重加粗(如{' '}
{t('rules.textStyle.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-bold
</code>
)或任何渐变色,保证全站字体一致性。
{t('rules.textStyle.description2')}
</li>
<li>
<strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab
页切换的页面,**禁止**将所有 Tab
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
或就近放在{' '}
{t('rules.tabsModular.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/
</code>{' '}
下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。
{t('rules.tabsModular.description2')}
</li>
<li>
<strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs
切换,当一个页面代码行数超过 600
行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如{' '}
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
Tabs 切换,当一个页面代码行数超过 600
{t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
</code>
),只有真正跨页面复用的组件才进入全局{' '}
{t('rules.complexityDriven.description2')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/common/
</code>
。典型标杆案例参考“数据管理” (
{t('rules.complexityDriven.description3')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database
</code>
@@ -145,19 +147,19 @@ export default function HeaderDemoPage() {
value='dashboard'
className='px-0 pb-2 text-xs font-semibold'
>
仪表盘与指标卡片
{t('tabs.dashboard')}
</TabsTrigger>
<TabsTrigger
value='table'
className='px-0 pb-2 text-xs font-semibold'
>
数据表格规范
{t('tabs.table')}
</TabsTrigger>
<TabsTrigger
value='controls'
className='px-0 pb-2 text-xs font-semibold'
>
输入控件与表单
{t('tabs.controls')}
</TabsTrigger>
</TabsList>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
import {
Card,
@@ -25,6 +26,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
export function ControlsTab() {
const t = useTranslations('admin.demo.controls');
const [formData, setFormData] = React.useState({
name: '',
category: 'database',
@@ -38,11 +40,9 @@ export function ControlsTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold'>
表单与输入框交互演示
{t('formDemoTitle')}
</CardTitle>
<CardDescription>
包含按钮变体、下拉选择、多选框及基础输入框的标准样式
</CardDescription>
<CardDescription>{t('formDemoDesc')}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{/* 输入框 */}
@@ -51,7 +51,7 @@ export function ControlsTab() {
htmlFor='task-name'
className='text-xs font-medium text-foreground'
>
任务名称
{t('taskName')}
</Label>
<Input
id='task-name'
@@ -59,18 +59,18 @@ export function ControlsTab() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder='输入要创建的任务描述...'
placeholder={t('taskNamePlaceholder')}
className='h-8 text-xs shadow-none bg-background focus-visible:ring-1'
/>
<p className='text-[10px] text-muted-foreground'>
任务名称将用于后台执行记录的可读性显示
{t('taskNameHint')}
</p>
</div>
{/* 下拉框 */}
<div className='space-y-1.5'>
<Label className='text-xs font-medium text-foreground'>
任务分类
{t('taskCategory')}
</Label>
<Select
value={formData.category}
@@ -82,9 +82,11 @@ export function ControlsTab() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='database'>数据库运维</SelectItem>
<SelectItem value='sync'>数据批量同步</SelectItem>
<SelectItem value='cleanup'>过期磁盘清理</SelectItem>
<SelectItem value='database'>
{t('categoryDatabase')}
</SelectItem>
<SelectItem value='sync'>{t('categorySync')}</SelectItem>
<SelectItem value='cleanup'>{t('categoryCleanup')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -107,10 +109,10 @@ export function ControlsTab() {
htmlFor='allow-notify'
className='text-xs font-medium text-foreground cursor-pointer select-none leading-none'
>
允许触发异步通知推送
{t('allowNotify')}
</label>
<p className='text-[10px] text-muted-foreground leading-normal'>
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。
{t('allowNotifyDesc')}
</p>
</div>
</div>
@@ -118,32 +120,32 @@ export function ControlsTab() {
{/* 按钮变体 */}
<div className='pt-2'>
<div className='text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2'>
按钮样式对齐:
{t('buttonStyles')}
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' className='h-8 text-xs shadow-none'>
主要操作 (Primary)
{t('primaryButton')}
</Button>
<Button
variant='secondary'
size='sm'
className='h-8 text-xs shadow-none'
>
次要操作 (Secondary)
{t('secondaryButton')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8 border-dashed text-xs shadow-none'
>
辅助线框 (Outline)
{t('outlineButton')}
</Button>
<Button
variant='ghost'
size='sm'
className='h-8 text-xs hover:bg-muted'
>
幽灵按钮 (Ghost)
{t('ghostButton')}
</Button>
</div>
</div>
@@ -155,13 +157,13 @@ export function ControlsTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表单设计规范
{t('formDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4 text-xs text-muted-foreground'>
<div className='space-y-2 leading-relaxed'>
<p className='font-semibold text-foreground text-[11px]'>
1. 统一控件高度与投影:
{t('spec.unifiedHeight')}
</p>
<p>
输入框 (
@@ -188,7 +190,7 @@ export function ControlsTab() {
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
2. Checkbox 与文本的对齐:
{t('spec.checkboxAlignment')}
</p>
<p>
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
@@ -203,7 +205,7 @@ export function ControlsTab() {
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
3. 按钮使用策略:
{t('spec.buttonStrategy')}
</p>
<p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
@@ -4,11 +4,13 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Cpu, Database, HardDrive } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
export function DashboardTab() {
const t = useTranslations('admin.demo.dashboard');
return (
<div className='space-y-6'>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
@@ -16,7 +18,7 @@ export function DashboardTab() {
<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'>
容器 CPU 使用率
{t('cpuUsage')}
</span>
<Cpu className='size-4 text-primary' />
</CardHeader>
@@ -25,7 +27,7 @@ export function DashboardTab() {
<Progress value={42.8} className='h-1.5' />
<p className='text-[10px] text-muted-foreground flex items-center gap-1'>
<span className='size-1.5 rounded-full bg-emerald-500 inline-block animate-pulse' />
容器运行正常,负载平稳
{t('cpuStatus')}
</p>
</CardContent>
</Card>
@@ -34,7 +36,7 @@ export function DashboardTab() {
<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'>
存储已用容量
{t('storageUsage')}
</span>
<HardDrive className='size-4 text-primary' />
</CardHeader>
@@ -45,7 +47,7 @@ export function DashboardTab() {
</div>
<Progress value={72.4} className='h-1.5' />
<p className='text-[10px] text-muted-foreground'>
数据已同步远端 S3 存储桶,支持自动过期
{t('storageStatus')}
</p>
</CardContent>
</Card>
@@ -54,7 +56,7 @@ export function DashboardTab() {
<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'>
物理数据库
{t('database')}
</span>
<Database className='size-4 text-primary' />
</CardHeader>
@@ -67,16 +69,16 @@ export function DashboardTab() {
</div>
<div className='flex items-center gap-3 text-xs text-muted-foreground'>
<div>
活跃连接:{' '}
{t('activeConnections')}{' '}
<span className='font-semibold text-foreground'>12</span>
</div>
<div>
总表数量:{' '}
{t('totalTables')}{' '}
<span className='font-semibold text-foreground'>34</span>
</div>
</div>
<p className='text-[10px] text-muted-foreground'>
链接池自动维护中,最大连接限制为 100
{t('dbStatus')}
</p>
</CardContent>
</Card>
@@ -87,45 +89,45 @@ export function DashboardTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
指标卡片 & 仪表盘设计规范
{t('dashboardDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
指标卡片统一采用{' '}
{t('spec.dashedBorder')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
作为边框和投影。
{t('spec.dashedBorder2')}
</li>
<li>
头部使用{' '}
{t('spec.headerLayout')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex flex-row items-center justify-between pb-2
</code>{' '}
布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。
{t('spec.headerLayout2')}
</li>
<li>
数值强调使用且仅使用{' '}
{t('spec.valueStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-2xl font-semibold tracking-tight
</code>{' '}
展示。
{t('spec.valueStyle2')}
</li>
<li>
进度条使用{' '}
{t('spec.progressBar')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-1.5
</code>{' '}
细条高度,不喧宾夺主。
{t('spec.progressBar2')}
</li>
<li>
底部的次要说明信息必须统一使用{' '}
{t('spec.secondaryText')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-[10px] text-muted-foreground
</code>{' '}
等辅助类。
{t('spec.secondaryText2')}
</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'>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -19,27 +20,28 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() {
const t = useTranslations('admin.demo.table');
const mockTableData = [
{
id: 'TX-1002',
name: '用户信息批量同步导出',
type: '数据同步',
name: t('mockData.syncExport'),
type: t('mockType.dataSync'),
status: 'success',
duration: '1.2s',
time: '2026-06-15 14:22',
},
{
id: 'TX-1003',
name: '系统数据库每周自动备份归档',
type: '数据库运维',
name: t('mockData.dbBackup'),
type: t('mockType.dbOps'),
status: 'running',
duration: '在执行中',
duration: t('mockDuration.running'),
time: '2026-06-15 15:00',
},
{
id: 'TX-1004',
name: '远端S3过期缓存对象自动清理任务',
type: '磁盘清理',
name: t('mockData.s3Cleanup'),
type: t('mockType.diskCleanup'),
status: 'failed',
duration: '0.4s',
time: '2026-06-15 10:05',
@@ -54,7 +56,7 @@ export function TableTab() {
<div className='relative w-64'>
<Search className='absolute left-2.5 top-2.5 size-3 text-muted-foreground' />
<Input
placeholder='输入任务名称搜索...'
placeholder={t('searchPlaceholder')}
className='h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background'
/>
</div>
@@ -64,12 +66,12 @@ export function TableTab() {
className='h-8 border-dashed text-xs shadow-none'
>
<ListFilter className='size-3 mr-1' />
过滤
{t('filter')}
</Button>
</div>
<Button size='sm' className='h-8 text-xs shadow-none'>
<Plus className='size-3.5 mr-1' />
新建任务
{t('newTask')}
</Button>
</div>
@@ -79,25 +81,25 @@ export function TableTab() {
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='w-[100px] text-xs font-semibold'>
任务编号
{t('columns.taskId')}
</TableHead>
<TableHead className='text-xs font-semibold'>
执行任务名称
{t('columns.taskName')}
</TableHead>
<TableHead className='w-[120px] text-xs font-semibold'>
任务类型
{t('columns.taskType')}
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
状态
{t('columns.status')}
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
执行耗时
{t('columns.duration')}
</TableHead>
<TableHead className='w-[150px] text-xs font-semibold'>
触发时间
{t('columns.triggerTime')}
</TableHead>
<TableHead className='w-[80px] text-xs font-semibold text-right'>
操作
{t('columns.actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -123,7 +125,7 @@ export function TableTab() {
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
成功
{t('status.success')}
</Badge>
)}
{row.status === 'running' && (
@@ -132,7 +134,7 @@ export function TableTab() {
className='text-[10px] bg-blue-500/10 border-blue-500/20 text-blue-600 rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-blue-500 rounded-full mr-1.5 shrink-0 animate-pulse' />
运行中
{t('status.running')}
</Badge>
)}
{row.status === 'failed' && (
@@ -141,7 +143,7 @@ export function TableTab() {
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败
{t('status.failed')}
</Badge>
)}
</TableCell>
@@ -180,13 +182,13 @@ export function TableTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表格设计规范与代码模板
{t('tableDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
表格容器必须统一使用带有虚线边框的容器,即类名:
{t('spec.container')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
@@ -194,40 +196,40 @@ export function TableTab() {
。
</li>
<li>
表头背景设为{' '}
{t('spec.headerBg')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
bg-muted/40
</code>
。表头表项字号统一为小字粗体{' '}
{t('spec.headerStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold
</code>
。
</li>
<li>
单元格行与列之间使用虚线分割(
{t('spec.cellDivider')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed
</code>
)。表格行悬浮交互采用温和过渡色{' '}
{t('spec.rowHover')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors
</code>
。
</li>
<li>
编码、主键或唯一 ID 类字段一律采用等宽字体呈现:
{t('spec.monoFont')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs
</code>
。
</li>
<li>
状态标签(Badge)尽量圆角化(
{t('spec.badgeStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
rounded-full py-0 px-2
</code>
),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。
{t('spec.badgeStyle2')}
</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'>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import {
@@ -81,6 +82,7 @@ function formatDate(dateStr: string) {
}
export function FileList() {
const t = useTranslations('admin.files');
const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState('');
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
@@ -157,10 +159,10 @@ export function FileList() {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
toast.success('文件已删除');
toast.success(t('list.deleteSuccess'));
setDeleteTarget(null);
},
onError: (err: Error) => toast.error(err.message || '删除失败'),
onError: (err: Error) => toast.error(err.message || t('list.deleteFailed')),
});
// 批量 ZIP 下载
@@ -173,9 +175,9 @@ export function FileList() {
a.download = 'batch_download.zip';
a.click();
URL.revokeObjectURL(url);
toast.success('批量下载已开始');
toast.success(t('list.batchDownloadSuccess'));
},
onError: () => toast.error('批量下载失败'),
onError: () => toast.error(t('list.batchDownloadFailed')),
});
const toggleSelect = (id: string) => {
@@ -209,7 +211,7 @@ export function FileList() {
<div className='relative flex-1 max-w-sm'>
<Search className='absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground' />
<Input
placeholder='搜索文件名...'
placeholder={t('list.searchPlaceholder')}
className='pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0'
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
@@ -232,7 +234,9 @@ export function FileList() {
selectedIds.size === files.length ? clearSelection : selectAll
}
>
{selectedIds.size === files.length ? '取消全选' : '全选本页'}
{selectedIds.size === files.length
? t('list.deselectAll')
: t('list.selectAll')}
</Button>
)}
@@ -247,7 +251,7 @@ export function FileList() {
className='flex items-center gap-2'
>
<Badge variant='secondary' className='text-xs px-2.5'>
已选 {selectedIds.size} 个
{t('list.selectedCount', { count: selectedIds.size })}
</Badge>
<Button
size='sm'
@@ -261,7 +265,7 @@ export function FileList() {
) : (
<FileArchive className='size-3.5 mr-1' />
)}
打包下载
{t('list.batchDownload')}
</Button>
<Button
size='sm'
@@ -278,7 +282,7 @@ export function FileList() {
{total > 0 && (
<span className='text-xs text-muted-foreground shrink-0'>
共 {total} 个文件
{t('list.totalFiles', { count: total })}
</span>
)}
</div>
@@ -292,7 +296,7 @@ export function FileList() {
<div className='flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground'>
<Upload className='size-12 text-muted-foreground/30' />
<p className='text-sm'>
{debouncedKeyword ? '没有匹配的文件' : '您还没有上传任何文件'}
{debouncedKeyword ? t('list.noMatch') : t('list.noFiles')}
</p>
</div>
) : (
@@ -309,18 +313,28 @@ export function FileList() {
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className='w-[80px] py-3'>预览</TableHead>
<TableHead className='w-[180px] py-3'>ID</TableHead>
<TableHead className='py-3'>文件名</TableHead>
<TableHead className='max-w-[200px] truncate py-3'>
路径
<TableHead className='w-[80px] py-3'>
{t('list.colPreview')}
</TableHead>
<TableHead className='w-[180px] py-3'>ID</TableHead>
<TableHead className='py-3'>{t('list.colName')}</TableHead>
<TableHead className='max-w-[200px] truncate py-3'>
{t('list.colPath')}
</TableHead>
<TableHead className='w-[100px] py-3'>
{t('list.colType')}
</TableHead>
<TableHead className='w-[125px] py-3'>
{t('list.colMime')}
</TableHead>
<TableHead className='w-[100px] py-3'>
{t('list.colSize')}
</TableHead>
<TableHead className='w-[150px] py-3'>
{t('list.colUploadedAt')}
</TableHead>
<TableHead className='w-[100px] py-3'>业务类别</TableHead>
<TableHead className='w-[125px] py-3'>MIME类型</TableHead>
<TableHead className='w-[100px] py-3'>大小</TableHead>
<TableHead className='w-[150px] py-3'>上传时间</TableHead>
<TableHead className='w-[120px] text-right pr-4 py-3'>
操作
{t('list.colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -396,7 +410,7 @@ export function FileList() {
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
title='查看详情'
title={t('list.viewDetail')}
onClick={() => setDetailTarget(file)}
>
<Eye className='size-3.5' />
@@ -405,7 +419,7 @@ export function FileList() {
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted'
title='下载'
title={t('list.download')}
onClick={() => handleDownload(file)}
>
<Download className='size-3.5' />
@@ -414,7 +428,7 @@ export function FileList() {
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
title='删除'
title={t('list.delete')}
onClick={() => setDeleteTarget(file)}
>
<Trash2 className='size-3.5' />
@@ -439,7 +453,7 @@ export function FileList() {
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
{t('list.prevPage')}
</Button>
<span className='text-xs text-muted-foreground'>
{page} / {totalPages}
@@ -451,7 +465,7 @@ export function FileList() {
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
{t('list.nextPage')}
</Button>
</div>
)}
@@ -463,9 +477,9 @@ export function FileList() {
>
<SheetContent className='w-full sm:max-w-md overflow-y-auto p-6'>
<SheetHeader className='border-b pb-4 px-0'>
<SheetTitle>文件详情</SheetTitle>
<SheetTitle>{t('list.detailTitle')}</SheetTitle>
<SheetDescription>
查看文件的完整元数据与配置属性。
{t('list.detailDesc')}
</SheetDescription>
</SheetHeader>
@@ -485,7 +499,9 @@ export function FileList() {
<div className='flex flex-col items-center gap-3'>
{getFileIcon(detailTarget.mime_type, 'size-14')}
<span className='text-xs font-medium text-muted-foreground uppercase'>
{detailTarget.extension} 文件
{t('list.extensionFile', {
ext: detailTarget.extension,
})}
</span>
</div>
)}
@@ -495,7 +511,7 @@ export function FileList() {
<div className='space-y-4 text-xs'>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件 ID
{t('list.detailId')}
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.id}
@@ -504,7 +520,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件名
{t('list.detailName')}
</span>
<span className='col-span-2 break-all font-medium text-foreground/90'>
{detailTarget.file_name}
@@ -513,7 +529,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储路径
{t('list.detailPath')}
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.file_path}
@@ -522,17 +538,18 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
大小
{t('list.detailSize')}
</span>
<span className='col-span-2 text-foreground/90'>
{formatFileSize(detailTarget.file_size)} (
{detailTarget.file_size.toLocaleString()} 字节)
{detailTarget.file_size.toLocaleString()} {t('list.bytes')}
)
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
MIME 类型
{t('list.detailMime')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.mime_type}
@@ -541,7 +558,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件后缀
{t('list.detailExtension')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.extension}
@@ -559,7 +576,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
业务类型
{t('list.detailBizType')}
</span>
<span className='col-span-2'>
<Badge
@@ -573,7 +590,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储驱动
{t('list.detailDriver')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{storageDriverQuery.data ?? 'local'}
@@ -582,7 +599,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传者 ID
{t('list.detailUploaderId')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.user_id}
@@ -591,7 +608,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传时间
{t('list.detailUploadedAt')}
</span>
<span className='col-span-2 text-foreground/90'>
{formatDate(detailTarget.created_at)}
@@ -602,7 +619,7 @@ export function FileList() {
Object.keys(detailTarget.metadata).length > 0 && (
<div className='space-y-1.5 pt-2'>
<span className='text-muted-foreground font-medium block'>
额外元数据
{t('list.detailMetadata')}
</span>
<pre className='p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto'>
{JSON.stringify(detailTarget.metadata, null, 2)}
@@ -622,18 +639,16 @@ export function FileList() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogTitle>{t('list.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{' '}
<span className='font-semibold text-foreground'>
「{deleteTarget?.file_name}」
</span>{' '}
吗?此操作不可撤销。
{t('list.deleteConfirm', {
name: deleteTarget?.file_name ?? '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{t('list.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
@@ -645,7 +660,7 @@ export function FileList() {
{deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
)}
确认删除
{t('list.confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import {
Database,
@@ -49,42 +50,47 @@ const categoryMap: Record<string, string> = {
其他: 'others',
};
const statsChartConfig = {
images: {
label: '图片',
color: 'hsl(var(--chart-1))',
},
videos: {
label: '视频',
color: 'hsl(var(--chart-2))',
},
audio: {
label: '音频',
color: 'hsl(var(--chart-3))',
},
documents: {
label: '文档',
color: 'hsl(var(--chart-4))',
},
archives: {
label: '压缩包',
color: 'hsl(var(--chart-5))',
},
others: {
label: '其他',
color: 'hsl(var(--chart-6))',
},
count: {
label: '新增文件数',
color: 'hsl(var(--primary))',
},
size: {
label: '新增大小',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
function useStatsChartConfig() {
const t = useTranslations('admin.files');
return {
images: {
label: t('stats.catImages'),
color: 'hsl(var(--chart-1))',
},
videos: {
label: t('stats.catVideos'),
color: 'hsl(var(--chart-2))',
},
audio: {
label: t('stats.catAudio'),
color: 'hsl(var(--chart-3))',
},
documents: {
label: t('stats.catDocuments'),
color: 'hsl(var(--chart-4))',
},
archives: {
label: t('stats.catArchives'),
color: 'hsl(var(--chart-5))',
},
others: {
label: t('stats.catOthers'),
color: 'hsl(var(--chart-6))',
},
count: {
label: t('stats.chartLabelCount'),
color: 'hsl(var(--primary))',
},
size: {
label: t('stats.chartLabelSize'),
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
}
export function FileStats() {
const t = useTranslations('admin.files');
const statsChartConfig = useStatsChartConfig();
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
'count',
);
@@ -137,11 +143,16 @@ export function FileStats() {
const maxStats = React.useMemo(() => {
if (!stats?.categories || stats.categories.length === 0) {
return { maxCountName: '无', maxCount: 0, maxSizeName: '无', maxSize: 0 };
return {
maxCountName: t('stats.none'),
maxCount: 0,
maxSizeName: t('stats.none'),
maxSize: 0,
};
}
let maxCountName = '无';
let maxCountName = t('stats.none');
let maxCount = 0;
let maxSizeName = '无';
let maxSizeName = t('stats.none');
let maxSize = 0;
stats.categories.forEach((cat) => {
@@ -156,7 +167,7 @@ export function FileStats() {
});
return { maxCountName, maxCount, maxSizeName, maxSize };
}, [stats?.categories]);
}, [stats?.categories, t]);
const trendSummary = React.useMemo(() => {
if (!stats?.trend) return { count: 0, size: 0 };
@@ -182,9 +193,9 @@ export function FileStats() {
<div className='flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4'>
<Upload className='size-14 text-muted-foreground/25' />
<div className='text-center space-y-1'>
<p className='font-medium'>暂无上传数据</p>
<p className='font-medium'>{t('stats.noData')}</p>
<p className='text-xs text-muted-foreground'>
上传文件后即可在此查看容量与文件分布分析。
{t('stats.noDataDesc')}
</p>
</div>
</div>
@@ -198,7 +209,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
总文件数
{t('stats.totalFiles')}
</CardTitle>
<Files className='size-4 text-sky-500' />
</CardHeader>
@@ -206,11 +217,11 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-sky-500'>
{stats.total_count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
个
{t('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
您上传的可用文件数量汇总
{t('stats.totalFilesDesc')}
</p>
</CardContent>
</Card>
@@ -218,7 +229,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
占用存储容量
{t('stats.totalSize')}
</CardTitle>
<Database className='size-4 text-emerald-500' />
</CardHeader>
@@ -227,7 +238,7 @@ export function FileStats() {
{formatFileSize(stats.total_size)}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
文件实际在磁盘/S3中占用的总空间
{t('stats.totalSizeDesc')}
</p>
</CardContent>
</Card>
@@ -235,7 +246,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
近 7 天新增文件
{t('stats.weeklyNewFiles')}
</CardTitle>
<TrendingUp className='size-4 text-indigo-500' />
</CardHeader>
@@ -243,11 +254,11 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-indigo-500'>
+{trendSummary.count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
个
{t('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传成功的文件总数
{t('stats.weeklyNewFilesDesc')}
</p>
</CardContent>
</Card>
@@ -255,7 +266,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
近 7 天新增大小
{t('stats.weeklyNewSize')}
</CardTitle>
<HardDrive className='size-4 text-amber-500' />
</CardHeader>
@@ -264,7 +275,7 @@ export function FileStats() {
+{formatFileSize(trendSummary.size)}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传所消耗的存储带宽
{t('stats.weeklyNewSizeDesc')}
</p>
</CardContent>
</Card>
@@ -275,10 +286,10 @@ export function FileStats() {
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-sm font-bold'>
最近 7 天新增趋势
{t('stats.trendTitle')}
</CardTitle>
<CardDescription className='text-xs'>
按日统计近一周期内的上传波动
{t('stats.trendDesc')}
</CardDescription>
</div>
<div className='flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30'>
@@ -288,7 +299,7 @@ export function FileStats() {
className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('count')}
>
新增数量 (个)
{t('stats.trendCount')}
</Button>
<Button
size='sm'
@@ -296,7 +307,7 @@ export function FileStats() {
className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('size')}
>
新增大小 (MB)
{t('stats.trendSize')}
</Button>
</div>
</CardHeader>
@@ -361,11 +372,13 @@ export function FileStats() {
hideLabel
formatter={(value) => {
const label =
trendMetric === 'count' ? '新增文件数' : '新增大小';
trendMetric === 'count'
? t('stats.chartLabelCount')
: t('stats.chartLabelSize');
const formattedValue =
trendMetric === 'size'
? formatFileSize(Number(value))
: `${value} 个`;
: `${value} ${t('stats.countUnit')}`;
return (
<>
<div className='flex items-center gap-1.5'>
@@ -419,16 +432,16 @@ export function FileStats() {
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className='text-sm font-bold'>
文件格式数量分布
{t('stats.countDistTitle')}
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件个数占比
{t('stats.countDistDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
{categoryCountData.length === 0 ? (
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无分类数据
{t('stats.noCategoryData')}
</div>
) : (
<div className='h-[240px] w-full'>
@@ -475,7 +488,7 @@ export function FileStats() {
</span>
</div>
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{value} 个
{value} {t('stats.countUnit')}
</span>
</>
);
@@ -498,15 +511,10 @@ export function FileStats() {
<div className='bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400'>
<Info className='size-4 shrink-0 mt-0.5' />
<p className='leading-normal'>
文件类型中数量最多的是{' '}
<strong className='font-bold'>
「{maxStats.maxCountName}」
</strong>
,累计上传了{' '}
<strong className='font-mono font-bold'>
{maxStats.maxCount}
</strong>{' '}
个文件。
{t('stats.maxCountHint', {
name: maxStats.maxCountName,
count: maxStats.maxCount,
})}
</p>
</div>
</CardContent>
@@ -516,16 +524,16 @@ export function FileStats() {
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className='text-sm font-bold'>
文件占用容量分布
{t('stats.sizeDistTitle')}
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件大小占比
{t('stats.sizeDistDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
{categorySizeData.length === 0 ? (
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无容量数据
{t('stats.noSizeData')}
</div>
) : (
<div className='h-[240px] w-full'>
@@ -595,15 +603,10 @@ export function FileStats() {
<div className='bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400'>
<Info className='size-4 shrink-0 mt-0.5' />
<p className='leading-normal'>
文件类型中占用存储最大的是{' '}
<strong className='font-bold'>
「{maxStats.maxSizeName}」
</strong>
,共消耗了{' '}
<strong className='font-mono font-bold'>
{formatFileSize(maxStats.maxSize)}
</strong>{' '}
存储容量。
{t('stats.maxSizeHint', {
name: maxStats.maxSizeName,
size: formatFileSize(maxStats.maxSize),
})}
</p>
</div>
</CardContent>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Loader2, Play, Save } from 'lucide-react';
import { toast } from 'sonner';
@@ -44,13 +45,13 @@ import type {
const storageConfigKey = 'storage_config';
const storageMigrationTaskType = 'storage_migration';
const driverLabels: Record<StorageDriver, string> = {
local: '本地文件系统',
s3: 'AWS S3',
r2: 'Cloudflare R2',
minio: 'MinIO',
oss: '阿里云 OSS',
webdav: 'WebDAV',
const driverLabelKeys: Record<StorageDriver, string> = {
local: 'driverLocal',
s3: 'driverS3',
r2: 'driverR2',
minio: 'driverMinio',
oss: 'driverOss',
webdav: 'driverWebdav',
};
const emptyObjectConfig: ObjectStorageConfig = {
@@ -112,8 +113,8 @@ function parseMigrationPayload(payload: string): StorageConfig | undefined {
}
}
function driverLabel(driver?: StorageDriver): string {
return driver ? driverLabels[driver] : '未知';
function driverLabelKey(driver?: StorageDriver): string {
return driver ? driverLabelKeys[driver] : 'driverUnknown';
}
function latestMigration(
@@ -136,6 +137,7 @@ function latestMigration(
}
export function StorageConfigTab() {
const t = useTranslations('admin.files');
const queryClient = useQueryClient();
const [config, setConfig] = React.useState<StorageConfig | null>(null);
const query = useQuery({
@@ -186,13 +188,13 @@ export function StorageConfigTab() {
});
},
onSuccess: () => {
toast.success('存储配置已保存');
toast.success(t('storage.saveSuccess'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || '保存存储配置失败');
toast.error(error.message || t('storage.saveFailed'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
@@ -208,13 +210,13 @@ export function StorageConfigTab() {
});
},
onSuccess: () => {
toast.success('存储迁移任务已下发');
toast.success(t('storage.migrateSuccess'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || '下发存储迁移任务失败');
toast.error(error.message || t('storage.migrateFailed'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
@@ -224,12 +226,13 @@ export function StorageConfigTab() {
mutationFn: (executionID: string) =>
services.adminTask.retryTaskExecution(executionID),
onSuccess: () => {
toast.success('存储迁移任务已重新下发');
toast.success(t('storage.retrySuccess'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'),
onError: (error: Error) =>
toast.error(error.message || t('storage.retryFailed')),
});
if (query.isPending || !config) {
@@ -269,7 +272,7 @@ export function StorageConfigTab() {
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
存储维护模式
{t('storage.maintenanceMode')}
<Badge
variant={
migration.state === 'failed' ? 'destructive' : 'secondary'
@@ -279,9 +282,10 @@ export function StorageConfigTab() {
</Badge>
</CardTitle>
<CardDescription>
{driverLabel(migration.source_driver)} →{' '}
{driverLabel(migration.target_driver)}
。迁移期间文件只允许读取,禁止上传、删除和清理。
{t('storage.maintenanceDesc', {
source: t(`storage.${driverLabelKey(migration.source_driver)}`),
target: t(`storage.${driverLabelKey(migration.target_driver)}`),
})}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-3'>
@@ -289,7 +293,9 @@ export function StorageConfigTab() {
value={migration.state === 'succeeded' ? 100 : undefined}
/>
<p className='text-sm text-muted-foreground'>
迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'}
{t('storage.migrationProgress', {
taskId: migration.task_id || t('storage.notDispatched'),
})}
</p>
{migration.error && (
<p className='text-sm text-destructive'>{migration.error}</p>
@@ -310,7 +316,7 @@ export function StorageConfigTab() {
) : (
<Play data-icon='inline-start' />
)}
重试迁移
{t('storage.retryMigration')}
</Button>
</CardFooter>
</Card>
@@ -324,10 +330,10 @@ export function StorageConfigTab() {
</div>
<div>
<CardTitle className='text-base font-semibold'>
文件存储
{t('storage.fileStorage')}
</CardTitle>
<CardDescription className='text-xs'>
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
{t('storage.fileStorageDesc')}
</CardDescription>
</div>
</div>
@@ -337,7 +343,7 @@ export function StorageConfigTab() {
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<Field className='flex flex-col gap-1.5 md:col-span-2'>
<FieldLabel className='text-xs font-semibold'>
存储类型
{t('storage.storageType')}
</FieldLabel>
<Select
value={config.driver}
@@ -351,10 +357,10 @@ export function StorageConfigTab() {
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(driverLabels) as StorageDriver[]).map(
{(Object.keys(driverLabelKeys) as StorageDriver[]).map(
(driver) => (
<SelectItem key={driver} value={driver}>
{driverLabels[driver]}
{t(`storage.${driverLabelKeys[driver]}`)}
</SelectItem>
),
)}
@@ -366,7 +372,7 @@ export function StorageConfigTab() {
{config.driver === 'local' && (
<div className='md:col-span-2'>
<TextField
label='根目录'
label={t('storage.rootDir')}
value={config.local.root}
placeholder='.'
onChange={(root) =>
@@ -396,7 +402,7 @@ export function StorageConfigTab() {
<>
<div className='md:col-span-2'>
<TextField
label='服务地址'
label={t('storage.endpoint')}
value={config.webdav.endpoint}
placeholder='https://dav.example.com'
onChange={(endpoint) =>
@@ -408,7 +414,7 @@ export function StorageConfigTab() {
/>
</div>
<TextField
label='用户名'
label={t('storage.username')}
value={config.webdav.username}
onChange={(username) =>
setConfig({
@@ -418,7 +424,7 @@ export function StorageConfigTab() {
}
/>
<TextField
label='密码'
label={t('storage.password')}
type='password'
value={config.webdav.password}
onChange={(password) =>
@@ -430,7 +436,7 @@ export function StorageConfigTab() {
/>
<div className='md:col-span-2'>
<TextField
label='基础路径'
label={t('storage.basePath')}
value={config.webdav.base_path}
placeholder='openflare'
onChange={(base_path) =>
@@ -449,8 +455,7 @@ export function StorageConfigTab() {
<CardFooter className='justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6'>
{config.driver !== query.data?.config.driver && (
<span className='text-xs text-amber-500 mr-auto text-left max-w-md'>
⚠️
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
{t('storage.switchWarning')}
</span>
)}
<div className='flex gap-2'>
@@ -465,7 +470,7 @@ export function StorageConfigTab() {
) : (
<Save data-icon='inline-start' />
)}
保存配置
{t('storage.saveConfig')}
</Button>
{config.driver !== query.data?.config.driver && (
<Button
@@ -477,7 +482,7 @@ export function StorageConfigTab() {
) : (
<Play data-icon='inline-start' />
)}
开始迁移
{t('storage.startMigration')}
</Button>
)}
</div>
@@ -496,6 +501,7 @@ function ObjectFields({
value: ObjectStorageConfig;
onChange: (patch: Partial<ObjectStorageConfig>) => void;
}) {
const t = useTranslations('admin.files');
return (
<>
{driver === 'r2' && (
@@ -539,13 +545,13 @@ function ObjectFields({
onChange={(secret_access_key) => onChange({ secret_access_key })}
/>
<TextField
label='对象前缀'
label={t('storage.keyPrefix')}
value={value.key_prefix}
placeholder='uploads'
onChange={(key_prefix) => onChange({ key_prefix })}
/>
<TextField
label='CDN 地址'
label={t('storage.cdnUrl')}
value={value.cdn_url}
placeholder='https://cdn.example.com'
onChange={(cdn_url) => onChange({ cdn_url })}
@@ -561,7 +567,7 @@ function ObjectFields({
Path Style
</FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
MinIO 等自托管 S3 通常需要开启。
{t('storage.pathStyleDesc')}
</FieldDescription>
</div>
<Switch
+8 -4
View File
@@ -3,6 +3,7 @@
import * as React from 'react';
import { motion } from 'motion/react';
import { FolderOpen } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FileStats } from './components/file-stats';
@@ -10,6 +11,7 @@ import { FileList } from './components/file-list';
import { StorageConfigTab } from './components/storage-config-tab';
export default function FilesPage() {
const t = useTranslations('admin.files');
const [activeTab, setActiveTab] = React.useState('stats');
return (
@@ -23,7 +25,9 @@ export default function FilesPage() {
<div className='flex items-center gap-2'>
<FolderOpen className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>存储管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
@@ -33,16 +37,16 @@ export default function FilesPage() {
value='stats'
className='px-0 pb-2 text-xs font-semibold'
>
文件存储信息
{t('tabStats')}
</TabsTrigger>
<TabsTrigger value='list' className='px-0 pb-2 text-xs font-semibold'>
文件列表
{t('tabList')}
</TabsTrigger>
<TabsTrigger
value='storage'
className='px-0 pb-2 text-xs font-semibold'
>
存储配置
{t('tabStorage')}
</TabsTrigger>
</TabsList>
@@ -28,6 +28,7 @@ import {
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -65,12 +66,15 @@ interface TopUserData {
count: number;
}
const chartConfig = {
count: {
label: '请求量',
color: 'hsl(var(--primary))',
},
} satisfies ChartConfig;
function useAnalyticsChartConfig() {
const t = useTranslations('admin.logs.analytics');
return {
count: {
label: t('requestVolume'),
color: 'hsl(var(--primary))',
},
} satisfies ChartConfig;
}
// Format YYYY-MM-DD to MM/DD
function formatDateLabel(dateStr: string) {
@@ -83,6 +87,8 @@ function formatDateLabel(dateStr: string) {
}
export function AccessAnalytics() {
const t = useTranslations('admin.logs.analytics');
const chartConfig = useAnalyticsChartConfig();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
@@ -109,7 +115,7 @@ export function AccessAnalytics() {
setClickhouseDisabled(false);
} catch (err) {
const errorInstance =
err instanceof Error ? err : new Error('获取数据统计失败');
err instanceof Error ? err : new Error(t('fetchFailed'));
const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
setClickhouseDisabled(true);
@@ -119,7 +125,7 @@ export function AccessAnalytics() {
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchAnalytics();
@@ -135,11 +141,9 @@ export function AccessAnalytics() {
return (
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
<XCircle className='size-10 text-muted-foreground mb-3' />
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
{t('clickhouseDisabledDesc')}
</p>
</div>
);
@@ -153,7 +157,7 @@ export function AccessAnalytics() {
return (
<LoadingStateWithBorder
icon={BarChart3}
description='加载访问统计指标中...'
description={t('loadingAnalytics')}
/>
);
}
@@ -165,7 +169,7 @@ export function AccessAnalytics() {
<Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
近 7 天总请求数
{t('totalRequests7d')}
</CardTitle>
<TrendingUp className='size-4 text-muted-foreground' />
</CardHeader>
@@ -174,14 +178,14 @@ export function AccessAnalytics() {
{totalTrendRequests.toLocaleString()}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
系统记录的所有成功认证的访问总频次
{t('totalRequests7dDesc')}
</p>
</CardContent>
</Card>
<Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
活跃终端分类数
{t('activeBrowserTypes')}
</CardTitle>
<Globe className='size-4 text-muted-foreground' />
</CardHeader>
@@ -190,14 +194,14 @@ export function AccessAnalytics() {
{browsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
在一周内发起请求的浏览器代理大类统计
{t('activeBrowserTypesDesc')}
</p>
</CardContent>
</Card>
<Card className='bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
活跃独立用户数
{t('activeUsers')}
</CardTitle>
<Users className='size-4 text-muted-foreground' />
</CardHeader>
@@ -206,7 +210,7 @@ export function AccessAnalytics() {
{topUsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
一周内累计发起高频请求的注册账户总量
{t('activeUsersDesc')}
</p>
</CardContent>
</Card>
@@ -217,10 +221,10 @@ export function AccessAnalytics() {
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base font-bold'>
一周访问量趋势
{t('weeklyAccessTrend')}
</CardTitle>
<CardDescription className='text-xs'>
展现系统最近 7 天内的每日 API 请求曲线
{t('weeklyAccessTrendDesc')}
</CardDescription>
</div>
<Button
@@ -241,7 +245,7 @@ export function AccessAnalytics() {
{trend.length === 0 ? (
<EmptyStateWithBorder
icon={TrendingUp}
description='暂无趋势数据'
description={t('noTrendData')}
/>
) : (
<div className='h-[280px] w-full'>
@@ -289,7 +293,7 @@ export function AccessAnalytics() {
strokeWidth={2}
fillOpacity={1}
fill='url(#colorCount)'
name='请求次数'
name={t('requestCount')}
/>
</AreaChart>
</ChartContainer>
@@ -305,17 +309,17 @@ export function AccessAnalytics() {
<CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Globe className='size-4.5 text-muted-foreground' />
使用的浏览器排行
{t('browserRanking')}
</CardTitle>
<CardDescription className='text-xs'>
基于请求头 User-Agent 智能分类的一周占比统计
{t('browserRankingDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1'>
{browsers.length === 0 ? (
<EmptyStateWithBorder
icon={Globe}
description='暂无浏览器排行数据'
description={t('noBrowserData')}
/>
) : (
<div className='space-y-4'>
@@ -337,8 +341,10 @@ export function AccessAnalytics() {
<span className='font-semibold'>{item.browser}</span>
</span>
<span className='text-muted-foreground font-mono text-xs'>
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}
%)
{t('countTimes', {
count: item.count.toLocaleString(),
percent: percent.toFixed(1),
})}
</span>
</div>
<div className='h-2 w-full rounded-full bg-muted overflow-hidden'>
@@ -360,17 +366,17 @@ export function AccessAnalytics() {
<CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Users className='size-4.5 text-muted-foreground' />
最活跃的用户排行 (Top 10)
{t('topActiveUsers')}
</CardTitle>
<CardDescription className='text-xs'>
统计最近一周发起接口访问请求数量最多的账户
{t('topActiveUsersDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1'>
{topUsers.length === 0 ? (
<EmptyStateWithBorder
icon={Users}
description='暂无活跃用户数据'
description={t('noActiveUsersData')}
/>
) : (
<div className='divide-y divide-border/40'>
@@ -385,10 +391,13 @@ export function AccessAnalytics() {
</div>
<div className='truncate max-w-[180px] sm:max-w-xs'>
<div className='text-xs font-bold leading-tight truncate'>
{user.username || '未知'}
{user.username || t('unknown')}
</div>
<div className='text-[10px] text-muted-foreground leading-normal truncate'>
{user.nickname ? `(${user.nickname})` : '(无昵称)'} |
{user.nickname
? `(${user.nickname})`
: t('noNickname')}{' '}
|
ID: {user.user_id}
</div>
</div>
@@ -398,7 +407,7 @@ export function AccessAnalytics() {
{user.count.toLocaleString()}
</div>
<div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'>
次请求
{t('requests')}
</div>
</div>
</div>
@@ -32,6 +32,7 @@ import {
} from 'lucide-react';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -97,6 +98,7 @@ function isClickhouseDisabledError(error: Error) {
}
export function AccessLogs() {
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1);
// 搜索过滤条件
@@ -184,12 +186,12 @@ export function AccessLogs() {
const copyToClipboard = (text: string, subject: string) => {
navigator.clipboard.writeText(text);
toast.success(`${subject}已复制到剪贴板`);
toast.success(t('copiedToClipboard', { subject }));
};
// Prettify Headers JSON string
const getPrettyHeaders = (headersRaw?: string) => {
if (!headersRaw) return '暂无头部数据';
if (!headersRaw) return t('noHeaderData');
try {
const parsed = JSON.parse(headersRaw);
return JSON.stringify(parsed, null, 2);
@@ -202,11 +204,9 @@ export function AccessLogs() {
return (
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
<XCircle className='size-10 text-muted-foreground mb-3' />
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
{t('clickhouseDisabledDesc')}
</p>
</div>
);
@@ -227,11 +227,11 @@ export function AccessLogs() {
>
<div className='grid gap-1.5'>
<Label htmlFor='search-user' className='text-xs'>
用户名
{t('username')}
</Label>
<Input
id='search-user'
placeholder='输入用户名搜索...'
placeholder={t('usernamePlaceholder')}
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
className='h-8 text-xs'
@@ -239,11 +239,11 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-path' className='text-xs'>
接口路径
{t('apiPath')}
</Label>
<Input
id='search-path'
placeholder='模糊匹配路径...'
placeholder={t('pathPlaceholder')}
value={pathFilter}
onChange={(e) => setPathFilter(e.target.value)}
className='h-8 text-xs'
@@ -251,7 +251,7 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-start' className='text-xs'>
起始时间
{t('startTime')}
</Label>
<Input
id='search-start'
@@ -263,7 +263,7 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-end' className='text-xs'>
结束时间
{t('endTime')}
</Label>
<Input
id='search-end'
@@ -276,7 +276,7 @@ export function AccessLogs() {
<div className='flex gap-2'>
<Button type='submit' size='sm' className='h-8 flex-1'>
<Search className='size-3.5 mr-1' />
搜索
{t('search')}
</Button>
<Button
type='button'
@@ -286,7 +286,7 @@ export function AccessLogs() {
className='h-8 flex-1'
>
<RotateCcw className='size-3.5 mr-1' />
重置
{t('reset')}
</Button>
</div>
</form>
@@ -295,26 +295,27 @@ export function AccessLogs() {
{loading && logs.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载访问日志中...'
description={t('loadingAccessLogs')}
/>
) : logs.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
description='未查询到任何访问日志数据'
/>
<EmptyStateWithBorder icon={Activity} description={t('noAccessLogs')} />
) : (
<div className='rounded-lg border bg-card overflow-hidden'>
<Table className='min-w-[900px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[100px]'>请求方法</TableHead>
<TableHead className='min-w-[200px]'>路径</TableHead>
<TableHead className='w-[150px]'>用户</TableHead>
<TableHead className='w-[120px]'>IP</TableHead>
<TableHead className='w-[90px]'>状态</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead>
<TableHead className='w-[170px]'>请求时间</TableHead>
<TableHead className='w-[80px] text-center'>详情</TableHead>
<TableHead className='w-[100px]'>{t('colMethod')}</TableHead>
<TableHead className='min-w-[200px]'>{t('colPath')}</TableHead>
<TableHead className='w-[150px]'>{t('colUser')}</TableHead>
<TableHead className='w-[120px]'>{t('colIp')}</TableHead>
<TableHead className='w-[90px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[100px]'>{t('colLatency')}</TableHead>
<TableHead className='w-[170px]'>
{t('colRequestTime')}
</TableHead>
<TableHead className='w-[80px] text-center'>
{t('colDetail')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -334,7 +335,7 @@ export function AccessLogs() {
<TableCell>
<div className='flex flex-col'>
<span className='text-xs font-medium'>
{log.username || '未知用户'}
{log.username || t('unknownUser')}
</span>
{log.nickname && (
<span className='text-[10px] text-muted-foreground'>
@@ -381,7 +382,7 @@ export function AccessLogs() {
{logs.length > 0 && (
<div className='flex items-center justify-between py-1'>
<div className='text-xs text-muted-foreground'>
共 {total} 条记录,当前第 {page}/{totalPages} 页
{t('paginationInfo', { total, page, totalPages })}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -392,7 +393,7 @@ export function AccessLogs() {
className='h-8 text-xs'
>
<ChevronLeft className='size-3.5 mr-1' />
上一页
{t('prevPage')}
</Button>
<Button
variant='outline'
@@ -401,7 +402,7 @@ export function AccessLogs() {
disabled={page >= totalPages || loading}
className='h-8 text-xs'
>
下一页
{t('nextPage')}
<ChevronRight className='size-3.5 ml-1' />
</Button>
</div>
@@ -412,9 +413,11 @@ export function AccessLogs() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l'>
<SheetHeader className='border-b px-5 py-4 shrink-0'>
<SheetTitle className='text-lg font-bold'>访问日志详情</SheetTitle>
<SheetTitle className='text-lg font-bold'>
{t('accessLogDetail')}
</SheetTitle>
<SheetDescription className='text-xs'>
访问 ID: {selectedLog?.id}
{t('accessId', { id: selectedLog?.id ?? '' })}
</SheetDescription>
</SheetHeader>
@@ -424,7 +427,7 @@ export function AccessLogs() {
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3'>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
请求方法
{t('method')}
</div>
<div className='mt-1 font-mono text-sm font-bold uppercase'>
{selectedLog.method}
@@ -432,7 +435,7 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
响应状态
{t('responseStatus')}
</div>
<div className='mt-1'>
<Badge variant={statusVariant(selectedLog.status)}>
@@ -442,7 +445,7 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
耗时
{t('latency')}
</div>
<div className='mt-1 font-mono text-sm'>
{formatLatency(selectedLog.latency)}
@@ -450,18 +453,18 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
IP 地址
{t('ipAddress')}
</div>
<div className='mt-1 font-mono text-sm'>{selectedLog.ip}</div>
</div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
用户
{t('user')}
</div>
<div className='mt-1 text-sm font-medium'>
{selectedLog.username
? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})`
: '未知/游客'}
? `${selectedLog.username} (${selectedLog.nickname || t('noNickname')})`
: t('unknownGuest')}
<span className='block font-mono text-[10px] text-muted-foreground mt-0.5'>
ID: {selectedLog.user_id}
</span>
@@ -472,7 +475,7 @@ export function AccessLogs() {
{/* Path */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求路径
{t('requestPath')}
</Label>
<div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'>
{selectedLog.path}
@@ -482,7 +485,7 @@ export function AccessLogs() {
{/* Request Time */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求时间
{t('requestTime')}
</Label>
<div className='font-mono text-xs'>
{formatDateTime(selectedLog.created_at)}
@@ -502,7 +505,7 @@ export function AccessLogs() {
onClick={() =>
copyToClipboard(selectedLog.user_agent, 'User-Agent')
}
title='复制 User-Agent'
title={t('copyUserAgent')}
>
<Copy className='size-3' />
</Button>
@@ -528,7 +531,7 @@ export function AccessLogs() {
'Headers',
)
}
title='复制 Headers'
title={t('copyHeaders')}
>
<Copy className='size-3' />
</Button>
@@ -542,7 +545,7 @@ export function AccessLogs() {
<div className='flex-grow flex items-center justify-center'>
<EmptyStateWithBorder
icon={Activity}
description='未选中日志明细'
description={t('noLogSelected')}
/>
</div>
)}
@@ -22,6 +22,7 @@ import { toast } from 'sonner';
import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
@@ -92,6 +93,7 @@ function parseLogLevel(
}
export function AppLogs() {
const t = useTranslations('admin.logs.appLogs');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
@@ -176,9 +178,9 @@ export function AppLogs() {
try {
return await services.adminLog.getLogs(cursor);
} catch (err) {
throw err instanceof Error ? err : new Error('获取日志失败');
throw err instanceof Error ? err : new Error(t('fetchFailed'));
}
}, []);
}, [t]);
const loadHistory = useCallback(
async (cursor: number = 0) => {
@@ -209,16 +211,16 @@ export function AppLogs() {
setNextCursor(data.next_cursor);
} catch (err) {
if (isInitial) {
setError(err instanceof Error ? err : new Error('获取日志失败'));
setError(err instanceof Error ? err : new Error(t('fetchFailed')));
} else {
toast.error('加载更早日志失败');
toast.error(t('loadOlderFailed'));
}
} finally {
if (isInitial) setLoading(false);
else setLoadingMore(false);
}
},
[fetchLogs],
[fetchLogs, t],
);
// ---- WebSocket -------------------------------------------------------
@@ -300,12 +302,12 @@ export function AppLogs() {
{loadingMore ? (
<>
<Loader2 className='size-3 mr-1.5 animate-spin' />
加载中...
{t('loading')}
</>
) : (
<>
<ChevronUp className='size-3 mr-1.5' />
加载更早日志
{t('loadOlderLogs')}
</>
)}
</Button>
@@ -313,7 +315,9 @@ export function AppLogs() {
)}
{logs.length === 0 ? (
<div className='px-4 py-8 text-center text-gray-500'>暂无日志</div>
<div className='px-4 py-8 text-center text-gray-500'>
{t('noLogs')}
</div>
) : (
<div
className='relative px-3 py-3'
@@ -349,7 +353,7 @@ export function AppLogs() {
className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
>
<ArrowDown className='size-3.5 mr-1.5' />
回到最新
{t('backToLatest')}
</Button>
</div>
)}
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic';
import { Activity, BarChart3, Terminal } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -26,13 +27,16 @@ const AppLogs = dynamic(
);
export function LogsPageClient() {
const t = useTranslations('admin.logs');
return (
<div className='flex flex-col h-full space-y-6 py-6'>
{/* Header */}
<div className='flex items-center gap-2'>
<Terminal className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>系统日志</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
{/* Tabs Layout */}
@@ -43,21 +47,21 @@ export function LogsPageClient() {
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<BarChart3 className='size-3.5' />
访问分析
{t('accessAnalytics')}
</TabsTrigger>
<TabsTrigger
value='access'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Activity className='size-3.5' />
用户访问日志
{t('userAccessLogs')}
</TabsTrigger>
<TabsTrigger
value='app'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Terminal className='size-3.5' />
应用运行日志
{t('appRuntimeLogs')}
</TabsTrigger>
</TabsList>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
@@ -80,6 +81,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
null,
@@ -120,12 +122,12 @@ export function EventsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) =>
PushService.updateEvent(id, data),
onSuccess: () => {
toast.success('事件更新成功');
toast.success(t('eventUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setEditEventOpen(false);
},
onError: (err: unknown) => {
toast.error('事件更新失败: ' + (err as Error).message);
toast.error(t('eventUpdateFailed') + ': ' + (err as Error).message);
},
});
@@ -135,14 +137,14 @@ export function EventsTab() {
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('操作失败: ' + (err as Error).message);
toast.error(t('operationFailed') + ': ' + (err as Error).message);
},
});
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => {
toast.success('事件创建成功');
toast.success(t('eventCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setCreateEventOpen(false);
setNewEventKey('');
@@ -152,7 +154,7 @@ export function EventsTab() {
setNewEventEnabled(true);
},
onError: (err: unknown) => {
toast.error('事件创建失败: ' + (err as Error).message);
toast.error(t('eventCreateFailed') + ': ' + (err as Error).message);
},
});
@@ -160,11 +162,11 @@ export function EventsTab() {
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
setDeleteTarget(null);
toast.success('配置删除成功');
toast.success(t('configDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('配置删除失败: ' + (err as Error).message);
toast.error(t('configDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -212,7 +214,7 @@ export function EventsTab() {
try {
JSON.parse(eventTemplate);
} catch {
toast.error('消息模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
@@ -260,8 +262,8 @@ export function EventsTab() {
);
if (taskMeta) {
const defaultTemplate = {
title: `任务完成: ${taskMeta.name}`,
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
title: t('taskCompleteTitle', { name: taskMeta.name }),
content: t('taskCompleteContent'),
level: 'INFO',
};
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2));
@@ -272,11 +274,11 @@ export function EventsTab() {
const handleCreateEvent = () => {
if (newEventType === 'builtin' && !newEventKey) {
toast.error('请选择系统事件');
toast.error(t('selectSystemEvent'));
return;
}
if (newEventType === 'task' && !newEventTaskType) {
toast.error('请选择异步任务');
toast.error(t('selectAsyncTask'));
return;
}
@@ -284,7 +286,7 @@ export function EventsTab() {
try {
JSON.parse(newEventTemplate);
} catch {
toast.error('内容渲染模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
}
@@ -309,11 +311,11 @@ export function EventsTab() {
<div className='flex justify-end'>
<Button size='sm' onClick={handleCreateEventClick} className='text-xs'>
<Plus className='size-3.5 mr-1' />
新增通知事件
{t('addEvent')}
</Button>
</div>
{eventsQuery.isLoading ? (
<LoadingStateWithBorder icon={Layers} description='加载通知事件中...' />
<LoadingStateWithBorder icon={Layers} description={t('loadingEvents')} />
) : eventsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
<ErrorInline
@@ -325,7 +327,7 @@ export function EventsTab() {
) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Layers}
description='暂无通知配置事件,请点击右上角新增'
description={t('noEvents')}
/>
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
@@ -336,19 +338,19 @@ export function EventsTab() {
ID
</TableHead>
<TableHead className='w-[180px] whitespace-nowrap py-2 h-8'>
通知事件
{t('colEvent')}
</TableHead>
<TableHead className='w-[200px] whitespace-nowrap py-2 h-8'>
关联渠道
{t('colChannels')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
推送目标
{t('colTargets')}
</TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态
{t('colStatus')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -376,7 +378,7 @@ export function EventsTab() {
variant='outline'
className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200'
>
任务
{t('taskBadge')}
</Badge>
)}
</div>
@@ -389,7 +391,7 @@ export function EventsTab() {
<div className='flex flex-wrap gap-1'>
{(event.channels ?? []).length === 0 ? (
<span className='text-xs text-muted-foreground italic'>
未指定渠道
{t('noChannelSpecified')}
</span>
) : (
event.channels.map((ch) => (
@@ -398,7 +400,7 @@ export function EventsTab() {
variant='secondary'
className='text-[10px] py-0 px-1.5 h-4.5'
>
{ch === 'email' ? '邮件' : ch}
{ch === 'email' ? t('emailChannel') : ch}
</Badge>
))
)}
@@ -453,7 +455,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
配置
{t('configure')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -472,7 +474,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
删除
{t('delete')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -489,15 +491,15 @@ export function EventsTab() {
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>新增通知事件</DialogTitle>
<DialogTitle>{t('addEventDialogTitle')}</DialogTitle>
<DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
{t('addEventDialogDesc')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>事件类型</Label>
<Label className='text-xs font-semibold'>{t('eventType')}</Label>
<div className='flex gap-4 p-1.5 border rounded-md bg-muted/20'>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -511,7 +513,7 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>系统内置事件</span>
<span>{t('builtinEventType')}</span>
</label>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -525,22 +527,22 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>任务完成事件</span>
<span>{t('taskEventType')}</span>
</label>
</div>
</div>
{newEventType === 'builtin' ? (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统事件</Label>
<Label className='text-xs font-semibold'>{t('systemEvent')}</Label>
{builtInEventsQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载系统事件中...</span>
<span>{t('loadingSystemEvents')}</span>
</div>
) : availableBuiltInEvents.length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
所有内置事件都已配置,没有可新增的事件。
{t('allBuiltinEventsConfigured')}
</div>
) : (
<Select
@@ -548,7 +550,9 @@ export function EventsTab() {
onValueChange={handleNewEventKeyChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择系统事件' />
<SelectValue
placeholder={t('selectSystemEventPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{availableBuiltInEvents.map((ev) => (
@@ -566,15 +570,17 @@ export function EventsTab() {
</div>
) : (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统异步任务</Label>
<Label className='text-xs font-semibold'>
{t('systemAsyncTask')}
</Label>
{taskTypesQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载异步任务中...</span>
<span>{t('loadingAsyncTasks')}</span>
</div>
) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
暂无可用的系统任务。
{t('noAvailableTasks')}
</div>
) : (
<Select
@@ -582,7 +588,9 @@ export function EventsTab() {
onValueChange={handleNewEventTaskTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择异步任务' />
<SelectValue
placeholder={t('selectAsyncTaskPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map((taskMeta) => (
@@ -603,11 +611,11 @@ export function EventsTab() {
{newEventType === 'builtin' && newEventKey && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
事件说明:
{t('eventDescription')}
</span>
<span>
{availableBuiltInEvents.find((e) => e.key === newEventKey)
?.description || '无描述信息'}
?.description || t('noDescription')}
</span>
</div>
)}
@@ -615,18 +623,20 @@ export function EventsTab() {
{newEventType === 'task' && newEventTaskType && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
任务说明:
{t('taskDescription')}
</span>
<span>
{(taskTypesQuery.data ?? []).find(
(t) => t.asynq_task === newEventTaskType,
)?.description || '无描述信息'}
)?.description || t('noDescription')}
</span>
</div>
)}
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>推送渠道 (可多选)</Label>
<Label className='text-xs font-semibold'>
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -636,11 +646,11 @@ export function EventsTab() {
{newEventChannels.length > 0
? newEventChannels
.map((ch) => {
if (ch === 'email') return '邮件';
if (ch === 'email') return t('emailChannel');
return ch;
})
.join(', ')
: '选择已配置推送渠道'}
: t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
@@ -666,12 +676,12 @@ export function EventsTab() {
}
}}
/>
<span>{ch === 'email' ? '邮件推送' : ch}</span>
<span>{ch === 'email' ? t('emailPush') : ch}</span>
</label>
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
{t('noAvailableChannels')}
</div>
)}
</div>
@@ -681,11 +691,11 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
placeholder={t('pushTargetsPlaceholder')}
value={newEventTargets}
onChange={(e) => setNewEventTargets(e.target.value)}
className='text-xs h-9'
@@ -695,12 +705,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{newEventType === 'task'
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -714,9 +724,9 @@ export function EventsTab() {
<div className='flex items-center justify-between p-3 border rounded-lg bg-muted/10'>
<div className='space-y-0.5'>
<Label className='text-xs font-semibold'>启用状态</Label>
<Label className='text-xs font-semibold'>{t('enableStatus')}</Label>
<div className='text-[10px] text-muted-foreground'>
创建后是否立即开始接收此事件的通知
{t('enableStatusDesc')}
</div>
</div>
<Switch
@@ -733,7 +743,7 @@ export function EventsTab() {
onClick={() => setCreateEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -749,7 +759,7 @@ export function EventsTab() {
{createEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
启用保存
{t('enableSave')}
</Button>
</DialogFooter>
</DialogContent>
@@ -759,9 +769,9 @@ export function EventsTab() {
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle>
<DialogTitle>{t('editEventDialogTitle')}</DialogTitle>
<DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
{t('editEventDialogDesc')}
</DialogDescription>
</DialogHeader>
@@ -770,7 +780,7 @@ export function EventsTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件名称
{t('eventName')}
</Label>
<Input
value={selectedEvent.name}
@@ -780,7 +790,7 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件键 (Key)
{t('eventKey')}
</Label>
<Input
value={selectedEvent.event_key}
@@ -793,7 +803,7 @@ export function EventsTab() {
{selectedEvent.task_type && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
关联异步任务
{t('relatedAsyncTask')}
</Label>
<Input
value={`${(taskTypesQuery.data ?? []).find((t) => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
@@ -805,7 +815,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送渠道 (可多选)
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
@@ -816,11 +826,11 @@ export function EventsTab() {
{eventChannels.length > 0
? eventChannels
.map((ch) => {
if (ch === 'email') return '邮件';
if (ch === 'email') return t('emailChannel');
return ch;
})
.join(', ')
: '选择已配置推送渠道'}
: t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
@@ -846,12 +856,12 @@ export function EventsTab() {
}
}}
/>
<span>{ch === 'email' ? '邮件推送' : ch}</span>
<span>{ch === 'email' ? t('emailPush') : ch}</span>
</label>
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
{t('noAvailableChannelsEdit')}
</div>
)}
</div>
@@ -861,11 +871,11 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
placeholder={t('pushTargetsPlaceholder')}
value={eventTargets}
onChange={(e) => setEventTargets(e.target.value)}
className='text-xs h-9'
@@ -875,12 +885,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{selectedEvent.task_type
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -901,7 +911,7 @@ export function EventsTab() {
onClick={() => setEditEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -913,7 +923,7 @@ export function EventsTab() {
{updateEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
保存修改
{t('saveChanges')}
</Button>
</DialogFooter>
</DialogContent>
@@ -925,14 +935,14 @@ export function EventsTab() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除通知事件</AlertDialogTitle>
<AlertDialogTitle>{t('deleteEventTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除该通知事件配置吗?删除后无法恢复。
{t('deleteEventConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteEventMutation.isPending}>
取消
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteEventMutation.isPending}
@@ -940,7 +950,9 @@ export function EventsTab() {
deleteTarget && deleteEventMutation.mutate(deleteTarget.id)
}
>
{deleteEventMutation.isPending ? '删除中...' : '确认删除'}
{deleteEventMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import {
ChevronLeft,
@@ -62,6 +63,7 @@ function getLevelBadgeVariant(
}
export function HistoriesTab() {
const t = useTranslations('admin.push.histories');
const [historyPage, setHistoryPage] = React.useState(1);
const [historySearch, setHistorySearch] = React.useState('');
const [historyStatus, setHistoryStatus] = React.useState('all');
@@ -99,7 +101,7 @@ export function HistoriesTab() {
<Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
<Input
type='text'
placeholder='输入事件键过滤...'
placeholder={t('searchPlaceholder')}
className='pl-8 text-xs h-9'
value={historySearch}
onChange={(e) => {
@@ -117,17 +119,17 @@ export function HistoriesTab() {
}}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='发送状态' />
<SelectValue placeholder={t('sendStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all' className='text-xs'>
全部状态
{t('allStatus')}
</SelectItem>
<SelectItem value='success' className='text-xs'>
发送成功
{t('sendSuccess')}
</SelectItem>
<SelectItem value='failed' className='text-xs'>
发送失败
{t('sendFailed')}
</SelectItem>
</SelectContent>
</Select>
@@ -151,13 +153,27 @@ export function HistoriesTab() {
<Table>
<TableHeader>
<TableRow className='bg-muted/30'>
<TableHead className='text-xs font-semibold'>事件</TableHead>
<TableHead className='text-xs font-semibold'>渠道</TableHead>
<TableHead className='text-xs font-semibold'>目标</TableHead>
<TableHead className='text-xs font-semibold'>标题</TableHead>
<TableHead className='text-xs font-semibold'>等级</TableHead>
<TableHead className='text-xs font-semibold'>状态</TableHead>
<TableHead className='text-xs font-semibold'>时间</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colEvent')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChannel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTarget')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTitle')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colLevel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colStatus')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTime')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -166,7 +182,7 @@ export function HistoriesTab() {
<TableCell colSpan={7} className='h-32'>
<LoadingStateWithBorder
icon={History}
description='加载推送历史记录中...'
description={t('loadingHistories')}
className='border-0 shadow-none'
/>
</TableCell>
@@ -187,7 +203,7 @@ export function HistoriesTab() {
colSpan={7}
className='h-32 text-center text-xs text-muted-foreground'
>
无推送历史数据
{t('noHistoryData')}
</TableCell>
</TableRow>
) : (
@@ -201,7 +217,9 @@ export function HistoriesTab() {
{hist.event_key}
</TableCell>
<TableCell className='text-xs uppercase font-semibold text-muted-foreground'>
{hist.channel === 'email' ? '邮件' : hist.channel}
{hist.channel === 'email'
? t('emailChannel')
: hist.channel}
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground max-w-[120px] truncate'
@@ -230,7 +248,7 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold'
>
{hist.status === 'success' ? '成功' : '失败'}
{hist.status === 'success' ? t('success') : t('failed')}
</Badge>
{hist.status !== 'success' && hist.error_msg && (
<div
@@ -253,7 +271,9 @@ export function HistoriesTab() {
{historiesQuery.data && historiesQuery.data.total > 0 && (
<div className='flex justify-between items-center text-xs text-muted-foreground'>
<span>共 {historiesQuery.data.total} 条历史记录</span>
<span>
{t('totalRecords', { total: historiesQuery.data.total })}
</span>
<div className='flex items-center gap-1.5'>
<Button
variant='outline'
@@ -284,10 +304,10 @@ export function HistoriesTab() {
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<History className='size-5 text-primary' />
推送通知详情
{t('pushNotificationDetail')}
</DialogTitle>
<DialogDescription>
查看该条通知发送的详细审计信息与内容
{t('pushNotificationDetailDesc')}
</DialogDescription>
</DialogHeader>
@@ -296,7 +316,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
事件标识
{t('eventIdentifier')}
</span>
<div className='font-mono bg-muted/40 p-2 rounded border'>
{selectedHistory.event_key}
@@ -304,11 +324,11 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送渠道
{t('sendChannel')}
</span>
<div className='bg-muted/40 p-2 rounded border uppercase font-medium'>
{selectedHistory.channel === 'email'
? '邮件推送'
? t('emailPush')
: selectedHistory.channel}
</div>
</div>
@@ -317,7 +337,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
推送目标
{t('pushTarget')}
</span>
<div
className='font-mono bg-muted/40 p-2 rounded border truncate'
@@ -328,7 +348,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送时间
{t('sendTime')}
</span>
<div className='bg-muted/40 p-2 rounded border'>
{new Date(selectedHistory.created_at).toLocaleString()}
@@ -339,7 +359,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知等级
{t('notificationLevel')}
</span>
<div>
<Badge
@@ -352,7 +372,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送状态
{t('sendStatus')}
</span>
<div>
<Badge
@@ -363,7 +383,9 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold py-0.5 px-2'
>
{selectedHistory.status === 'success' ? '成功' : '失败'}
{selectedHistory.status === 'success'
? t('success')
: t('failed')}
</Badge>
</div>
</div>
@@ -373,7 +395,7 @@ export function HistoriesTab() {
selectedHistory.error_msg && (
<div className='space-y-1.5'>
<span className='font-semibold text-destructive block'>
失败原因
{t('failureReason')}
</span>
<div className='font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all'>
{selectedHistory.error_msg}
@@ -383,7 +405,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知标题
{t('notificationTitle')}
</span>
<div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'>
{selectedHistory.title}
@@ -392,7 +414,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知内容
{t('notificationContent')}
</span>
<div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'>
{selectedHistory.content}
@@ -408,7 +430,7 @@ export function HistoriesTab() {
onClick={() => setDetailOpen(false)}
className='h-9 text-xs'
>
关闭
{t('close')}
</Button>
</DialogFooter>
</DialogContent>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@@ -61,6 +62,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
null,
@@ -82,12 +84,12 @@ export function SettingsTab() {
const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
onSuccess: () => {
toast.success('通道创建成功');
toast.success(t('channelCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道创建失败: ' + (err as Error).message);
toast.error(t('channelCreateFailed') + ': ' + (err as Error).message);
},
});
@@ -95,12 +97,12 @@ export function SettingsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
PushService.updateChannel(id, data),
onSuccess: () => {
toast.success('通道更新成功');
toast.success(t('channelUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道更新失败: ' + (err as Error).message);
toast.error(t('channelUpdateFailed') + ': ' + (err as Error).message);
},
});
@@ -108,11 +110,11 @@ export function SettingsTab() {
mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => {
setDeleteTarget(null);
toast.success('通道删除成功');
toast.success(t('channelDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
},
onError: (err: unknown) => {
toast.error('通道删除失败: ' + (err as Error).message);
toast.error(t('channelDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -195,16 +197,16 @@ export function SettingsTab() {
const handleSaveChannel = () => {
if (!channelName && !editingChannel) {
toast.error('通道名称不能为空');
toast.error(t('channelNameRequired'));
return;
}
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
toast.error('通道名称只能使用英文字母、数字和下划线');
toast.error(t('channelNameFormat'));
return;
}
if (!activeDef) {
toast.error('无效的通道类型');
toast.error(t('invalidChannelType'));
return;
}
@@ -217,7 +219,7 @@ export function SettingsTab() {
? channelToken
: channelOther;
if (field.required && !value.trim()) {
toast.error(`${field.label}不能为空`);
toast.error(t('fieldRequired', { label: field.label }));
return;
}
}
@@ -225,7 +227,7 @@ export function SettingsTab() {
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
if (channelType !== 'email') {
if (channelUrl && !channelUrl.startsWith('https://')) {
toast.error('地址必须以 https:// 开头以确保安全性');
toast.error(t('httpsRequired'));
return;
}
}
@@ -235,14 +237,14 @@ export function SettingsTab() {
try {
JSON.parse(channelOther);
} catch {
toast.error('请求体必须是合法的 JSON 格式');
toast.error(t('jsonFormatRequired'));
return;
}
} else if (channelType === 'lark' && channelOther) {
try {
JSON.parse(channelOther);
} catch {
toast.error('自定义卡片模版必须是合法的 JSON 格式');
toast.error(t('larkTemplateFormat'));
return;
}
}
@@ -281,15 +283,15 @@ export function SettingsTab() {
const handleSendChannelTest = async () => {
try {
setIsTestingChannel(true);
toast.info('正在发送测试推送...');
toast.info(t('sendingTestPush'));
await PushService.testChannel({
name: testChannelName,
target: testChannelTarget || undefined,
});
toast.success('测试推送发送成功,请前往对应平台确认。');
toast.success(t('testPushSuccess'));
setTestChannelOpen(false);
} catch (err: unknown) {
toast.error('连通性测试失败: ' + (err as Error).message);
toast.error(t('connectivityTestFailed') + ': ' + (err as Error).message);
} finally {
setIsTestingChannel(false);
}
@@ -299,9 +301,9 @@ export function SettingsTab() {
<div className='pt-4 space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h2 className='text-sm font-semibold'>自定义推送通道</h2>
<h2 className='text-sm font-semibold'>{t('customPushChannels')}</h2>
<p className='text-[11px] text-muted-foreground mt-0.5'>
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
{t('customPushChannelsDesc')}
</p>
</div>
<Button
@@ -310,14 +312,14 @@ export function SettingsTab() {
className='text-xs'
>
<Plus className='size-3.5 mr-1' />
新建消息通道
{t('createChannel')}
</Button>
</div>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Settings}
description='加载消息通道中...'
description={t('loadingChannels')}
/>
) : channelsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
@@ -334,7 +336,7 @@ export function SettingsTab() {
style={{ animationDuration: '3s' }}
/>
<span className='text-xs font-medium'>
暂无自定义通道配置,请点击右上角新建
{t('noCustomChannels')}
</span>
</div>
) : (
@@ -343,19 +345,19 @@ export function SettingsTab() {
<TableHeader className='sticky top-0 z-20 bg-background'>
<TableRow className='border-b border-dashed hover:bg-transparent'>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
名称
{t('colName')}
</TableHead>
<TableHead className='w-[100px] whitespace-nowrap py-2 h-8'>
类型
{t('colType')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
备注
{t('colRemark')}
</TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态
{t('colStatus')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -380,7 +382,9 @@ export function SettingsTab() {
</Badge>
</TableCell>
<TableCell className='text-xs text-muted-foreground max-w-[200px] truncate py-1'>
{ch.description || <span className='italic'>无备注</span>}
{ch.description || (
<span className='italic'>{t('noRemark')}</span>
)}
</TableCell>
<TableCell
className='text-center py-1'
@@ -416,7 +420,7 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10'
>
<Play className='size-2.5 mr-1' />
测试
{t('test')}
</Button>
<Button
variant='ghost'
@@ -425,7 +429,7 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground'
>
<Edit2 className='size-2.5 mr-1' />
编辑
{t('edit')}
</Button>
<Button
variant='ghost'
@@ -435,7 +439,7 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
>
<Trash2 className='size-2.5 mr-1' />
删除
{t('delete')}
</Button>
</div>
</TableCell>
@@ -451,35 +455,34 @@ export function SettingsTab() {
<DialogContent className='sm:max-w-[600px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{editingChannel ? '编辑消息通道' : '新建消息通道'}
{editingChannel
? t('editChannelDialogTitle')
: t('createChannelDialogTitle')}
</DialogTitle>
<DialogDescription>
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook
或推送服务投递数据
</DialogDescription>
<DialogDescription>{t('channelDialogDesc')}</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>名称</Label>
<Label className='text-xs font-semibold'>{t('channelName')}</Label>
<Input
type='text'
placeholder='请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一'
placeholder={t('channelNamePlaceholder')}
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
disabled={!!editingChannel}
className='text-xs h-9 font-mono'
/>
<p className='text-[10px] text-muted-foreground'>
通道唯一标识,创建后不可修改
{t('channelNameHint')}
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>备注</Label>
<Label className='text-xs font-semibold'>{t('channelRemark')}</Label>
<Input
type='text'
placeholder='请输入备注信息'
placeholder={t('channelRemarkPlaceholder')}
value={channelDescription}
onChange={(e) => setChannelDescription(e.target.value)}
className='text-xs h-9'
@@ -487,13 +490,13 @@ export function SettingsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>通道类型</Label>
<Label className='text-xs font-semibold'>{t('channelType')}</Label>
<Select
value={channelType}
onValueChange={handleChannelTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='选择通道类型' />
<SelectValue placeholder={t('selectChannelType')} />
</SelectTrigger>
<SelectContent>
{(definitionsQuery.data ?? []).map((d) => (
@@ -509,7 +512,7 @@ export function SettingsTab() {
<>
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-1.5'>
<div className='text-xs font-semibold'>
{activeDef.name}配置说明
{t('configHelp', { name: activeDef.name })}
</div>
<p className='text-[11px] text-muted-foreground leading-relaxed'>
{activeDef.description}
@@ -572,7 +575,7 @@ export function SettingsTab() {
{channelType === 'custom' && (
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-2.5'>
<Label className='text-[11px] font-semibold'>
快捷加载常用模版实例:
{t('quickLoadTemplates')}
</Label>
<div className='flex flex-wrap gap-1.5'>
<Button
@@ -596,7 +599,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载飞书 Webhook 模版');
toast.success(t('loadedFeishuTemplate'));
}}
>
飞书 Webhook
@@ -623,7 +626,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载钉钉机器人模版');
toast.success(t('loadedDingTalkTemplate'));
}}
>
钉钉群机器人
@@ -650,7 +653,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载企业微信机器人模版');
toast.success(t('loadedWeChatTemplate'));
}}
>
企业微信群机器人
@@ -674,7 +677,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载 Bark App 模版');
toast.success(t('loadedBarkTemplate'));
}}
>
Bark App
@@ -693,7 +696,7 @@ export function SettingsTab() {
onClick={() => setChannelDialogOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -709,7 +712,7 @@ export function SettingsTab() {
updateChannelMutation.isPending) && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
确定
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -719,15 +722,15 @@ export function SettingsTab() {
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
<DialogContent className='sm:max-w-[450px]'>
<DialogHeader>
<DialogTitle>发送测试通知</DialogTitle>
<DialogTitle>{t('sendTestNotification')}</DialogTitle>
<DialogDescription>
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
{t('sendTestNotificationDesc')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3'>
<div className='space-y-1.5'>
<Label className='text-xs'>推送通道名称</Label>
<Label className='text-xs'>{t('pushChannelName')}</Label>
<Input
type='text'
value={testChannelName}
@@ -736,10 +739,10 @@ export function SettingsTab() {
/>
</div>
<div className='space-y-1.5'>
<Label className='text-xs'>测试推送目标 (对应模板变量 $to)</Label>
<Label className='text-xs'>{t('testPushTarget')}</Label>
<Input
type='text'
placeholder='请输入测试推送接收人/目标标识,如 Bark Token、邮箱等'
placeholder={t('testPushTargetPlaceholder')}
value={testChannelTarget}
onChange={(e) => setTestChannelTarget(e.target.value)}
className='text-xs h-9'
@@ -754,7 +757,7 @@ export function SettingsTab() {
onClick={() => setTestChannelOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -766,7 +769,7 @@ export function SettingsTab() {
{isTestingChannel && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
发送测试
{t('sendTest')}
</Button>
</DialogFooter>
</DialogContent>
@@ -778,14 +781,14 @@ export function SettingsTab() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除通道</AlertDialogTitle>
<AlertDialogTitle>{t('deleteChannelTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除通道「{deleteTarget?.name}」吗?删除后无法恢复。
{t('deleteChannelConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteChannelMutation.isPending}>
取消
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
disabled={deleteChannelMutation.isPending}
@@ -793,7 +796,9 @@ export function SettingsTab() {
deleteTarget && deleteChannelMutation.mutate(deleteTarget.id)
}
>
{deleteChannelMutation.isPending ? '删除中...' : '确认删除'}
{deleteChannelMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+6 -4
View File
@@ -6,6 +6,7 @@
import * as React from 'react';
import { motion } from 'motion/react';
import { Bell, History, Layers, Settings } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EventsTab } from './components/events-tab';
@@ -13,6 +14,7 @@ import { HistoriesTab } from './components/histories-tab';
import { SettingsTab } from './components/settings-tab';
export default function PushAdminPage() {
const t = useTranslations('admin.push');
const [activeTab, setActiveTab] = React.useState('events');
return (
@@ -26,7 +28,7 @@ export default function PushAdminPage() {
<Bell className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
通知推送管理
{t('pageTitle')}
</h1>
</div>
</div>
@@ -38,21 +40,21 @@ export default function PushAdminPage() {
className='px-0 pb-2 text-xs font-semibold'
>
<Layers className='size-3.5 mr-1' />
事件管理
{t('eventsTab')}
</TabsTrigger>
<TabsTrigger
value='histories'
className='px-0 pb-2 text-xs font-semibold'
>
<History className='size-3.5 mr-1' />
通知历史
{t('historiesTab')}
</TabsTrigger>
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
<Settings className='size-3.5 mr-1' />
通道管理与设置
{t('settingsTab')}
</TabsTrigger>
</TabsList>
@@ -89,11 +89,18 @@ export function mapOptionsToOpsFields(
};
}
export function formatDurationLabel(value: string) {
export type OpsMessage = (
key: string,
values?: Record<string, string | number>,
) => string;
export function formatDurationLabel(value: string, t: OpsMessage) {
const milliseconds = Number.parseInt(value, 10);
if (Number.isNaN(milliseconds)) return value;
if (milliseconds >= 60000) return `${milliseconds / 60000} 分钟`;
return `${milliseconds / 1000} 秒`;
if (milliseconds >= 60000) {
return t('duration.minutes', { count: milliseconds / 60000 });
}
return t('duration.seconds', { count: milliseconds / 1000 });
}
export function normalizeServerUrl(value: string) {
@@ -116,21 +123,24 @@ export function buildDiscoveryCommand(
].join('\n');
}
export function validateAgentFields(fields: OpenFlareOpsFields) {
export function validateAgentFields(fields: OpenFlareOpsFields, t: OpsMessage) {
const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10);
const offline = Number.parseInt(fields.node_offline_threshold, 10);
if (Number.isNaN(heartbeat) || heartbeat < 1000) {
throw new Error('心跳间隔不能小于 1000 毫秒。');
throw new Error(t('errors.heartbeatMin'));
}
if (Number.isNaN(offline) || offline < 10000) {
throw new Error('离线阈值不能小于 10000 毫秒。');
throw new Error(t('errors.offlineMin'));
}
if (offline < heartbeat * 3) {
throw new Error('离线阈值建议至少为心跳间隔的 3 倍。');
throw new Error(t('errors.offlineRatio'));
}
}
export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
export function validateUptimeKumaFields(
fields: OpenFlareOpsFields,
t: OpsMessage,
) {
const syncInt = Number.parseInt(fields.uptime_kuma_sync_interval, 10);
const interval = Number.parseInt(fields.uptime_kuma_interval, 10);
const retry = Number.parseInt(fields.uptime_kuma_retry, 10);
@@ -138,25 +148,26 @@ export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
const timeout = Number.parseInt(fields.uptime_kuma_timeout, 10);
if (fields.uptime_kuma_enabled) {
if (!fields.uptime_kuma_url.trim())
throw new Error('请输入 Uptime Kuma 地址。');
if (!fields.uptime_kuma_url.trim()) throw new Error(t('errors.kumaUrl'));
if (!fields.uptime_kuma_username.trim())
throw new Error('请输入 Uptime Kuma 用户名。');
throw new Error(t('errors.kumaUser'));
}
if (Number.isNaN(syncInt) || syncInt <= 0)
throw new Error('同步间隔必须为正整数。');
throw new Error(t('errors.syncInterval'));
if (Number.isNaN(interval) || interval <= 0)
throw new Error('心跳间隔必须为正整数。');
if (Number.isNaN(retry) || retry < 0)
throw new Error('重试次数必须为非负整数。');
throw new Error(t('errors.interval'));
if (Number.isNaN(retry) || retry < 0) throw new Error(t('errors.retry'));
if (Number.isNaN(retryInt) || retryInt <= 0)
throw new Error('心跳重试间隔必须为正整数。');
throw new Error(t('errors.retryInterval'));
if (Number.isNaN(timeout) || timeout <= 0)
throw new Error('请求超时必须为正整数。');
throw new Error(t('errors.timeout'));
}
export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
validateAgentFields(fields);
export function agentOptionEntries(
fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validateAgentFields(fields, t);
return [
{ key: 'agent_heartbeat_interval', value: fields.agent_heartbeat_interval },
{
@@ -171,8 +182,9 @@ export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
export function uptimeKumaOptionEntries(
fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validateUptimeKumaFields(fields);
validateUptimeKumaFields(fields, t);
return [
{ key: 'uptime_kuma_enabled', value: String(fields.uptime_kuma_enabled) },
{ key: 'uptime_kuma_url', value: fields.uptime_kuma_url.trim() },
@@ -200,21 +212,22 @@ export function uptimeKumaOptionEntries(
];
}
export function validatePagesFields(fields: OpenFlareOpsFields) {
export function validatePagesFields(fields: OpenFlareOpsFields, t: OpsMessage) {
const packageSize = Number.parseInt(fields.pages_max_package_size_mb, 10);
const historyCount = Number.parseInt(fields.pages_max_history_count, 10);
if (Number.isNaN(packageSize) || packageSize < 1 || packageSize > 2048) {
throw new Error('Pages 部署包大小上限必须为 1~2048 MiB。');
throw new Error(t('errors.pagesSize'));
}
if (Number.isNaN(historyCount) || historyCount < 0) {
throw new Error(
'Pages 历史保留数必须为大于等于 0 的整数(0 表示不限制)。',
);
throw new Error(t('errors.pagesHistory'));
}
}
export function pagesOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
validatePagesFields(fields);
export function pagesOptionEntries(
fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validatePagesFields(fields, t);
return [
{
key: 'pages_max_package_size_mb',
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -63,6 +64,7 @@ async function copyText(value: string) {
}
export function OpenFlareOpsSettings() {
const t = useTranslations('openflareOps');
const queryClient = useQueryClient();
const [fields, setFields] = useState<OpenFlareOpsFields>(
defaultOpenFlareOpsFields,
@@ -113,20 +115,20 @@ export function OpenFlareOpsSettings() {
await OptionService.updateBatch(entries);
},
onSuccess: async () => {
toast.success('OpenFlare 运维设置已保存');
toast.success(t('saved'));
await queryClient.invalidateQueries({ queryKey: optionsQueryKey });
setSavingSection(null);
},
onError: (error) => {
setSavingSection(null);
toast.error(error instanceof Error ? error.message : '保存失败');
toast.error(error instanceof Error ? error.message : t('saveFailed'));
},
});
const rotateTokenMutation = useMutation({
mutationFn: () => NodeService.rotateBootstrapToken(),
onSuccess: async (data) => {
toast.success('Discovery Token 已重新生成');
toast.success(t('tokenRotated'));
await queryClient.invalidateQueries({
queryKey: ['openflare', 'bootstrap-token'],
});
@@ -139,15 +141,17 @@ export function OpenFlareOpsSettings() {
}
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Token 轮换失败');
toast.error(
error instanceof Error ? error.message : t('tokenRotateFailed'),
);
},
});
const syncUptimeKumaMutation = useMutation({
mutationFn: () => UptimeKumaService.sync(),
onSuccess: () => toast.success('Uptime Kuma 同步任务已执行'),
onSuccess: () => toast.success(t('kumaSynced')),
onError: (error) =>
toast.error(error instanceof Error ? error.message : '同步失败'),
toast.error(error instanceof Error ? error.message : t('syncFailed')),
});
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
@@ -167,10 +171,10 @@ export function OpenFlareOpsSettings() {
try {
saveMutation.mutate({
section: 'agent',
entries: agentOptionEntries(fields),
entries: agentOptionEntries(fields, t),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : '参数校验失败');
toast.error(error instanceof Error ? error.message : t('invalidParams'));
}
};
@@ -178,10 +182,10 @@ export function OpenFlareOpsSettings() {
try {
saveMutation.mutate({
section: 'uptimekuma',
entries: uptimeKumaOptionEntries(fields),
entries: uptimeKumaOptionEntries(fields, t),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : '参数校验失败');
toast.error(error instanceof Error ? error.message : t('invalidParams'));
}
};
@@ -189,19 +193,16 @@ export function OpenFlareOpsSettings() {
try {
saveMutation.mutate({
section: 'pages',
entries: pagesOptionEntries(fields),
entries: pagesOptionEntries(fields, t),
});
} catch (error) {
toast.error(error instanceof Error ? error.message : '参数校验失败');
toast.error(error instanceof Error ? error.message : t('invalidParams'));
}
};
if (optionsQuery.isLoading) {
return (
<LoadingStateWithBorder
icon={Server}
description='加载 OpenFlare 运维设置...'
/>
<LoadingStateWithBorder icon={Server} description={t('loading')} />
);
}
@@ -211,7 +212,7 @@ export function OpenFlareOpsSettings() {
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: '加载失败'
: t('loadFailed')
}
onRetry={() => void optionsQuery.refetch()}
/>
@@ -224,10 +225,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>Agent 运行参数</CardTitle>
<CardDescription>
心跳间隔与离线阈值会在下个心跳周期同步到节点。
</CardDescription>
<CardTitle className='text-base'>{t('agent.title')}</CardTitle>
<CardDescription>{t('agent.description')}</CardDescription>
</div>
<Button
size='sm'
@@ -239,13 +238,18 @@ export function OpenFlareOpsSettings() {
) : (
<Save className='size-3.5 mr-1' />
)}
保存
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label={`心跳间隔 (${formatDurationLabel(fields.agent_heartbeat_interval)})`}
label={t('agent.heartbeat', {
duration: formatDurationLabel(
fields.agent_heartbeat_interval,
t,
),
})}
value={fields.agent_heartbeat_interval}
type='number'
onChange={(value) =>
@@ -253,7 +257,12 @@ export function OpenFlareOpsSettings() {
}
/>
<FieldInput
label={`离线阈值 (${formatDurationLabel(fields.node_offline_threshold)})`}
label={t('agent.offline', {
duration: formatDurationLabel(
fields.node_offline_threshold,
t,
),
})}
value={fields.node_offline_threshold}
type='number'
onChange={(value) =>
@@ -262,15 +271,15 @@ export function OpenFlareOpsSettings() {
/>
</div>
<ToggleRow
label='开启 WS 连接升级'
description='HTTP 心跳成功后尝试升级为 WebSocket,配置发布可即时通知。'
label={t('agent.wsUpgrade')}
description={t('agent.wsUpgradeDesc')}
checked={fields.agent_websocket_upgrade_enabled}
onChange={(value) =>
updateField('agent_websocket_upgrade_enabled', value)
}
/>
<FieldInput
label='Agent 更新仓库'
label={t('agent.updateRepo')}
value={fields.agent_update_repo}
placeholder='Rain-kl/OpenFlare'
onChange={(value) => updateField('agent_update_repo', value)}
@@ -281,11 +290,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>IP 归属解析</CardTitle>
<CardDescription>
控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用
MaxMind mmdb。
</CardDescription>
<CardTitle className='text-base'>{t('geo.title')}</CardTitle>
<CardDescription>{t('geo.description')}</CardDescription>
</div>
<Button
size='sm'
@@ -297,12 +303,12 @@ export function OpenFlareOpsSettings() {
) : (
<Save className='size-3.5 mr-1' />
)}
保存
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-1.5'>
<Label>归属方式</Label>
<Label>{t('geo.mode')}</Label>
<Select
value={fields.geoip_provider}
onValueChange={(value) => updateField('geoip_provider', value)}
@@ -311,7 +317,7 @@ export function OpenFlareOpsSettings() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='disabled'>关闭</SelectItem>
<SelectItem value='disabled'>{t('geo.disabled')}</SelectItem>
<SelectItem value='mmdb'>MaxMind mmdb</SelectItem>
<SelectItem value='ip-api'>ip-api.com</SelectItem>
<SelectItem value='geojs'>geojs.io</SelectItem>
@@ -321,7 +327,7 @@ export function OpenFlareOpsSettings() {
</div>
<div className='flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-end'>
<FieldInput
label='测试 IP'
label={t('geo.testIp')}
value={geoIPTestIP}
onChange={setGeoIPTestIP}
placeholder='8.8.8.8'
@@ -332,13 +338,13 @@ export function OpenFlareOpsSettings() {
disabled={geoIPMutation.isPending}
onClick={() => geoIPMutation.mutate()}
>
{geoIPMutation.isPending ? '查询中...' : '查询归属'}
{geoIPMutation.isPending ? t('geo.querying') : t('geo.query')}
</Button>
</div>
{geoIPMutation.data ? (
<div className='grid gap-2 text-sm sm:grid-cols-2'>
<InfoCell
label='国家/地区'
label={t('geo.country')}
value={geoIPMutation.data.name || '—'}
/>
<InfoCell
@@ -354,10 +360,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>Pages 静态托管</CardTitle>
<CardDescription>
配置部署包上传体积上限与每个项目的历史部署保留数量。
</CardDescription>
<CardTitle className='text-base'>{t('pages.title')}</CardTitle>
<CardDescription>{t('pages.description')}</CardDescription>
</div>
<Button
size='sm'
@@ -369,13 +373,13 @@ export function OpenFlareOpsSettings() {
) : (
<Save className='size-3.5 mr-1' />
)}
保存
{t('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label='部署包大小上限 (MiB)'
label={t('pages.maxSize')}
value={fields.pages_max_package_size_mb}
type='number'
onChange={(value) =>
@@ -384,7 +388,7 @@ export function OpenFlareOpsSettings() {
placeholder='100'
/>
<FieldInput
label='历史部署保留数(0 不限制)'
label={t('pages.history')}
value={fields.pages_max_history_count}
type='number'
onChange={(value) =>
@@ -394,9 +398,7 @@ export function OpenFlareOpsSettings() {
/>
</div>
<p className='text-xs text-muted-foreground'>
每个项目最多保留 N
条部署:激活部署始终保留,其余按从新到旧填充;超出的非激活部署会在上传成功后自动清理。支持
zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式。
{t('pages.historyHint')}
</p>
</CardContent>
</Card>
@@ -404,17 +406,16 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>Discovery Token 与部署</CardTitle>
<CardDescription>
新节点首次接入使用 Discovery
Token;轮换请前往节点管理或使用下方操作。
</CardDescription>
<CardTitle className='text-base'>
{t('discovery.title')}
</CardTitle>
<CardDescription>{t('discovery.description')}</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href='/nodes'>
<ExternalLink className='size-3.5 mr-1' />
节点管理
{t('discovery.manageNodes')}
</Link>
</Button>
<Button
@@ -424,7 +425,7 @@ export function OpenFlareOpsSettings() {
onClick={() => rotateTokenMutation.mutate()}
>
<RotateCw className='size-3.5 mr-1' />
轮换 Token
{t('discovery.rotate')}
</Button>
</div>
</CardHeader>
@@ -437,21 +438,19 @@ export function OpenFlareOpsSettings() {
/>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
Discovery Token(只读)
{t('discovery.tokenReadonly')}
</p>
<p className='mt-2 break-all text-sm font-mono'>
{bootstrapQuery.isLoading
? '加载中...'
: discoveryToken || '未生成'}
? t('discovery.loading')
: discoveryToken || t('discovery.notGenerated')}
</p>
</div>
<div className='space-y-1.5'>
<Label>一键部署命令</Label>
<Label>{t('discovery.command')}</Label>
<Textarea
readOnly
value={
discoveryCommand || '请先填写可访问的 Server URL 并获取 Token。'
}
value={discoveryCommand || t('discovery.commandHint')}
className='min-h-24 font-mono text-xs'
/>
{discoveryCommand ? (
@@ -461,12 +460,12 @@ export function OpenFlareOpsSettings() {
size='sm'
onClick={() =>
void copyText(discoveryCommand).then(() =>
toast.success('命令已复制'),
toast.success(t('discovery.copied')),
)
}
>
<Copy className='size-3.5 mr-1' />
复制命令
{t('discovery.copy')}
</Button>
) : null}
</div>
@@ -476,10 +475,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>Uptime Kuma 集成</CardTitle>
<CardDescription>
将反代站点差分同步至 Uptime Kuma 监控实例。
</CardDescription>
<CardTitle className='text-base'>{t('kuma.title')}</CardTitle>
<CardDescription>{t('kuma.description')}</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
<Button
@@ -491,20 +488,20 @@ export function OpenFlareOpsSettings() {
onClick={() => syncUptimeKumaMutation.mutate()}
>
<RefreshCw className='size-3.5 mr-1' />
立即同步
{t('kuma.syncNow')}
</Button>
<Button
size='sm'
disabled={savingSection === 'uptimekuma'}
onClick={saveUptimeKumaSettings}
>
保存
{t('save')}
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<ToggleRow
label='开启 Uptime Kuma'
label={t('kuma.enable')}
checked={fields.uptime_kuma_enabled}
onChange={(value) => updateField('uptime_kuma_enabled', value)}
/>
@@ -512,29 +509,29 @@ export function OpenFlareOpsSettings() {
<>
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label='Uptime Kuma 地址'
label={t('kuma.url')}
value={fields.uptime_kuma_url}
onChange={(value) => updateField('uptime_kuma_url', value)}
placeholder='http://localhost:3001'
/>
<FieldInput
label='用户名'
label={t('kuma.username')}
value={fields.uptime_kuma_username}
onChange={(value) =>
updateField('uptime_kuma_username', value)
}
/>
<FieldInput
label='密码'
label={t('kuma.password')}
value={fields.uptime_kuma_password}
type='password'
onChange={(value) =>
updateField('uptime_kuma_password', value)
}
placeholder='留空表示不更新'
placeholder={t('kuma.passwordKeep')}
/>
<FieldInput
label='同步间隔 (分钟)'
label={t('kuma.syncInterval')}
value={fields.uptime_kuma_sync_interval}
type='number'
onChange={(value) =>
@@ -542,7 +539,7 @@ export function OpenFlareOpsSettings() {
}
/>
<div className='space-y-1.5'>
<Label>监控范围</Label>
<Label>{t('kuma.scope')}</Label>
<Select
value={fields.uptime_kuma_monitor_scope}
onValueChange={(value) =>
@@ -553,8 +550,10 @@ export function OpenFlareOpsSettings() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部站点</SelectItem>
<SelectItem value='selected'>选择站点</SelectItem>
<SelectItem value='all'>{t('kuma.allSites')}</SelectItem>
<SelectItem value='selected'>
{t('kuma.selectedSites')}
</SelectItem>
</SelectContent>
</Select>
</div>
@@ -562,26 +561,28 @@ export function OpenFlareOpsSettings() {
{fields.uptime_kuma_monitor_scope === 'selected' ? (
<div className='rounded-lg border border-dashed p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>已选站点</p>
<p className='text-sm font-medium'>
{t('kuma.selectedLabel')}
</p>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setUptimeKumaModalOpen(true)}
>
选择监控站点
{t('kuma.pickSites')}
</Button>
</div>
<p className='mt-2 break-all text-xs text-muted-foreground'>
{fields.uptime_kuma_selected_sites
? fields.uptime_kuma_selected_sites.split(',').join(', ')
: '未选择任何站点'}
: t('kuma.noneSelected')}
</p>
</div>
) : null}
<div className='grid gap-4 md:grid-cols-2'>
<FieldInput
label='检测频率 (秒)'
label={t('kuma.interval')}
value={fields.uptime_kuma_interval}
type='number'
onChange={(value) =>
@@ -589,13 +590,13 @@ export function OpenFlareOpsSettings() {
}
/>
<FieldInput
label='重试次数'
label={t('kuma.retry')}
value={fields.uptime_kuma_retry}
type='number'
onChange={(value) => updateField('uptime_kuma_retry', value)}
/>
<FieldInput
label='重试间隔 (秒)'
label={t('kuma.retryInterval')}
value={fields.uptime_kuma_retry_interval}
type='number'
onChange={(value) =>
@@ -603,7 +604,7 @@ export function OpenFlareOpsSettings() {
}
/>
<FieldInput
label='请求超时 (秒)'
label={t('kuma.timeout')}
value={fields.uptime_kuma_timeout}
type='number'
onChange={(value) =>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
@@ -45,6 +46,7 @@ const formatNumber = (num: number | string) => {
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const t = useTranslations('openflareOps.status');
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false);
@@ -82,14 +84,14 @@ export function SystemStatusManager() {
setStatus(data);
prevStatusRef.current = data;
} catch (err) {
toast.error('获取系统状态失败', {
description: err instanceof Error ? err.message : '请求时发生未知错误',
toast.error(t('loadFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoading(false);
setWavelet(false);
}
}, []);
}, [t]);
// 轮询逻辑
useEffect(() => {
@@ -196,7 +198,9 @@ export function SystemStatusManager() {
<div className='flex items-center gap-2'>
<Activity className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>系统状态</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
</div>
@@ -211,7 +215,7 @@ export function SystemStatusManager() {
htmlFor='auto-refresh'
className='text-xs font-medium cursor-pointer select-none'
>
自动刷新
{t('autoRefresh')}
</label>
</div>
@@ -237,7 +241,7 @@ export function SystemStatusManager() {
disabled={wavelet}
>
<RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} />
刷新
{t('refresh')}
</Button>
</div>
</div>
@@ -251,10 +255,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
服务概览
{t('overview')}
</CardTitle>
<CardDescription className='text-[10px]'>
服务的基础生命指标
{t('overviewDesc')}
</CardDescription>
</div>
<Badge
@@ -266,17 +270,17 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='服务运行时间'
label={t('uptime')}
value={status.uptime}
field='uptime'
/>
<RenderMetricRow
label='当前 Goroutines 数量'
label={t('goroutines')}
value={status.num_goroutine}
field='num_goroutine'
/>
<RenderMetricRow
label='Heap 对象数量'
label={t('heapObjects')}
value={status.heap_objects}
field='heap_objects'
/>
@@ -288,10 +292,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
内存统计
{t('memory')}
</CardTitle>
<CardDescription className='text-[10px]'>
主内存消耗概览
{t('memoryDesc')}
</CardDescription>
</div>
<Badge
@@ -304,22 +308,22 @@ export function SystemStatusManager() {
<CardContent className='space-y-3'>
<div className='space-y-1'>
<RenderMetricRow
label='当前内存使用量'
label={t('alloc')}
value={status.alloc}
field='alloc'
/>
<RenderMetricRow
label='所有已分配的内存'
label={t('totalAlloc')}
value={status.total_alloc}
field='total_alloc'
/>
<RenderMetricRow
label='内存占用量'
label={t('sys')}
value={status.sys}
field='sys'
/>
<RenderMetricRow
label='下次 GC 内存回收量'
label={t('nextGc')}
value={status.next_gc}
field='next_gc'
/>
@@ -327,7 +331,7 @@ export function SystemStatusManager() {
{/* 物理内存水位比例 */}
<div className='space-y-1 px-1 pt-1'>
<div className='flex justify-between text-[10px] text-muted-foreground'>
<span>当前使用率 (Alloc / Sys)</span>
<span>{t('usageRatio')}</span>
<span className='font-mono'>
{getMemoryUsagePercent()}%
</span>
@@ -342,10 +346,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
堆/栈详情
{t('heapStack')}
</CardTitle>
<CardDescription className='text-[10px]'>
运行时堆栈内存空间细节
{t('heapStackDesc')}
</CardDescription>
</div>
<Badge
@@ -357,37 +361,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='当前 Heap 内存使用量'
label={t('heapAlloc')}
value={status.heap_alloc}
field='heap_alloc'
/>
<RenderMetricRow
label='Heap 内存占用量'
label={t('heapSys')}
value={status.heap_sys}
field='heap_sys'
/>
<RenderMetricRow
label='Heap 内存空闲量'
label={t('heapIdle')}
value={status.heap_idle}
field='heap_idle'
/>
<RenderMetricRow
label='正在使用的 Heap 内存'
label={t('heapInuse')}
value={status.heap_inuse}
field='heap_inuse'
/>
<RenderMetricRow
label='已释放的 Heap 内存'
label={t('heapReleased')}
value={status.heap_released}
field='heap_released'
/>
<RenderMetricRow
label='启动 Stack 使用量'
label={t('stackInuse')}
value={status.stack_inuse}
field='stack_inuse'
/>
<RenderMetricRow
label='已分配的 Stack 内存'
label={t('stackSys')}
value={status.stack_sys}
field='stack_sys'
/>
@@ -399,10 +403,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
底层及结构体内存
{t('structs')}
</CardTitle>
<CardDescription className='text-[10px]'>
运行时底层管理结构体开销
{t('structsDesc')}
</CardDescription>
</div>
<Badge
@@ -414,37 +418,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='MSpan 结构内存使用量'
label={t('mspanInuse')}
value={status.mspan_inuse}
field='mspan_inuse'
/>
<RenderMetricRow
label='已分配的 MSpan 结构内存'
label={t('mspanSys')}
value={status.mspan_sys}
field='mspan_sys'
/>
<RenderMetricRow
label='MCache 结构内存使用量'
label={t('mcacheInuse')}
value={status.mcache_inuse}
field='mcache_inuse'
/>
<RenderMetricRow
label='已分配的 MCache 结构内存'
label={t('mcacheSys')}
value={status.mcache_sys}
field='mcache_sys'
/>
<RenderMetricRow
label='已分配的剖析哈希表内存'
label={t('buckHashSys')}
value={status.buck_hash_sys}
field='buck_hash_sys'
/>
<RenderMetricRow
label='已分配的 GC 元数据内存'
label={t('gcSys')}
value={status.gc_sys}
field='gc_sys'
/>
<RenderMetricRow
label='其它已分配的系统内存'
label={t('otherSys')}
value={status.other_sys}
field='other_sys'
/>
@@ -456,10 +460,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
垃圾回收与分配计数
{t('gc')}
</CardTitle>
<CardDescription className='text-[10px]'>
GC 历史数据与分配频次
{t('gcDesc')}
</CardDescription>
</div>
<Badge
@@ -473,39 +477,39 @@ export function SystemStatusManager() {
<div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'>
<div>
<RenderMetricRow
label='GC 执行次数'
label={t('numGc')}
value={status.num_gc}
field='num_gc'
/>
<RenderMetricRow
label='距离上次 GC 时间'
label={t('lastGc')}
value={status.last_gc_time}
field='last_gc_time'
/>
<RenderMetricRow
label='GC 暂停时间总量'
label={t('pauseTotal')}
value={status.pause_total_ns}
field='pause_total_ns'
/>
<RenderMetricRow
label='上次 GC 暂停时间'
label={t('lastPause')}
value={status.last_pause}
field='last_pause'
/>
</div>
<div>
<RenderMetricRow
label='内存分配次数'
label={t('mallocs')}
value={status.mallocs}
field='mallocs'
/>
<RenderMetricRow
label='内存释放次数'
label={t('frees')}
value={status.frees}
field='frees'
/>
<RenderMetricRow
label='指针查找次数'
label={t('lookups')}
value={status.lookups}
field='lookups'
/>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
@@ -29,6 +30,8 @@ export function UptimeKumaSiteSelectModal({
onOpenChange,
onSave,
}: UptimeKumaSiteSelectModalProps) {
const t = useTranslations('openflareOps.siteModal');
const tCommon = useTranslations('common');
const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
@@ -90,19 +93,17 @@ export function UptimeKumaSiteSelectModal({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>选择监控站点</DialogTitle>
<DialogDescription>
选择要同步到 Uptime Kuma 的反代站点,支持按站点名称和域名搜索。
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-1.5'>
<Label>搜索站点</Label>
<Label>{t('search')}</Label>
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder='按名称或域名搜索...'
placeholder={t('searchPlaceholder')}
/>
</div>
@@ -113,7 +114,7 @@ export function UptimeKumaSiteSelectModal({
size='sm'
onClick={handleSelectAll}
>
全选过滤项
{t('selectFiltered')}
</Button>
<Button
type='button'
@@ -121,30 +122,32 @@ export function UptimeKumaSiteSelectModal({
size='sm'
onClick={handleDeselectAll}
>
清空过滤项
{t('clearFiltered')}
</Button>
</div>
<div className='max-h-72 overflow-y-auto rounded-lg border'>
{routesQuery.isLoading ? (
<p className='p-4 text-sm text-muted-foreground'>
加载站点列表...
{t('loading')}
</p>
) : routesQuery.isError ? (
<p className='p-4 text-sm text-destructive'>
{routesQuery.error instanceof Error
? routesQuery.error.message
: '加载站点失败'}
: t('loadFailed')}
</p>
) : filteredRoutes.length === 0 ? (
<p className='p-4 text-sm text-muted-foreground'>无匹配的站点</p>
<p className='p-4 text-sm text-muted-foreground'>
{t('noMatch')}
</p>
) : (
<table className='w-full text-left text-sm'>
<thead className='sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground'>
<tr>
<th className='w-12 px-3 py-2'>选择</th>
<th className='px-3 py-2'>站点名称</th>
<th className='px-3 py-2'>主域名</th>
<th className='w-12 px-3 py-2'>{t('select')}</th>
<th className='px-3 py-2'>{t('siteName')}</th>
<th className='px-3 py-2'>{t('primaryDomain')}</th>
</tr>
</thead>
<tbody className='divide-y'>
@@ -184,7 +187,7 @@ export function UptimeKumaSiteSelectModal({
</div>
<p className='text-right text-xs text-muted-foreground'>
已选择 {tempSelected.size} 个监控站点
{t('selectedCount', { count: tempSelected.size })}
</p>
</div>
@@ -194,7 +197,7 @@ export function UptimeKumaSiteSelectModal({
variant='outline'
onClick={() => onOpenChange(false)}
>
取消
{tCommon('cancel')}
</Button>
<Button
type='button'
@@ -203,7 +206,7 @@ export function UptimeKumaSiteSelectModal({
onOpenChange(false);
}}
>
保存选择
{t('save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,6 +5,7 @@
import dynamic from 'next/dynamic';
import { useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import { Loader2, Settings } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
@@ -79,6 +80,7 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function AdminSettingsPageClient() {
const t = useTranslations('admin.settings');
const { user, loading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
@@ -136,7 +138,9 @@ export function AdminSettingsPageClient() {
<div className='flex items-center gap-2'>
<Settings className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>系统设置</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
<Tabs
@@ -149,40 +153,40 @@ export function AdminSettingsPageClient() {
value='openflare-ops'
className='px-0 pb-2 text-xs font-semibold'
>
OpenFlare
{t('tabs.openflare')}
</TabsTrigger>
<TabsTrigger
value='security'
className='px-0 pb-2 text-xs font-semibold'
>
安全设置
{t('tabs.security')}
</TabsTrigger>
<TabsTrigger
value='operation'
className='px-0 pb-2 text-xs font-semibold'
>
业务设置
{t('tabs.operation')}
</TabsTrigger>
<TabsTrigger
value='system'
className='px-0 pb-2 text-xs font-semibold'
>
系统设置
{t('tabs.system')}
</TabsTrigger>
<TabsTrigger
value='other'
className='px-0 pb-2 text-xs font-semibold'
>
其他设置
{t('tabs.other')}
</TabsTrigger>
<TabsTrigger
value='status'
className='px-0 pb-2 text-xs font-semibold'
>
系统状态
{t('tabs.status')}
</TabsTrigger>
<TabsTrigger value='info' className='px-0 pb-2 text-xs font-semibold'>
系统信息
{t('tabs.info')}
</TabsTrigger>
</TabsList>
+26 -19
View File
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
@@ -51,13 +52,14 @@ function SystemConfigDetailPanel({
onSave: () => void;
saving: boolean;
}) {
const t = useTranslations('admin.system');
return (
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
<div className='grid grid-cols-1 gap-0'>
<div className='border border-dashed rounded-lg'>
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置键
{t('configKey')}
</label>
<p className='text-xs text-muted-foreground font-mono'>
{config?.key}
@@ -66,7 +68,7 @@ function SystemConfigDetailPanel({
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置值
{t('configValue')}
</label>
<div className='flex gap-1 w-[90%] justify-end items-center pr-3'>
{config?.key.endsWith('_enabled') ? (
@@ -107,7 +109,9 @@ function SystemConfigDetailPanel({
: config?.value || ''
}
placeholder={
editData.value === undefined && !config?.value ? '必需' : ''
editData.value === undefined && !config?.value
? t('required')
: ''
}
onChange={(e) => {
const value = e.target.value;
@@ -128,7 +132,7 @@ function SystemConfigDetailPanel({
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置描述
{t('configDescription')}
</label>
<div className='flex gap-1 w-[90%]'>
<Input
@@ -138,7 +142,7 @@ function SystemConfigDetailPanel({
? editData.description
: config?.description || ''
}
placeholder='可选描述'
placeholder={t('optionalDescription')}
onChange={(e) => {
const value = e.target.value;
onEditDataChange('description', value);
@@ -150,7 +154,7 @@ function SystemConfigDetailPanel({
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
公共可见
{t('publicVisibility')}
</label>
<Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
@@ -162,7 +166,7 @@ function SystemConfigDetailPanel({
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置类型
{t('configType')}
</label>
<span
className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
@@ -171,13 +175,15 @@ function SystemConfigDetailPanel({
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}
>
{config?.type === 'system' ? '系统配置' : '业务配置'}
{config?.type === 'system'
? t('systemConfig')
: t('businessConfig')}
</span>
</div>
<div className='px-3 py-2 flex items-center justify-between'>
<label className='text-xs font-medium text-muted-foreground'>
创建时间
{t('createdAt')}
</label>
<p className='text-xs text-muted-foreground'>
{config ? formatDateTime(config.created_at) : ''}
@@ -199,6 +205,7 @@ function SystemConfigDetailPanel({
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const t = useTranslations('admin.system');
const {
systemConfigs: configs,
systemConfigsLoading: loading,
@@ -236,7 +243,7 @@ export function SystemConfigs() {
return (
<ManagePage<SystemConfig>
title='系统配置'
title={t('pageTitle')}
icon={ShieldCheck}
data={configs}
loading={loading}
@@ -245,8 +252,8 @@ export function SystemConfigs() {
getInitialEditData={getInitialEditData}
onSave={handleSave}
getId={(config) => config.key}
emptyDescription='未发现系统配置'
loadingDescription='配置加载中'
emptyDescription={t('emptyDescription')}
loadingDescription={t('loadingDescription')}
headerExtra={
<Tabs
value={activeTab}
@@ -255,24 +262,24 @@ export function SystemConfigs() {
>
<TabsList className='grid w-full grid-cols-2 h-8'>
<TabsTrigger value='business' className='text-[11px] h-7'>
业务配置
{t('businessConfig')}
</TabsTrigger>
<TabsTrigger value='system' className='text-[11px] h-7'>
系统配置
{t('systemConfig')}
</TabsTrigger>
</TabsList>
</Tabs>
}
columns={[
{
header: '配置键',
header: t('configKey'),
cell: (item) => (
<span className='font-mono font-medium'>{item.key}</span>
),
width: '200px',
},
{
header: '配置值',
header: t('configValue'),
cell: (item) => (
<span
className='truncate max-w-[120px] inline-block'
@@ -284,14 +291,14 @@ export function SystemConfigs() {
width: '120px',
},
{
header: '公共可见',
header: t('publicVisibility'),
cell: (item) => (
<span>{item.visibility === 1 ? '可见' : '不可见'}</span>
<span>{item.visibility === 1 ? t('visible') : t('invisible')}</span>
),
width: '80px',
},
{
header: '描述',
header: t('description'),
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format } from 'date-fns';
@@ -49,18 +50,18 @@ import {
TableRow,
} from '@/components/ui/table';
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
const STATUS_LABELS_KEYS: Record<TaskExecutionStatus, string> = {
pending: 'statusPending',
running: 'statusRunning',
succeeded: 'statusSucceeded',
failed: 'statusFailed',
};
const TRIGGER_LABELS: Record<string, string> = {
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
const TRIGGER_LABELS_KEYS: Record<string, string> = {
system: 'triggerSystem',
manual: 'triggerManual',
retry: 'triggerRetry',
schedule: 'triggerSchedule',
};
const PAGE_SIZE = 10;
@@ -85,6 +86,7 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function TaskExecutionsManager() {
const t = useTranslations('admin.tasks');
const queryClient = useQueryClient();
const [executionsPage, setExecutionsPage] = useState(1);
const [executionStatus, setExecutionStatus] = useState<
@@ -129,16 +131,16 @@ export function TaskExecutionsManager() {
const retryMutation = useMutation({
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success('任务已重新下发', {
description: `新任务 ID:${taskID}`,
toast.success(t('retrySuccess'), {
description: t('retrySuccessDesc', { taskId: taskID }),
});
void queryClient.invalidateQueries({
queryKey: ['admin', 'task-executions'],
});
},
onError: (err: Error) => {
toast.error('任务重试失败', {
description: err.message || '未知错误',
toast.error(t('retryFailed'), {
description: err.message || t('unknownError'),
});
},
});
@@ -187,14 +189,14 @@ export function TaskExecutionsManager() {
}
>
<SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' />
<SelectValue placeholder={t('colStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
<SelectItem value='all'>{t('allStatuses')}</SelectItem>
<SelectItem value='pending'>{t('statusPending')}</SelectItem>
<SelectItem value='running'>{t('statusRunning')}</SelectItem>
<SelectItem value='succeeded'>{t('statusSucceeded')}</SelectItem>
<SelectItem value='failed'>{t('statusFailed')}</SelectItem>
</SelectContent>
</Select>
<Select
@@ -202,10 +204,10 @@ export function TaskExecutionsManager() {
onValueChange={handleTaskTypeChange}
>
<SelectTrigger size='sm' className='w-[180px]'>
<SelectValue placeholder='任务类型' />
<SelectValue placeholder={t('taskTypeFilter')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部任务</SelectItem>
<SelectItem value='all'>{t('allTasks')}</SelectItem>
{taskTypes.map((task) => (
<SelectItem key={task.type} value={task.type}>
{task.name || task.type}
@@ -224,7 +226,7 @@ export function TaskExecutionsManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新
{t('refresh')}
</Button>
</div>
</div>
@@ -240,22 +242,22 @@ export function TaskExecutionsManager() {
) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务执行记录中...'
description={t('loadingExecutions')}
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description='暂无任务执行记录' />
<EmptyStateWithBorder icon={Activity} description={t('noExecutions')} />
) : (
<div className='rounded-lg border bg-card'>
<Table className='min-w-[900px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务</TableHead>
<TableHead className='w-[100px]'>状态</TableHead>
<TableHead className='w-[110px]'>触发</TableHead>
<TableHead className='w-[120px]'>重试</TableHead>
<TableHead className='w-[120px]'>耗时</TableHead>
<TableHead className='min-w-[220px]'>结果/错误</TableHead>
<TableHead className='w-[170px]'>创建时间</TableHead>
<TableHead className='w-[180px]'>{t('colTask')}</TableHead>
<TableHead className='w-[100px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[110px]'>{t('colTrigger')}</TableHead>
<TableHead className='w-[120px]'>{t('colRetry')}</TableHead>
<TableHead className='w-[120px]'>{t('colDuration')}</TableHead>
<TableHead className='min-w-[220px]'>{t('colResult')}</TableHead>
<TableHead className='w-[170px]'>{t('colCreatedAt')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -277,12 +279,13 @@ export function TaskExecutionsManager() {
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
{t(STATUS_LABELS_KEYS[execution.status]) ||
execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
{t(TRIGGER_LABELS_KEYS[execution.triggered_by]) ||
execution.triggered_by}
</Badge>
</TableCell>
@@ -307,7 +310,11 @@ export function TaskExecutionsManager() {
<div className='flex items-center justify-between'>
<div className='text-xs text-muted-foreground'>
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
{t('paginationInfo', {
total: executionsTotal,
current: executionsPage,
totalPages,
})}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -317,7 +324,7 @@ export function TaskExecutionsManager() {
disabled={executionsPage <= 1 || executionsLoading}
>
<ChevronLeft className='size-4' />
上一页
{t('prevPage')}
</Button>
<Button
variant='outline'
@@ -327,7 +334,7 @@ export function TaskExecutionsManager() {
}
disabled={executionsPage >= totalPages || executionsLoading}
>
下一页
{t('nextPage')}
<ChevronRight className='size-4' />
</Button>
</div>
@@ -336,11 +343,11 @@ export function TaskExecutionsManager() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px]'>
<SheetHeader className='border-b'>
<SheetTitle>任务执行详情</SheetTitle>
<SheetTitle>{t('executionDetailTitle')}</SheetTitle>
<SheetDescription>
{selectedExecution?.task_name ||
selectedExecution?.task_type ||
'任务记录'}
t('executionRecord')}
</SheetDescription>
</SheetHeader>
@@ -348,32 +355,35 @@ export function TaskExecutionsManager() {
{detailLoading && !selectedExecution ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
description={t('loadingDetail')}
/>
) : selectedExecution ? (
<div className='space-y-5 py-4'>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>状态</div>
<div className='text-xs text-muted-foreground'>
{t('detailStatus')}
</div>
<div className='mt-2'>
<Badge variant={statusVariant(selectedExecution.status)}>
{STATUS_LABELS[selectedExecution.status] ||
{t(STATUS_LABELS_KEYS[selectedExecution.status]) ||
selectedExecution.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
触发来源
{t('detailTrigger')}
</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selectedExecution.triggered_by] ||
selectedExecution.triggered_by}
{t(
TRIGGER_LABELS_KEYS[selectedExecution.triggered_by],
) || selectedExecution.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
重试次数
{t('detailRetryCount')}
</div>
<div className='mt-2 font-mono text-sm'>
{selectedExecution.retry_count}/
@@ -381,7 +391,9 @@ export function TaskExecutionsManager() {
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='text-xs text-muted-foreground'>
{t('detailDuration')}
</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selectedExecution.duration)}
</div>
@@ -389,7 +401,7 @@ export function TaskExecutionsManager() {
</div>
<div className='grid gap-2'>
<Label>任务标识</Label>
<Label>{t('detailTaskId')}</Label>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selectedExecution.task_id}
</div>
@@ -397,25 +409,25 @@ export function TaskExecutionsManager() {
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<div className='grid gap-2'>
<Label>创建时间</Label>
<Label>{t('detailCreatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.created_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>开始时间</Label>
<Label>{t('detailStartedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.started_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>结束时间</Label>
<Label>{t('detailFinishedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.finished_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>更新时间</Label>
<Label>{t('detailUpdatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.updated_at)}
</div>
@@ -423,7 +435,7 @@ export function TaskExecutionsManager() {
</div>
<div className='grid gap-2'>
<Label>执行结果</Label>
<Label>{t('detailResult')}</Label>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selectedExecution.result || '-'}
</div>
@@ -431,7 +443,7 @@ export function TaskExecutionsManager() {
{selectedExecution.error_message && (
<div className='grid gap-2'>
<Label>错误信息</Label>
<Label>{t('detailError')}</Label>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selectedExecution.error_message}
</div>
@@ -439,23 +451,23 @@ export function TaskExecutionsManager() {
)}
<div className='grid gap-2'>
<Label>Payload</Label>
<Label>{t('detailPayload')}</Label>
<pre className='max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selectedExecution.payload || '{}'}
</pre>
</div>
<div className='grid gap-2'>
<Label>执行日志</Label>
<Label>{t('detailLog')}</Label>
<pre className='min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap'>
{selectedExecution.log || '暂无日志'}
{selectedExecution.log || t('noLog')}
</pre>
</div>
</div>
) : (
<EmptyStateWithBorder
icon={Activity}
description='未选择任务记录'
description={t('noExecutionSelected')}
/>
)}
</div>
@@ -471,7 +483,7 @@ export function TaskExecutionsManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新详情
{t('refreshDetail')}
</Button>
{selectedExecution &&
selectedExecution.status === 'failed' &&
@@ -485,7 +497,7 @@ export function TaskExecutionsManager() {
) : (
<RotateCcw className='size-4' />
)}
重试任务
{t('retryTask')}
</Button>
)}
</SheetFooter>
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -88,10 +89,10 @@ const TASK_CONFIGS: Record<
},
};
const LOG_DATABASE_LABELS: Record<string, string> = {
postgres: 'PostgreSQL(主库)',
sqlite: 'SQLite(主库)',
clickhouse: 'ClickHouse',
const LOG_DATABASE_LABEL_KEYS: Record<string, string> = {
postgres: 'dbPostgresPrimary',
sqlite: 'dbSqlitePrimary',
clickhouse: 'dbClickhouse',
};
const DEFAULT_TASK_CONFIG = {
@@ -108,6 +109,7 @@ function DatePickerWithTime({
date: Date | undefined;
setDate: (date: Date | undefined) => void;
}) {
const t = useTranslations('admin.tasks');
const timeString = date ? format(date, 'HH:mm:ss') : '';
const handleDateSelect = (newDate: Date | undefined) => {
@@ -163,7 +165,7 @@ function DatePickerWithTime({
)}
>
<CalendarIcon className='mr-1 size-3' />
{date ? format(date, 'yyyy-MM-dd') : <span>选择日期</span>}
{date ? format(date, 'yyyy-MM-dd') : <span>{t('selectDate')}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
@@ -190,6 +192,7 @@ function DatePickerWithTime({
}
export function TaskManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]);
@@ -210,11 +213,11 @@ export function TaskManager() {
const data = await services.adminTask.getTaskTypes();
setTaskTypes(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'));
setError(err instanceof Error ? err : new Error(t('loadTaskTypesFailed')));
} finally {
setLoading(false);
}
}, []);
}, [t]);
const [logDbStatus, setLogDbStatus] = useState<LogDatabaseStatus | null>(
null,
@@ -302,8 +305,11 @@ export function TaskManager() {
const taskID = await services.adminTask.dispatchTask(params);
toast.success('任务下发成功', {
description: `已成功将任务 ${targetTask?.name || selectedTaskType} 加入队列:${taskID}`,
toast.success(t('dispatchSuccess'), {
description: t('dispatchSuccessDesc', {
name: targetTask?.name || selectedTaskType,
taskId: taskID,
}),
});
setDialogOpen(false);
@@ -312,8 +318,8 @@ export function TaskManager() {
setUserId('');
setParamValues({});
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('dispatchFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setDispatching(false);
@@ -345,10 +351,10 @@ export function TaskManager() {
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder
icon={Layers}
description='加载任务类型中...'
description={t('loadingTaskTypes')}
/>
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description='暂无可用任务类型' />
<EmptyStateWithBorder icon={Layers} description={t('noTaskTypes')} />
) : (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'>
{taskTypes.map((task, index) => {
@@ -386,13 +392,15 @@ export function TaskManager() {
variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
>
类型:{task.type}
{t('taskType')}
{task.type}
</Badge>
<Badge
variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
>
重试:{task.max_retry}
{t('taskRetry')}
{task.max_retry}
</Badge>
</div>
</div>
@@ -401,16 +409,23 @@ export function TaskManager() {
<div className='pt-3 mt-3 border-t border-border/50 space-y-1.5'>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
日志主库
{t('logPrimaryDb')}
</span>
<span className='text-[10px] font-mono text-foreground truncate'>
{LOG_DATABASE_LABELS[logDbStatus.active_database] ||
logDbStatus.active_database}
{LOG_DATABASE_LABEL_KEYS[
logDbStatus.active_database
]
? t(
LOG_DATABASE_LABEL_KEYS[
logDbStatus.active_database
],
)
: logDbStatus.active_database}
</span>
</div>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
保留天数
{t('retentionDays')}
</span>
<span className='text-[10px] font-mono text-muted-foreground truncate'>
{retentionSummary || '-'}
@@ -418,7 +433,7 @@ export function TaskManager() {
</div>
<div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'>
迁移状态
{t('migrationStatus')}
</span>
<Badge
variant={
@@ -429,8 +444,8 @@ export function TaskManager() {
className='text-[10px] h-5 px-1.5'
>
{logDbStatus.migration === 'migrating'
? '迁移中'
: '空闲'}
? t('migrating')
: t('idle')}
</Badge>
</div>
</div>
@@ -443,7 +458,7 @@ export function TaskManager() {
onClick={() => openDispatchDialog(task.type)}
>
<Play className='size-3 mr-1' />
立即执行
{t('executeNow')}
</Button>
</div>
</div>
@@ -457,15 +472,13 @@ export function TaskManager() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
<DialogTitle>{t('dispatchTask')}</DialogTitle>
<DialogDescription>{t('dispatchTaskDesc')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label>执行任务</Label>
<Label>{t('executeTask')}</Label>
<div className='flex items-center gap-2 p-2 rounded-lg bg-muted/50'>
{(() => {
const meta = getSelectedTaskMeta();
@@ -495,20 +508,20 @@ export function TaskManager() {
return (
<>
<div className='grid gap-2'>
<Label>开始时间</Label>
<Label>{t('startTime')}</Label>
<DatePickerWithTime
date={startTime}
setDate={setStartTime}
/>
<p className='text-xs text-muted-foreground'>
选择同步的起始时间点
{t('startTimeDesc')}
</p>
</div>
<div className='grid gap-2'>
<Label>结束时间</Label>
<Label>{t('endTime')}</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className='text-xs text-muted-foreground'>
选择同步的结束时间点
{t('endTimeDesc')}
</p>
</div>
</>
@@ -572,8 +585,8 @@ export function TaskManager() {
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : isSwitchTarget &&
@@ -593,12 +606,14 @@ export function TaskManager() {
className='w-full text-xs'
size='sm'
>
<SelectValue placeholder='选择目标日志库...' />
<SelectValue placeholder={t('selectLogDb')} />
</SelectTrigger>
<SelectContent>
{availableLogDbTargets.map((target) => (
<SelectItem key={target} value={target}>
{LOG_DATABASE_LABELS[target] || target}
{LOG_DATABASE_LABEL_KEYS[target]
? t(LOG_DATABASE_LABEL_KEYS[target])
: target}
</SelectItem>
))}
</SelectContent>
@@ -639,7 +654,7 @@ export function TaskManager() {
return (
<div className='flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed'>
<Info className='h-4 w-4' />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
<span>{t('noParamsHint')}</span>
</div>
);
}
@@ -654,7 +669,7 @@ export function TaskManager() {
disabled={dispatching}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
onClick={handleDispatch}
@@ -666,7 +681,7 @@ export function TaskManager() {
) : (
<Play className='size-3' />
)}
{dispatching ? '下发中' : '启动'}
{dispatching ? t('dispatching') : t('start')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -39,6 +40,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
export function TaskSchedulesManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [schedules, setSchedules] = useState<Schedule[]>([]);
@@ -73,11 +75,11 @@ export function TaskSchedulesManager() {
setSchedules(schedulesData || []);
setTaskTypes(taskTypesData || []);
} catch (err) {
setError(err instanceof Error ? err : new Error('加载定时任务配置失败'));
setError(err instanceof Error ? err : new Error(t('loadSchedulesFailed')));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchData();
@@ -130,12 +132,12 @@ export function TaskSchedulesManager() {
);
toast.success(
updated.is_active
? `已启用定时任务:${updated.name}`
: `已停用定时任务:${updated.name}`,
? t('scheduleEnabled', { name: updated.name })
: t('scheduleDisabled', { name: updated.name }),
);
} catch (err) {
toast.error('修改任务状态失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('changeScheduleStatusFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
}
};
@@ -161,11 +163,11 @@ export function TaskSchedulesManager() {
const handleSubmit = async () => {
if (!name.trim()) {
toast.error('任务名称不能为空');
toast.error(t('scheduleNameRequired'));
return;
}
if (!cron.trim()) {
toast.error('Cron 表达式不能为空');
toast.error(t('cronRequired'));
return;
}
@@ -203,7 +205,7 @@ export function TaskSchedulesManager() {
setSchedules((prev) =>
prev.map((s) => (s.id === editingSchedule.id ? updated : s)),
);
toast.success('定时任务更新成功');
toast.success(t('scheduleUpdateSuccess'));
} else {
const req: CreateScheduleRequest = {
name,
@@ -214,13 +216,13 @@ export function TaskSchedulesManager() {
};
const created = await services.adminTask.createSchedule(req);
setSchedules((prev) => [created, ...prev]);
toast.success('定时任务创建成功');
toast.success(t('scheduleCreateSuccess'));
}
setDialogOpen(false);
} catch (err) {
toast.error('提交失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('submitFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setSubmitLoading(false);
@@ -238,11 +240,11 @@ export function TaskSchedulesManager() {
setDeleteLoading(true);
await services.adminTask.deleteSchedule(deletingId);
setSchedules((prev) => prev.filter((s) => s.id !== deletingId));
toast.success('定时任务删除成功');
toast.success(t('scheduleDeleteSuccess'));
setDeleteOpen(false);
} catch (err) {
toast.error('删除失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('deleteFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setDeleteLoading(false);
@@ -274,7 +276,7 @@ export function TaskSchedulesManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新
{t('refresh')}
</Button>
<Button
size='sm'
@@ -283,7 +285,7 @@ export function TaskSchedulesManager() {
variant={'secondary'}
>
<Plus className='size-4 mr-1' />
新增定时任务
{t('addSchedule')}
</Button>
</div>
</div>
@@ -299,28 +301,34 @@ export function TaskSchedulesManager() {
) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder
icon={Clock}
description='加载定时任务配置中...'
description={t('loadingSchedules')}
/>
) : schedules.length === 0 ? (
<EmptyStateWithBorder
icon={Clock}
description='暂无定时任务配置,点击上方按钮新增'
description={t('noSchedules')}
/>
) : (
<div className='rounded-lg border bg-card'>
<Table className='min-w-[800px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务名称</TableHead>
<TableHead className='w-[160px]'>关联异步任务</TableHead>
<TableHead className='w-[120px]'>Cron 表达式</TableHead>
<TableHead className='w-[180px]'>
{t('colScheduleName')}
</TableHead>
<TableHead className='w-[160px]'>
{t('colAssociatedTask')}
</TableHead>
<TableHead className='w-[120px]'>{t('colCron')}</TableHead>
<TableHead className='min-w-[200px]'>
执行参数 (Payload)
{t('colPayload')}
</TableHead>
<TableHead className='w-[100px] text-center'>
启用状态
{t('colEnabled')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('colActions')}
</TableHead>
<TableHead className='w-[120px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -385,19 +393,19 @@ export function TaskSchedulesManager() {
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{editingSchedule ? '修改定时任务' : '新增定时任务'}
{editingSchedule ? t('editSchedule') : t('addSchedule')}
</DialogTitle>
<DialogDescription>
配置定时任务的调度参数和运行载荷。
{t('scheduleDialogDesc')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='sched-name'>任务名称</Label>
<Label htmlFor='sched-name'>{t('scheduleName')}</Label>
<Input
id='sched-name'
placeholder='例如:每日数据清理'
placeholder={t('scheduleNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
className='text-xs'
@@ -405,7 +413,7 @@ export function TaskSchedulesManager() {
</div>
<div className='grid gap-2'>
<Label htmlFor='sched-type'>关联异步任务</Label>
<Label htmlFor='sched-type'>{t('associatedTask')}</Label>
<select
id='sched-type'
value={selectedTaskType}
@@ -422,24 +430,24 @@ export function TaskSchedulesManager() {
</div>
<div className='grid gap-2'>
<Label htmlFor='sched-cron'>Cron 表达式</Label>
<Label htmlFor='sched-cron'>{t('cronExpression')}</Label>
<Input
id='sched-cron'
placeholder='e.g. 0 */2 * * * 或 @daily'
placeholder={t('cronPlaceholder')}
value={cron}
onChange={(e) => setCron(e.target.value)}
className='text-xs font-mono'
/>
<p className='text-[10px] text-muted-foreground'>
使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。
{t('cronHint')}
</p>
</div>
<div className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<Label htmlFor='sched-active'>启用状态</Label>
<Label htmlFor='sched-active'>{t('scheduleActive')}</Label>
<p className='text-[10px] text-muted-foreground'>
决定此定时任务是否会定时触发执行。
{t('scheduleActiveDesc')}
</p>
</div>
<Switch
@@ -459,14 +467,14 @@ export function TaskSchedulesManager() {
return (
<div className='flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed'>
<Info className='h-4 w-4 shrink-0' />
<span>所选任务类型不需要运行参数参数。</span>
<span>{t('noParamsRequired')}</span>
</div>
);
}
return (
<div className='space-y-4 pt-2 border-t'>
<Label className='text-xs font-semibold'>
运行参数配置 (Payload)
{t('payloadConfig')}
</Label>
{targetTask.params.map((param) => (
<div
@@ -509,8 +517,8 @@ export function TaskSchedulesManager() {
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : (
@@ -547,7 +555,7 @@ export function TaskSchedulesManager() {
disabled={submitLoading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
onClick={handleSubmit}
@@ -555,7 +563,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs'
>
{submitLoading && <Spinner className='size-3 mr-1' />}
{editingSchedule ? '保存修改' : '确认创建'}
{editingSchedule ? t('saveChanges') : t('confirmCreate')}
</Button>
</DialogFooter>
</DialogContent>
@@ -565,9 +573,9 @@ export function TaskSchedulesManager() {
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className='sm:max-w-[400px]'>
<DialogHeader>
<DialogTitle>删除定时任务</DialogTitle>
<DialogTitle>{t('deleteScheduleTitle')}</DialogTitle>
<DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
{t('deleteScheduleDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter className='gap-2 sm:gap-0'>
@@ -577,7 +585,7 @@ export function TaskSchedulesManager() {
disabled={deleteLoading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='destructive'
@@ -586,7 +594,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs'
>
{deleteLoading && <Spinner className='size-3 mr-1' />}
确认删除
{t('confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
+8 -4
View File
@@ -2,6 +2,7 @@
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TaskManager } from './components/task-manager';
import { TaskSchedulesManager } from './components/task-schedules';
@@ -10,6 +11,7 @@ import { Spinner } from '@/components/ui/spinner';
import { Layers } from 'lucide-react';
function TasksPageContent() {
const t = useTranslations('admin.tasks');
const searchParams = useSearchParams();
const router = useRouter();
const activeTab = searchParams.get('tab') || 'tasks';
@@ -22,7 +24,9 @@ function TasksPageContent() {
<div className='flex items-center gap-2'>
<Layers className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>任务管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
@@ -36,19 +40,19 @@ function TasksPageContent() {
value='tasks'
className='px-0 pb-2 text-xs font-semibold'
>
任务管理
{t('tabTasks')}
</TabsTrigger>
<TabsTrigger
value='schedules'
className='px-0 pb-2 text-xs font-semibold'
>
定时任务
{t('tabSchedules')}
</TabsTrigger>
<TabsTrigger
value='executions'
className='px-0 pb-2 text-xs font-semibold'
>
任务日志
{t('tabExecutions')}
</TabsTrigger>
</TabsList>
<TabsContent value='tasks' className='space-y-4 outline-none'>
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -31,6 +32,7 @@ export function CreateUserModal({
isOpen: boolean;
onClose: () => void;
}) {
const t = useTranslations('admin.users');
const { createUser } = useAdminUsers();
const [form, setForm] = useState<CreateUserRequest>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -48,21 +50,21 @@ export function CreateUserModal({
const validate = () => {
const newErrors: Record<string, string> = {};
if (!form.username.trim()) {
newErrors.username = '用户名不能为空';
newErrors.username = t('validation.usernameRequired');
} else if (form.username.trim().length < 3) {
newErrors.username = '用户名长度不能少于 3 位';
newErrors.username = t('validation.usernameTooShort');
}
if (!form.email.trim()) {
newErrors.email = '邮箱不能为空';
newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确';
newErrors.email = t('validation.emailInvalid');
}
if (!form.password) {
newErrors.password = '密码不能为空';
newErrors.password = t('validation.passwordRequired');
} else if (form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位';
newErrors.password = t('validation.passwordTooShort');
}
setErrors(newErrors);
@@ -93,22 +95,20 @@ export function CreateUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
<DialogTitle>{t('createTitle')}</DialogTitle>
<DialogDescription>{t('createDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
<div className='space-y-1.5'>
<Label htmlFor='username'>用户名</Label>
<Label htmlFor='username'>{t('username')}</Label>
<Input
id='username'
value={form.username}
onChange={(e) =>
setForm((prev) => ({ ...prev, username: e.target.value }))
}
placeholder='请输入用户名 (至少 3 位)'
placeholder={t('usernamePlaceholder')}
/>
{errors.username && (
<p className='text-xs text-destructive'>{errors.username}</p>
@@ -116,19 +116,19 @@ export function CreateUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input
id='nickname'
value={form.nickname}
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
placeholder={t('nicknamePlaceholder')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Label htmlFor='email'>{t('email')}</Label>
<Input
id='email'
type='email'
@@ -136,7 +136,7 @@ export function CreateUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
placeholder={t('emailPlaceholder')}
/>
{errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p>
@@ -144,7 +144,7 @@ export function CreateUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='password'>密码</Label>
<Label htmlFor='password'>{t('password')}</Label>
<Input
id='password'
type='password'
@@ -152,7 +152,7 @@ export function CreateUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='请输入登录密码 (至少 8 位)'
placeholder={t('passwordPlaceholder')}
/>
{errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p>
@@ -161,9 +161,11 @@ export function CreateUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>启用账户</div>
<div className='font-medium text-sm'>
{t('enableAccountLabel')}
</div>
<div className='text-xs text-muted-foreground'>
禁用后此账号将无法登录系统。
{t('enableAccountDesc')}
</div>
</div>
<Switch
@@ -176,9 +178,9 @@ export function CreateUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>管理员权限</div>
<div className='font-medium text-sm'>{t('adminPermission')}</div>
<div className='text-xs text-muted-foreground'>
开启后此账号将拥有后台管理权限。
{t('adminPermissionDesc')}
</div>
</div>
<Switch
@@ -191,10 +193,10 @@ export function CreateUserModal({
<div className='flex justify-end gap-2 pt-2 border-t mt-2'>
<Button variant='outline' type='button' onClick={onClose}>
取消
{t('cancel')}
</Button>
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '创建中...' : '创建'}
{saving ? t('creating') : t('create')}
</Button>
</div>
</form>
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -43,6 +44,7 @@ export function EditUserModal({
onClose: () => void;
user: AdminUser | null;
}) {
const t = useTranslations('admin.users');
const { updateUser, deleteUser, getUserDetail } = useAdminUsers();
const { user: currentUser } = useAuth();
@@ -96,13 +98,13 @@ export function EditUserModal({
const newErrors: Record<string, string> = {};
if (!form.email.trim()) {
newErrors.email = '邮箱不能为空';
newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确';
newErrors.email = t('validation.emailInvalid');
}
if (form.password && form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位';
newErrors.password = t('validation.passwordTooShort');
}
setErrors(newErrors);
@@ -150,10 +152,8 @@ export function EditUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>编辑用户</DialogTitle>
<DialogDescription>
修改用户档案、授予/撤销管理权限或重置其密码。
</DialogDescription>
<DialogTitle>{t('editTitle')}</DialogTitle>
<DialogDescription>{t('editDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
@@ -162,7 +162,7 @@ export function EditUserModal({
htmlFor='username'
className='text-xs text-muted-foreground'
>
用户名 (不可修改)
{t('usernameImmutable')}
</Label>
<Input
id='username'
@@ -173,19 +173,19 @@ export function EditUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input
id='nickname'
value={form.nickname}
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
placeholder={t('nicknamePlaceholder')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Label htmlFor='email'>{t('email')}</Label>
<Input
id='email'
type='email'
@@ -193,7 +193,7 @@ export function EditUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
placeholder={t('emailPlaceholder')}
/>
{errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p>
@@ -201,7 +201,7 @@ export function EditUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='password'>重置密码 (选填)</Label>
<Label htmlFor='password'>{t('resetPasswordOptional')}</Label>
<Input
id='password'
type='password'
@@ -209,7 +209,7 @@ export function EditUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='留空表示保持当前密码,输入则重置 (至少 8 位)'
placeholder={t('resetPasswordPlaceholder')}
/>
{errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p>
@@ -218,11 +218,11 @@ export function EditUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>管理员权限</div>
<div className='font-medium text-sm'>{t('adminPermission')}</div>
<div className='text-xs text-muted-foreground'>
{isSelf
? '不能撤销当前登录用户的管理员权限。'
: '开启后此账号将拥有后台管理权限。'}
? t('cannotRevokeSelfAdmin')
: t('adminPermissionDesc')}
</div>
</div>
<Switch
@@ -255,7 +255,7 @@ export function EditUserModal({
onClick={() => setShowDeleteConfirm(true)}
disabled={saving}
>
删除用户
{t('deleteUser')}
</Button>
)}
</div>
@@ -266,10 +266,10 @@ export function EditUserModal({
onClick={onClose}
disabled={saving}
>
取消
{t('cancel')}
</Button>
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '保存中...' : '保存'}
{saving ? t('saving') : t('save')}
</Button>
</div>
</div>
@@ -280,14 +280,15 @@ export function EditUserModal({
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {user.nickname || user.username}{' '}
吗?该操作会移除用户账号,删除后无法撤销。
{t('deleteConfirmDesc', {
name: user.nickname || user.username,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={saving}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
@@ -296,7 +297,7 @@ export function EditUserModal({
disabled={saving}
>
{saving && <Loader2 className='size-3 animate-spin mr-1' />}
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import {
Globe,
Loader2,
@@ -33,13 +34,14 @@ export function UserDetailSheet({
detailLoading,
onStatusToggle,
}: UserDetailSheetProps) {
const t = useTranslations('admin.users');
const displayValue = (value?: string) =>
value && value.trim() ? value : '-';
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-[400px] w-full p-0 flex flex-col gap-0'>
<SheetTitle className='px-5 py-3'>用户档案</SheetTitle>
<SheetTitle className='px-5 py-3'>{t('userProfile')}</SheetTitle>
{selectedUser && (
<>
@@ -79,14 +81,14 @@ export function UserDetailSheet({
{detailLoading && (
<div className='flex items-center gap-1 text-[10px] text-muted-foreground'>
<Loader2 className='size-3 animate-spin' />
正在刷新详情
{t('refreshingDetail')}
</div>
)}
<div className='gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50'>
<div className='flex flex-col gap-0.5'>
<span className='text-[9px] uppercase tracking-widest text-muted-foreground font-medium'>
注册时间
{t('registeredAt')}
</span>
<span className='font-mono text-xs font-semibold'>
{
@@ -103,13 +105,13 @@ export function UserDetailSheet({
<div className='p-6 space-y-6'>
<div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
个人资料
{t('personalInfo')}
</h4>
<div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Mail className='size-3' />
邮箱
{t('email')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.email)}
@@ -118,7 +120,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Smartphone className='size-3' />
手机
{t('phone')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.phone)}
@@ -129,7 +131,7 @@ export function UserDetailSheet({
<span className='size-3 flex items-center justify-center font-bold text-[9px]'>
⚧
</span>
性别
{t('gender')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.gender)}
@@ -138,7 +140,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<MapPin className='size-3' />
所在地
{t('location')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.location)}
@@ -147,7 +149,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Globe className='size-3' />
网站
{t('website')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.website)}
@@ -155,7 +157,7 @@ export function UserDetailSheet({
</div>
<div className='flex flex-col gap-2 p-3.5 text-sm'>
<span className='text-[10px] text-muted-foreground'>
简介
{t('bio')}
</span>
<span className='break-words text-[10px] leading-5'>
{displayValue(selectedUser.bio)}
@@ -166,40 +168,42 @@ export function UserDetailSheet({
<div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
系统记录
{t('systemRecords')}
</h4>
<div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>账户状态</span>
<span className='text-[10px]'>{t('accountStatus')}</span>
<Badge
variant={
selectedUser.is_active ? 'secondary' : 'outline'
}
className='text-[10px]'
>
{selectedUser.is_active ? '正常' : '禁用'}
{selectedUser.is_active
? t('statusActive')
: t('statusDisabled')}
</Badge>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>管理员</span>
<span className='text-[10px]'>{t('isAdmin')}</span>
<span className='font-mono text-[10px]'>
{selectedUser.is_admin ? '是' : '否'}
{selectedUser.is_admin ? t('yes') : t('no')}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后登录</span>
<span className='text-[10px]'>{t('lastLogin')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.last_login_at)}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>注册时间</span>
<span className='text-[10px]'>{t('registeredAt')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.created_at)}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后更新</span>
<span className='text-[10px]'>{t('lastUpdate')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.updated_at)}
</span>
@@ -219,12 +223,12 @@ export function UserDetailSheet({
{selectedUser.is_active ? (
<>
<ShieldCheck className='size-3 mr-1' />
封禁账户
{t('banAccount')}
</>
) : (
<>
<UserCheck className='size-3 mr-1' />
解除封禁
{t('unbanAccount')}
</>
)}
</Button>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import {
ChevronDown,
ChevronLeft,
@@ -24,6 +25,7 @@ import { useAdminUsers } from '@/contexts/admin-users-context';
import { cn } from '@/lib/utils';
export function UserFilterBar() {
const t = useTranslations('admin.users');
const {
total,
loading,
@@ -61,7 +63,7 @@ export function UserFilterBar() {
)}
>
<Search className='size-3 mr-1' />
搜索
{t('search')}
{hasSearchFilter && (
<>
<Separator orientation='vertical' className='mx-1' />
@@ -79,19 +81,19 @@ export function UserFilterBar() {
<div className='space-y-2.5'>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入用户 ID...'
placeholder={t('searchUserIdPlaceholder')}
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入 username...'
placeholder={t('searchUsernamePlaceholder')}
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入邮箱...'
placeholder={t('searchEmailPlaceholder')}
value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)}
/>
@@ -106,7 +108,7 @@ export function UserFilterBar() {
setSearchEmail('');
}}
>
清除
{t('clear')}
</Button>
)}
</div>
@@ -124,7 +126,7 @@ export function UserFilterBar() {
)}
>
<Filter className='size-3' />
状态
{t('status')}
{statusFilter !== 'all' && (
<>
<Separator orientation='vertical' className='mx-1' />
@@ -153,7 +155,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>全部状态</span>
<span className='text-xs'>{t('statusAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -169,7 +171,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>正常</span>
<span className='text-xs'>{t('statusActive')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -185,7 +187,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>禁用</span>
<span className='text-xs'>{t('statusDisabled')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -205,7 +207,7 @@ export function UserFilterBar() {
className='h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground'
>
<X className='size-3' />
清空筛选
{t('clearFilters')}
</Button>
</>
)}
@@ -215,7 +217,7 @@ export function UserFilterBar() {
<div className='flex items-center gap-1.5 self-end lg:self-auto'>
<span className='text-[10px] text-muted-foreground whitespace-nowrap'>
{total} 条记录
{t('totalRecords', { total })}
</span>
<div className='flex items-center border border-dashed rounded-md shadow-none'>
<Button
@@ -249,7 +251,7 @@ export function UserFilterBar() {
className='h-6 border-dashed text-[10px] px-2 font-mono shadow-none'
disabled={loading}
>
{pageSize}条/页
{t('perPage', { count: pageSize })}
<ChevronDown className='size-3 opacity-50' />
</Button>
</DropdownMenuTrigger>
@@ -263,7 +265,7 @@ export function UserFilterBar() {
pageSize === size && 'bg-accent',
)}
>
{size}条/页
{t('perPage', { count: size })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -275,7 +277,7 @@ export function UserFilterBar() {
className='h-6 w-6 border-dashed shadow-none'
onClick={() => fetchUsers(true)}
disabled={loading}
title='刷新数据'
title={t('refreshData')}
>
<Loader2 className={cn('size-3', loading && 'animate-spin')} />
</Button>
+19 -15
View File
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
@@ -33,6 +34,7 @@ import { UserDetailSheet } from './components/user-detail-sheet';
import { EditUserModal } from './components/edit-user-modal';
export default function UsersPage() {
const t = useTranslations('admin.users');
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
useAdminUsers();
@@ -78,7 +80,9 @@ export default function UsersPage() {
<div className='flex items-center gap-2'>
<UserRound className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>用户管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
<Button
@@ -88,7 +92,7 @@ export default function UsersPage() {
onClick={() => setCreateModalOpen(true)}
>
<Plus className='size-3.5 mr-1' />
新增用户
{t('createUser')}
</Button>
</div>
@@ -104,9 +108,9 @@ export default function UsersPage() {
/>
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' />
<LoadingStateWithBorder icon={Layers} description={t('loadingUsers')} />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' />
<EmptyStateWithBorder icon={UserX} description={t('noUsers')} />
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<TooltipProvider delayDuration={0}>
@@ -117,22 +121,22 @@ export default function UsersPage() {
ID
</TableHead>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
用户
{t('colUser')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'>
邮箱
{t('colEmail')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次登陆
{t('colLastLogin')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
注册时间
{t('colRegisteredAt')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次更新
{t('colLastUpdate')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -211,10 +215,10 @@ export default function UsersPage() {
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{user.is_admin
? '管理员账户'
? t('adminAccount')
: user.is_active
? '禁用账户'
: '启用账户'}
? t('disableAccount')
: t('enableAccount')}
</TooltipContent>
</Tooltip>
@@ -230,7 +234,7 @@ export default function UsersPage() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
{t('viewDetail')}
</TooltipContent>
</Tooltip>
@@ -246,7 +250,7 @@ export default function UsersPage() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
编辑用户
{t('editUser')}
</TooltipContent>
</Tooltip>
</div>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -19,7 +21,8 @@ interface LogDetailSheetProps {
onOpenChange: (open: boolean) => void;
}
function getResultBadge(result: string) {
function ResultBadge({ result }: { result: string }) {
const t = useTranslations('applyLogs');
if (result === 'success') {
return (
<Badge
@@ -27,7 +30,7 @@ function getResultBadge(result: string) {
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' />
成功
{t('success')}
</Badge>
);
}
@@ -38,7 +41,7 @@ function getResultBadge(result: string) {
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
>
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
警告
{t('warning')}
</Badge>
);
}
@@ -48,7 +51,7 @@ function getResultBadge(result: string) {
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
>
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败
{t('failed')}
</Badge>
);
}
@@ -58,58 +61,57 @@ export function LogDetailSheet({
open,
onOpenChange,
}: LogDetailSheetProps) {
const t = useTranslations('applyLogs');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-md w-full p-0 flex flex-col gap-0'>
<SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>应用日志详情</SheetTitle>
<SheetDescription>
查看单条应用日志的完整结果与校验信息。
</SheetDescription>
<SheetTitle>{t('sheetTitle')}</SheetTitle>
<SheetDescription>{t('sheetDesc')}</SheetDescription>
</SheetHeader>
{log ? (
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-4'>
<div className='flex flex-wrap gap-2'>
{getResultBadge(log.result)}
<ResultBadge result={log.result} />
<Badge variant='outline' className='text-[10px] rounded-full'>
Node: {log.node_id}
</Badge>
<Badge variant='outline' className='text-[10px] rounded-full'>
版本: {log.version}
{t('versionBadge', { version: log.version })}
</Badge>
</div>
<div className='grid gap-3 text-xs'>
<div>
<p className='text-muted-foreground'>创建时间</p>
<p className='text-muted-foreground'>{t('createdAt')}</p>
<p className='font-medium'>{formatDateTime(log.created_at)}</p>
</div>
<div>
<p className='text-muted-foreground'>目标 Checksum</p>
<p className='text-muted-foreground'>{t('targetChecksum')}</p>
<p className='font-mono break-all'>{log.checksum || '—'}</p>
</div>
<div>
<p className='text-muted-foreground'>主配置摘要</p>
<p className='text-muted-foreground'>{t('mainChecksum')}</p>
<p className='font-mono break-all'>
{log.main_config_checksum || '—'}
</p>
</div>
<div>
<p className='text-muted-foreground'>路由配置摘要</p>
<p className='text-muted-foreground'>{t('routeChecksum')}</p>
<p className='font-mono break-all'>
{log.route_config_checksum || '—'}
</p>
</div>
<div>
<p className='text-muted-foreground'>支持文件数</p>
<p className='text-muted-foreground'>{t('supportFiles')}</p>
<p className='font-medium'>{log.support_file_count}</p>
</div>
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground mb-2'>
消息
{t('message')}
</p>
<pre className='text-xs whitespace-pre-wrap break-words'>
{log.message || '—'}
@@ -120,7 +122,7 @@ export function LogDetailSheet({
<SheetFooter className='px-5 py-4 border-t'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
关闭
{t('close')}
</Button>
</SheetFooter>
</SheetContent>
+71 -47
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -49,7 +50,8 @@ function truncateHash(value: string) {
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
}
function getResultBadge(result: string) {
function ResultBadge({ result }: { result: string }) {
const t = useTranslations('applyLogs');
if (result === 'success') {
return (
<Badge
@@ -57,7 +59,7 @@ function getResultBadge(result: string) {
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' />
成功
{t('success')}
</Badge>
);
}
@@ -68,7 +70,7 @@ function getResultBadge(result: string) {
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
>
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
警告
{t('warning')}
</Badge>
);
}
@@ -78,12 +80,14 @@ function getResultBadge(result: string) {
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
>
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败
{t('failed')}
</Badge>
);
}
export function ApplyLogsPageClient() {
const t = useTranslations('applyLogs');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const initialNodeId = searchParams.get('node_id')?.trim() ?? '';
@@ -108,12 +112,12 @@ export function ApplyLogsPageClient() {
const summary = useMemo(() => {
const nodeIds = new Set(rows.map((item) => item.node_id));
return [
{ label: '总记录数', value: total },
{ label: '当前页', value: current },
{ label: '总页数', value: totalPage },
{ label: '当前页节点数', value: nodeIds.size },
{ label: t('totalRecords'), value: total },
{ label: t('currentPage'), value: current },
{ label: t('totalPages'), value: totalPage },
{ label: t('pageNodes'), value: nodeIds.size },
];
}, [rows, total, current, totalPage]);
}, [rows, total, current, totalPage, t]);
const fetchLogs = useCallback(async () => {
setLoading(true);
@@ -129,11 +133,11 @@ export function ApplyLogsPageClient() {
setTotal(data.total);
setTotalPage(data.totalPage);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [nodeFilter, pageNo, pageSize]);
}, [nodeFilter, pageNo, pageSize, t]);
useEffect(() => {
void fetchLogs();
@@ -162,15 +166,16 @@ export function ApplyLogsPageClient() {
setCleaning(true);
try {
const result = await ApplyLogService.cleanup({ delete_all: true });
toast.success('应用日志已清空', {
description: `共删除 ${result.deleted_count} 条记录`,
toast.success(t('cleared'), {
description: t('clearedDesc', { count: result.deleted_count }),
});
setCleanupOpen(false);
setPageNo(1);
await fetchLogs();
} catch (err) {
toast.error('清空应用日志失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('clearFailed'), {
description:
err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setCleaning(false);
@@ -183,10 +188,10 @@ export function ApplyLogsPageClient() {
<div className='flex items-center gap-2'>
<ClipboardList className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>应用日志</h1>
<p className='text-sm text-muted-foreground'>
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
</p>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('description')}</p>
</div>
</div>
@@ -200,7 +205,7 @@ export function ApplyLogsPageClient() {
<RefreshCw
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
/>
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -209,10 +214,10 @@ export function ApplyLogsPageClient() {
disabled={loading || cleaning || total === 0}
>
<Trash2 className='size-3.5 mr-1' />
清空日志
{t('clear')}
</Button>
<Button variant='outline' size='sm' asChild>
<Link href='/nodes'>返回节点</Link>
<Link href='/nodes'>{t('backToNodes')}</Link>
</Button>
</div>
</div>
@@ -234,7 +239,9 @@ export function ApplyLogsPageClient() {
<div className='flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between'>
<div className='grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]'>
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>Node ID</p>
<p className='text-xs font-medium text-muted-foreground'>
{t('nodeId')}
</p>
<div className='relative'>
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
<Input
@@ -243,14 +250,14 @@ export function ApplyLogsPageClient() {
onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch();
}}
placeholder='输入 node_id 过滤应用日志'
placeholder={t('nodePlaceholder')}
className='pl-8 h-9 text-xs'
/>
</div>
</div>
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>
每页条数
{t('pageSize')}
</p>
<Select
value={String(pageSize)}
@@ -265,7 +272,7 @@ export function ApplyLogsPageClient() {
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} 条
{t('pageSizeOption', { count: option })}
</SelectItem>
))}
</SelectContent>
@@ -274,10 +281,10 @@ export function ApplyLogsPageClient() {
</div>
<div className='flex flex-wrap gap-2'>
<Button size='sm' onClick={handleSearch}>
筛选
{t('filter')}
</Button>
<Button variant='outline' size='sm' onClick={handleResetFilters}>
重置筛选
{t('reset')}
</Button>
</div>
</div>
@@ -291,23 +298,33 @@ export function ApplyLogsPageClient() {
<LoadingStateWithBorder />
) : rows.length === 0 ? (
<EmptyStateWithBorder
title='暂无应用日志'
description='当前筛选条件下没有可展示的应用记录。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'>Node ID</TableHead>
<TableHead className='text-xs font-semibold'>版本</TableHead>
<TableHead className='text-xs font-semibold'>结果</TableHead>
<TableHead className='text-xs font-semibold'>
Checksum
{t('colNodeId')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colVersion')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colResult')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChecksum')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTime')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colMessage')}
</TableHead>
<TableHead className='text-xs font-semibold'>时间</TableHead>
<TableHead className='text-xs font-semibold'>消息</TableHead>
<TableHead className='text-xs font-semibold text-right'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -323,7 +340,9 @@ export function ApplyLogsPageClient() {
<TableCell className='text-xs text-muted-foreground'>
{log.version}
</TableCell>
<TableCell>{getResultBadge(log.result)}</TableCell>
<TableCell>
<ResultBadge result={log.result} />
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground'
title={log.checksum}
@@ -349,7 +368,7 @@ export function ApplyLogsPageClient() {
}}
>
<Eye className='size-3 mr-1' />
详情
{t('detail')}
</Button>
</TableCell>
</TableRow>
@@ -362,7 +381,11 @@ export function ApplyLogsPageClient() {
{!loading && rows.length > 0 ? (
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<p className='text-xs text-muted-foreground'>
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
{t('pageInfo', {
current,
total: Math.max(totalPage, 1),
count: total,
})}
</p>
<div className='flex gap-2'>
<Button
@@ -371,7 +394,7 @@ export function ApplyLogsPageClient() {
disabled={current <= 1}
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
>
上一页
{t('prev')}
</Button>
<Button
variant='outline'
@@ -383,7 +406,7 @@ export function ApplyLogsPageClient() {
)
}
>
下一页
{t('next')}
</Button>
</div>
</div>
@@ -398,14 +421,15 @@ export function ApplyLogsPageClient() {
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认清空应用日志?</AlertDialogTitle>
<AlertDialogTitle>{t('clearTitle')}</AlertDialogTitle>
<AlertDialogDescription>
该操作将删除全部 {total}{' '}
条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。
{t('clearDesc', { count: total })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={cleaning}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={cleaning}
@@ -414,7 +438,7 @@ export function ApplyLogsPageClient() {
void handleCleanupLogs();
}}
>
{cleaning ? '清空中...' : '确认清空'}
{cleaning ? t('clearing') : t('confirmClear')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+67 -44
View File
@@ -35,6 +35,8 @@ import { CertificateDetailDialog } from '../websites/components/certificate-deta
import { CertificateEditorDialog } from '../websites/components/certificate-editor-dialog';
import { CertificateImportDialog } from '../websites/components/certificate-import-dialog';
import { WebsiteStatusBadge } from '../websites/components/status-badge';
import { useTranslations } from 'next-intl';
import {
getCertificateStatus,
getErrorMessage,
@@ -45,6 +47,8 @@ const certificatesQueryKey = ['openflare', 'tls-certificates'];
type CertificateApplyMode = 'edit-acme' | 'convert-upload';
export default function CertificatesPage() {
const t = useTranslations('certificates');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const [importOpen, setImportOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
@@ -68,20 +72,20 @@ export default function CertificatesPage() {
const deleteMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
onSuccess: async () => {
toast.success('证书已删除');
toast.success(t('deleted'));
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const renewMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.renew(id),
onSuccess: async (cert) => {
toast.success(`证书 ${cert.name} 续期任务已提交`);
toast.success(t('renewSubmitted', { name: cert.name }));
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const certificates = useMemo(
@@ -105,7 +109,7 @@ export default function CertificatesPage() {
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'>
<FileKey className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>证书</h1>
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
</div>
<div className='flex flex-wrap gap-2'>
<Button
@@ -119,7 +123,7 @@ export default function CertificatesPage() {
}
>
<RefreshCw className='size-3.5 mr-1' />
刷新证书
{t('refresh')}
</Button>
<Button
variant='secondary'
@@ -127,7 +131,7 @@ export default function CertificatesPage() {
className='h-7 text-xs'
onClick={() => setImportOpen(true)}
>
导入证书
{t('importCert')}
</Button>
<Button
size='sm'
@@ -135,28 +139,29 @@ export default function CertificatesPage() {
onClick={() => setApplyOpen(true)}
>
<Plus className='size-3.5 mr-1' />
申请证书
{t('applyTitle')}
</Button>
</div>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base font-semibold'>证书列表</CardTitle>
<CardDescription>
展示证书有效期、备注和状态,支持查看详情、编辑内容或删除证书。
</CardDescription>
<CardTitle className='text-base font-semibold'>{t('listTitle')}</CardTitle>
<CardDescription>{t('listDesc')}</CardDescription>
</CardHeader>
<CardContent>
{certificatesQuery.isLoading ? (
<LoadingStateWithBorder
icon={FileKey}
description='加载证书列表中...'
description={t('loadingList')}
/>
) : certificatesQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(certificatesQuery.error)}
message={getErrorMessage(
certificatesQuery.error,
t('requestFailed'),
)}
onRetry={() => void certificatesQuery.refetch()}
className='justify-center'
/>
@@ -164,12 +169,12 @@ export default function CertificatesPage() {
) : certificates.length === 0 ? (
<EmptyStateWithBorder
icon={FileKey}
description='暂无证书,点击右上角「导入证书」或「申请证书」开始录入。'
description={t('emptyList')}
/>
) : (
<div className='space-y-3'>
{certificates.map((certificate) => {
const status = getCertificateStatus(certificate);
const status = getCertificateStatus(certificate, t);
return (
<div
@@ -188,32 +193,50 @@ export default function CertificatesPage() {
/>
</div>
<div className='text-xs leading-5 text-muted-foreground space-y-0.5'>
<p>生效:{formatDateTime(certificate.not_before)}</p>
<p>到期:{formatDateTime(certificate.not_after)}</p>
<p>
来源:
{certificate.provider === 'acme'
? 'ACME 申请'
: '手动上传'}
{t('notBeforeLabel', {
value: formatDateTime(certificate.not_before),
})}
</p>
<p>
{t('notAfterLabel', {
value: formatDateTime(certificate.not_after),
})}
</p>
<p>
{t('sourceLabel', {
value:
certificate.provider === 'acme'
? t('sourceAcme')
: t('sourceUpload'),
})}
</p>
{certificate.apply_status === 'applying' ? (
<p className='text-blue-600'>
状态:
{certificate.provider === 'upload'
? '转换申请中...'
: '申请中...'}
{t('statusLabel', {
value:
certificate.provider === 'upload'
? t('converting')
: t('applying'),
})}
</p>
) : null}
{certificate.apply_status === 'error' ? (
<p className='text-destructive'>
状态:
{certificate.provider === 'upload'
? '转换失败'
: '申请失败'}
({certificate.apply_message})
{t('statusError', {
value:
certificate.provider === 'upload'
? t('convertFailed')
: t('applyFailed'),
message: certificate.apply_message,
})}
</p>
) : null}
<p>备注:{certificate.remark || '暂无备注'}</p>
<p>
{t('remarkLabel', {
value: certificate.remark || t('noRemark'),
})}
</p>
</div>
</div>
@@ -227,7 +250,7 @@ export default function CertificatesPage() {
setDetailOpen(true);
}}
>
查看
{t('view')}
</Button>
<Button
variant='outline'
@@ -235,7 +258,7 @@ export default function CertificatesPage() {
className='h-7 text-xs'
onClick={() => handleOpenEditor(certificate)}
>
编辑
{t('edit')}
</Button>
{certificate.provider === 'acme' ? (
<Button
@@ -245,7 +268,7 @@ export default function CertificatesPage() {
disabled={renewMutation.isPending}
onClick={() => renewMutation.mutate(certificate.id)}
>
续期
{t('renew')}
</Button>
) : null}
<Button
@@ -270,7 +293,7 @@ export default function CertificatesPage() {
open={importOpen}
onOpenChange={setImportOpen}
onImported={(certificate) =>
toast.success(`证书 ${certificate.name} 已导入`)
toast.success(t('imported', { name: certificate.name }))
}
/>
@@ -278,7 +301,7 @@ export default function CertificatesPage() {
open={applyOpen && !applyCertificate}
onOpenChange={setApplyOpen}
onApplied={(certificate) =>
toast.success(`证书 ${certificate.name} 申请任务已提交`)
toast.success(t('applySubmitted', { name: certificate.name }))
}
/>
@@ -295,8 +318,8 @@ export default function CertificatesPage() {
setApplyCertificate(null);
toast.success(
applyMode === 'convert-upload'
? `证书 ${certificate.name} 转换申请已提交`
: `证书 ${certificate.name} 配置已更新,重新申请中...`,
? t('convertSubmitted', { name: certificate.name })
: t('reapplySubmitted', { name: certificate.name }),
);
}}
/>
@@ -326,7 +349,7 @@ export default function CertificatesPage() {
open={editorOpen}
onOpenChange={setEditorOpen}
onSaved={(certificate) =>
toast.success(`证书 ${certificate.name} 已更新`)
toast.success(t('updated', { name: certificate.name }))
}
onConvert={(certificate) => {
setEditorOpen(false);
@@ -342,20 +365,20 @@ export default function CertificatesPage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除证书 {deleteTarget?.name} 吗?
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
删除
{tc('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
@@ -43,6 +44,8 @@ export function GroupDialog({
pending: boolean;
onSubmit: (payload: CloudflareGroupPayload) => void;
}) {
const t = useTranslations('cloudflare.groupDialog');
const tCommon = useTranslations('common');
const edgeNodes = useMemo(
() => nodes.filter((node) => node.node_type === 'edge_node'),
[nodes],
@@ -66,14 +69,14 @@ export function GroupDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{group ? '编辑指向分组' : '新增指向分组'}</DialogTitle>
<DialogDescription>
一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。
</DialogDescription>
<DialogTitle>
{group ? t('editTitle') : t('createTitle')}
</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-group-name'>分组名称</FieldLabel>
<FieldLabel htmlFor='cf-group-name'>{t('name')}</FieldLabel>
<Input
id='cf-group-name'
value={name}
@@ -81,10 +84,10 @@ export function GroupDialog({
/>
</Field>
<Field>
<FieldLabel htmlFor='cf-primary-node'>主节点</FieldLabel>
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder='选择带 IPv4 的边缘节点' />
<SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -94,7 +97,7 @@ export function GroupDialog({
value={String(node.id)}
disabled={!node.ip}
>
{node.name} · {node.ip || '未配置 IP'}
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
@@ -102,19 +105,19 @@ export function GroupDialog({
</Select>
</Field>
<Field>
<FieldLabel htmlFor='cf-backup-node'>备用节点</FieldLabel>
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='none'>不设置</SelectItem>
<SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes
.filter((node) => String(node.id) !== primaryNodeID)
.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || '未配置 IP'}
{node.name} · {node.ip || t('noIp')}
</SelectItem>
))}
</SelectGroup>
@@ -123,7 +126,7 @@ export function GroupDialog({
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
新成员默认开启橙云
{t('defaultProxied')}
</FieldLabel>
<Switch
id='cf-default-proxied'
@@ -132,7 +135,7 @@ export function GroupDialog({
/>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-enabled'>启用同步</FieldLabel>
<FieldLabel htmlFor='cf-enabled'>{t('enableSync')}</FieldLabel>
<Switch
id='cf-enabled'
checked={enabled}
@@ -142,7 +145,7 @@ export function GroupDialog({
</FieldGroup>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
{tCommon('cancel')}
</Button>
<Button
disabled={pending || !name.trim() || !primaryNodeID}
@@ -157,7 +160,7 @@ export function GroupDialog({
})
}
>
保存
{t('save')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Search } from 'lucide-react';
@@ -72,6 +73,8 @@ export function MemberAddDialog({
pending: boolean;
onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void;
}) {
const t = useTranslations('cloudflare.memberDialog');
const tCommon = useTranslations('common');
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const [proxied, setProxied] = useState(defaultProxied);
@@ -157,15 +160,14 @@ export function MemberAddDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>添加域名成员</DialogTitle>
<DialogDescription>
支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名
A 会拒绝同步。
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-domain-search'>Zone 域名</FieldLabel>
<FieldLabel htmlFor='cf-domain-search'>
{t('zoneDomain')}
</FieldLabel>
<div className='space-y-2'>
<div className='relative'>
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
@@ -173,7 +175,7 @@ export function MemberAddDialog({
id='cf-domain-search'
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder='搜索域名或顶级域…'
placeholder={t('searchPlaceholder')}
className='pl-8'
disabled={pending}
/>
@@ -182,9 +184,9 @@ export function MemberAddDialog({
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Badge variant='secondary' className='font-normal'>
已选 {selectedIDs.length}
{t('selected', { count: selectedIDs.length })}
</Badge>
<span>可见 {visibleIDs.length}</span>
<span>{t('visible', { count: visibleIDs.length })}</span>
</div>
<div className='flex items-center gap-1'>
<Button
@@ -195,7 +197,7 @@ export function MemberAddDialog({
disabled={pending || visibleIDs.length === 0}
onClick={toggleAllVisible}
>
{allVisibleSelected ? '取消全选' : '全选可见'}
{allVisibleSelected ? t('unselectAll') : t('selectVisible')}
</Button>
<Button
type='button'
@@ -205,18 +207,18 @@ export function MemberAddDialog({
disabled={pending || selectedIDs.length === 0}
onClick={() => setSelectedIDs([])}
>
清空
{t('clear')}
</Button>
</div>
</div>
{domains.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
暂无可用域名(均已加入分组,或尚未在网站管理中添加)。
{t('noAvailable')}
</div>
) : filteredGroups.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
没有匹配的域名
{t('noMatch')}
</div>
) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
@@ -233,7 +235,7 @@ export function MemberAddDialog({
disabled={pending}
onCheckedChange={toggleAllVisible}
/>
<span>按顶级域分组</span>
<span>{t('groupByZone')}</span>
</label>
{filteredGroups.map((group) => {
@@ -270,7 +272,9 @@ export function MemberAddDialog({
}
disabled={pending}
onCheckedChange={() => toggleGroup(group)}
aria-label={`选择顶级域 ${group.zoneDomain}`}
aria-label={t('selectZone', {
name: group.zoneDomain,
})}
className='ml-1'
/>
<CollapsibleTrigger asChild>
@@ -330,7 +334,7 @@ export function MemberAddDialog({
variant='secondary'
className='shrink-0 text-[10px] font-normal'
>
顶级域
{t('zone')}
</Badge>
) : null}
{checked ? (
@@ -350,7 +354,7 @@ export function MemberAddDialog({
</div>
</Field>
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel>
<FieldLabel htmlFor='cf-member-proxied'>{t('proxied')}</FieldLabel>
<Switch
id='cf-member-proxied'
checked={proxied}
@@ -365,7 +369,7 @@ export function MemberAddDialog({
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
{tCommon('cancel')}
</Button>
<Button
disabled={pending || selectedIDs.length === 0}
@@ -377,8 +381,8 @@ export function MemberAddDialog({
}
>
{selectedIDs.length > 1
? `添加并同步(${selectedIDs.length})`
: '添加并同步'}
? t('submitCount', { count: selectedIDs.length })
: t('submit')}
</Button>
</DialogFooter>
</DialogContent>
@@ -9,6 +9,7 @@ import {
RefreshCw,
RotateCcw,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
@@ -109,20 +110,6 @@ async function listCloudflareSyncExecutions(options: {
};
}
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
};
const TRIGGER_LABELS: Record<string, string> = {
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
};
function formatDateTime(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
@@ -143,6 +130,7 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function SyncTasksPanel() {
const t = useTranslations('cloudflare.tasks');
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all');
@@ -175,16 +163,16 @@ export function SyncTasksPanel() {
const retryMutation = useMutation({
mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success('同步任务已重新下发', {
description: `新任务 ID:${taskID}`,
toast.success(t('retried'), {
description: t('newTaskId', { id: taskID }),
});
void queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
},
onError: (err: Error) => {
toast.error('任务重试失败', {
description: err.message || '未知错误',
toast.error(t('retryFailed'), {
description: err.message || t('unknownError'),
});
},
});
@@ -197,6 +185,18 @@ export function SyncTasksPanel() {
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
[total],
);
const statusLabels: Record<TaskExecutionStatus, string> = {
pending: t('pending'),
running: t('running'),
succeeded: t('succeeded'),
failed: t('failed'),
};
const triggerLabels: Record<string, string> = {
system: t('system'),
manual: t('manual'),
retry: t('retry'),
schedule: t('schedule'),
};
const openDetail = (execution: TaskExecution) => {
setPreview(execution);
@@ -210,12 +210,9 @@ export function SyncTasksPanel() {
<div className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-base'>
<Activity className='size-4 text-primary' />
同步任务
{t('title')}
</CardTitle>
<CardDescription>
仅展示 Cloudflare
域名同步(sync_member)与分组同步(sync_group)的执行记录。
</CardDescription>
<CardDescription>{t('description')}</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Select
@@ -226,14 +223,14 @@ export function SyncTasksPanel() {
}}
>
<SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' />
<SelectValue placeholder={t('statusPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
<SelectItem value='all'>{t('allStatus')}</SelectItem>
<SelectItem value='pending'>{t('pending')}</SelectItem>
<SelectItem value='running'>{t('running')}</SelectItem>
<SelectItem value='succeeded'>{t('succeeded')}</SelectItem>
<SelectItem value='failed'>{t('failed')}</SelectItem>
</SelectContent>
</Select>
<Button
@@ -247,7 +244,7 @@ export function SyncTasksPanel() {
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
{t('refresh')}
</Button>
</div>
</CardHeader>
@@ -257,32 +254,44 @@ export function SyncTasksPanel() {
message={
executionsQuery.error instanceof Error
? executionsQuery.error.message
: '加载同步任务失败'
: t('loadFailed')
}
onRetry={() => void executionsQuery.refetch()}
/>
) : loading && executions.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载同步任务中...'
description={t('loading')}
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
title='暂无同步任务'
description='添加域名或同步分组后,这里会显示 Cloudflare 域名 / 分组同步执行记录。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className='rounded-lg border'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[200px]'>任务</TableHead>
<TableHead className='w-[100px]'>状态</TableHead>
<TableHead className='w-[100px]'>触发</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead>
<TableHead className='min-w-[180px]'>结果/错误</TableHead>
<TableHead className='w-[160px]'>创建时间</TableHead>
<TableHead className='w-[200px]'>
{t('columns.task')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.status')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.trigger')}
</TableHead>
<TableHead className='w-[100px]'>
{t('columns.duration')}
</TableHead>
<TableHead className='min-w-[180px]'>
{t('columns.result')}
</TableHead>
<TableHead className='w-[160px]'>
{t('columns.createdAt')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -304,12 +313,12 @@ export function SyncTasksPanel() {
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
{statusLabels[execution.status] || execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
{triggerLabels[execution.triggered_by] ||
execution.triggered_by}
</Badge>
</TableCell>
@@ -331,7 +340,7 @@ export function SyncTasksPanel() {
<div className='flex items-center justify-between gap-2'>
<div className='text-xs text-muted-foreground'>
共 {total} 条,第 {page}/{totalPages} 页
{t('pageSummary', { total, page, pages: totalPages })}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -341,7 +350,7 @@ export function SyncTasksPanel() {
disabled={page <= 1 || loading}
>
<ChevronLeft className='size-4' />
上一页
{t('prev')}
</Button>
<Button
variant='outline'
@@ -349,7 +358,7 @@ export function SyncTasksPanel() {
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading}
>
下一页
{t('next')}
<ChevronRight className='size-4' />
</Button>
</div>
@@ -359,43 +368,51 @@ export function SyncTasksPanel() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[560px]'>
<SheetHeader className='border-b'>
<SheetTitle>同步任务详情</SheetTitle>
<SheetTitle>{t('detailTitle')}</SheetTitle>
<SheetDescription>
{selected?.task_name || selected?.task_type || 'Cloudflare 同步'}
{selected?.task_name || selected?.task_type || t('defaultName')}
</SheetDescription>
</SheetHeader>
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{detailQuery.isFetching && !selected ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
description={t('loadingDetail')}
/>
) : selected ? (
<>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>状态</div>
<div className='text-xs text-muted-foreground'>
{t('status')}
</div>
<div className='mt-2'>
<Badge variant={statusVariant(selected.status)}>
{STATUS_LABELS[selected.status] || selected.status}
{statusLabels[selected.status] || selected.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>触发</div>
<div className='text-xs text-muted-foreground'>
{t('trigger')}
</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selected.triggered_by] ||
{triggerLabels[selected.triggered_by] ||
selected.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>重试</div>
<div className='text-xs text-muted-foreground'>
{t('retries')}
</div>
<div className='mt-2 font-mono text-sm'>
{selected.retry_count}/{selected.max_retry}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='text-xs text-muted-foreground'>
{t('duration')}
</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selected.duration)}
</div>
@@ -403,7 +420,9 @@ export function SyncTasksPanel() {
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>任务 ID</div>
<div className='text-xs text-muted-foreground'>
{t('taskId')}
</div>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selected.task_id}
</div>
@@ -412,7 +431,7 @@ export function SyncTasksPanel() {
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
创建时间
{t('createdAt')}
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.created_at)}
@@ -420,7 +439,7 @@ export function SyncTasksPanel() {
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
结束时间
{t('finishedAt')}
</div>
<div className='font-mono text-xs'>
{formatDateTime(selected.finished_at)}
@@ -429,7 +448,9 @@ export function SyncTasksPanel() {
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>执行结果</div>
<div className='text-xs text-muted-foreground'>
{t('result')}
</div>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selected.result || '-'}
</div>
@@ -438,7 +459,7 @@ export function SyncTasksPanel() {
{selected.error_message ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
错误信息
{t('error')}
</div>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selected.error_message}
@@ -456,7 +477,7 @@ export function SyncTasksPanel() {
{selected.log ? (
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>
执行日志
{t('logs')}
</div>
<pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.log}
@@ -478,11 +499,11 @@ export function SyncTasksPanel() {
) : (
<RotateCcw data-icon='inline-start' />
)}
重试
{t('retry')}
</Button>
) : null}
<Button variant='outline' onClick={() => setDetailOpen(false)}>
关闭
{t('close')}
</Button>
</SheetFooter>
</SheetContent>
@@ -11,6 +11,7 @@ import {
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -46,6 +47,7 @@ import { GroupDialog } from '../../components/group-dialog';
import { MemberAddDialog } from '../../components/member-add-dialog';
export function CloudflareGroupDetailPageClient() {
const t = useTranslations('cloudflare');
const params = useParams<{ id: string }>();
const groupID = Number(params.id);
const queryClient = useQueryClient();
@@ -72,7 +74,7 @@ export function CloudflareGroupDetailPageClient() {
mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.updateGroup(groupID, payload),
onSuccess: async () => {
toast.success('分组配置已更新');
toast.success(t('groupUpdated'));
setEditOpen(false);
await invalidate();
},
@@ -102,13 +104,13 @@ export function CloudflareGroupDetailPageClient() {
if (failed === 0) {
toast.success(
total === 1
? '域名已加入并排队同步'
: `已添加 ${succeeded} 个域名并排队同步`,
? t('memberAddedOne')
: t('memberAddedMany', { count: succeeded }),
);
} else if (succeeded === 0) {
toast.error(`添加失败:${failed} 个域名未能加入`);
toast.error(t('memberAddFailed', { count: failed }));
} else {
toast.warning(`部分成功:${succeeded} 个已加入,${failed} 个失败`);
toast.warning(t('memberPartial', { succeeded, failed }));
}
setAddOpen(false);
await invalidate();
@@ -124,7 +126,7 @@ export function CloudflareGroupDetailPageClient() {
proxied: boolean;
}) => CloudflareService.updateMember(groupID, memberID, proxied),
onSuccess: async () => {
toast.success('橙云设置已更新并排队同步');
toast.success(t('proxyUpdated'));
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
@@ -132,14 +134,14 @@ export function CloudflareGroupDetailPageClient() {
const syncMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.syncMember(groupID, memberID),
onSuccess: () => toast.success('成员同步任务已入队'),
onSuccess: () => toast.success(t('memberSyncQueued')),
onError: (error) => toast.error(getErrorMessage(error)),
});
const removeMutation = useMutation({
mutationFn: (memberID: number) =>
CloudflareService.removeMember(groupID, memberID),
onSuccess: async () => {
toast.success('成员及远端 A 记录已删除');
toast.success(t('memberDeleted'));
await invalidate();
},
onError: (error) => toast.error(getErrorMessage(error)),
@@ -148,7 +150,10 @@ export function CloudflareGroupDetailPageClient() {
if (detailQuery.isLoading)
return (
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' />
<LoadingStateWithBorder
icon={Cloud}
description={t('loadingDetail')}
/>
</div>
);
if (detailQuery.isError || !detailQuery.data)
@@ -168,7 +173,7 @@ export function CloudflareGroupDetailPageClient() {
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
返回列表
{t('back')}
</Link>
</Button>
<div className='flex items-center justify-between gap-3'>
@@ -190,7 +195,7 @@ export function CloudflareGroupDetailPageClient() {
) : (
<RefreshCw data-icon='inline-start' />
)}
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -198,11 +203,11 @@ export function CloudflareGroupDetailPageClient() {
onClick={() => setEditOpen(true)}
>
<Settings data-icon='inline-start' />
编辑
{t('edit')}
</Button>
<Button size='sm' onClick={() => setAddOpen(true)}>
<Plus data-icon='inline-start' />
添加域名
{t('addDomain')}
</Button>
</div>
</div>
@@ -210,41 +215,50 @@ export function CloudflareGroupDetailPageClient() {
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>当前指向</CardTitle>
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
<CardDescription>
生效节点 {group.active_node.name} · {group.active_node.ip}
{t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '同步已启用' : '同步已停用'}
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
</Badge>
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
<Badge variant='outline'>
备用节点 {group.backup_node?.name ?? '未设置'}
{t('primaryNode', { name: group.primary_node.name })}
</Badge>
<Badge variant='outline'>
{t('backupNode', {
name: group.backup_node?.name ?? t('backupUnset'),
})}
</Badge>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>域名成员</CardTitle>
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
<CardTitle className='text-base'>{t('members')}</CardTitle>
<CardDescription>{t('membersDesc')}</CardDescription>
</CardHeader>
<CardContent>
{members.length === 0 ? (
<p className='py-8 text-center text-sm text-muted-foreground'>
暂无域名成员。
{t('noMembers')}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>域名</TableHead>
<TableHead>期望 IP</TableHead>
<TableHead>状态</TableHead>
<TableHead>橙云</TableHead>
<TableHead className='text-right'>操作</TableHead>
<TableHead>{t('columns.domain')}</TableHead>
<TableHead>{t('columns.desiredIp')}</TableHead>
<TableHead>{t('columns.status')}</TableHead>
<TableHead>{t('columns.proxied')}</TableHead>
<TableHead className='text-right'>
{t('columns.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -258,7 +272,9 @@ export function CloudflareGroupDetailPageClient() {
</p>
) : null}
</TableCell>
<TableCell>{member.desired_ip || '待同步'}</TableCell>
<TableCell>
{member.desired_ip || t('pendingSync')}
</TableCell>
<TableCell>
<Badge
variant={
@@ -291,7 +307,7 @@ export function CloudflareGroupDetailPageClient() {
onClick={() => syncMutation.mutate(member.id)}
>
<RefreshCw data-icon='inline-start' />
同步
{t('sync')}
</Button>
<Button
variant='destructive'
@@ -299,7 +315,7 @@ export function CloudflareGroupDetailPageClient() {
onClick={() => removeMutation.mutate(member.id)}
>
<Trash2 data-icon='inline-start' />
移出
{t('remove')}
</Button>
</div>
</TableCell>
@@ -1,5 +1,6 @@
import { Suspense } from 'react';
import { Cloud } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';
import { LoadingStateWithBorder } from '@/components/layout/loading';
@@ -11,13 +12,14 @@ export async function generateStaticParams() {
}
export default async function CloudflareGroupDetailPage() {
const t = await getTranslations('cloudflare');
return (
<Suspense
fallback={
<div className='w-full py-6 px-1'>
<LoadingStateWithBorder
icon={Cloud}
description='加载分组详情中...'
description={t('loadingDetail')}
/>
</div>
}
+33 -32
View File
@@ -10,6 +10,7 @@ import {
Trash2,
} from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -48,6 +49,8 @@ import {
import { getErrorMessage } from '../websites/components/website-utils';
export default function CloudflarePage() {
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>(
@@ -72,7 +75,7 @@ export default function CloudflarePage() {
mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.createGroup(payload),
onSuccess: async () => {
toast.success('指向分组已创建');
toast.success(t('created'));
setCreateOpen(false);
await invalidate();
},
@@ -81,7 +84,7 @@ export default function CloudflarePage() {
const syncMutation = useMutation({
mutationFn: (id: number) => CloudflareService.syncGroup(id),
onSuccess: async () => {
toast.success('分组同步任务已入队');
toast.success(t('syncQueued'));
await queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'],
});
@@ -91,7 +94,7 @@ export default function CloudflarePage() {
const deleteMutation = useMutation({
mutationFn: (id: number) => CloudflareService.deleteGroup(id),
onSuccess: async () => {
toast.success('指向分组及远端记录已删除');
toast.success(t('deleted'));
setDeleteTarget(null);
await invalidate();
},
@@ -105,18 +108,20 @@ export default function CloudflarePage() {
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>Cloudflare</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div>
<div className='flex items-center gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href='/cloudflare/settings'>
<Settings data-icon='inline-start' />
连接设置
{t('connectionSettings')}
</Link>
</Button>
<Button size='sm' onClick={() => setCreateOpen(true)}>
<Plus data-icon='inline-start' />
新增分组
{t('addGroup')}
</Button>
</div>
</div>
@@ -124,7 +129,7 @@ export default function CloudflarePage() {
{overviewQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description='加载 Cloudflare 总览中...'
description={t('loadingOverview')}
/>
) : overviewQuery.isError ? (
<ErrorInline
@@ -134,14 +139,11 @@ export default function CloudflarePage() {
) : !overview?.connection.ready ? (
<Alert>
<AlertTriangle />
<AlertTitle>Cloudflare 连接尚未就绪</AlertTitle>
<AlertTitle>{t('notReadyTitle')}</AlertTitle>
<AlertDescription className='flex flex-col items-start gap-3'>
<span>
请先导入现有 Cloudflare DNS 账号,或录入独立 API Token
并完成连接测试。
</span>
<span>{t('notReadyDesc')}</span>
<Button asChild size='sm'>
<Link href='/cloudflare/settings'>配置连接</Link>
<Link href='/cloudflare/settings'>{t('configure')}</Link>
</Button>
</AlertDescription>
</Alert>
@@ -149,16 +151,14 @@ export default function CloudflarePage() {
<div className='flex flex-col gap-4'>
<div>
<h2 className='text-lg font-semibold'>指向分组</h2>
<p className='text-sm text-muted-foreground'>
管理 Cloudflare A 记录对应的节点与域名成员。
</p>
<h2 className='text-lg font-semibold'>{t('groupsTitle')}</h2>
<p className='text-sm text-muted-foreground'>{t('groupsDesc')}</p>
</div>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description='加载指向分组中...'
description={t('loadingGroups')}
/>
) : groupsQuery.isError ? (
<ErrorInline
@@ -168,9 +168,9 @@ export default function CloudflarePage() {
) : (groupsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Cloud}
title='暂无指向分组'
description='创建分组后,可将域名成员同步到指定的边缘节点。'
actionText='新增分组'
title={t('emptyTitle')}
description={t('emptyDesc')}
actionText={t('addGroup')}
onAction={() => setCreateOpen(true)}
/>
) : (
@@ -186,20 +186,22 @@ export default function CloudflarePage() {
</CardDescription>
</div>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '已启用' : '已停用'}
{group.enabled ? t('enabled') : t('disabled')}
</Badge>
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<p className='text-sm text-muted-foreground'>
成员 {group.member_count} 个 · 新成员默认
{group.default_proxied ? '开启' : '关闭'}橙云
{t('memberSummary', {
count: group.member_count,
proxy: group.default_proxied ? t('proxyOn') : t('proxyOff'),
})}
</p>
<div className='flex flex-wrap gap-2'>
<Button asChild size='sm'>
<Link href={`/cloudflare/groups/${group.id}`}>
<Settings data-icon='inline-start' />
管理
{t('manage')}
</Link>
</Button>
<Button
@@ -208,7 +210,7 @@ export default function CloudflarePage() {
onClick={() => syncMutation.mutate(group.id)}
>
<RefreshCw data-icon='inline-start' />
同步
{t('sync')}
</Button>
<Button
variant='destructive'
@@ -216,7 +218,7 @@ export default function CloudflarePage() {
onClick={() => setDeleteTarget(group)}
>
<Trash2 data-icon='inline-start' />
删除
{t('delete')}
</Button>
</div>
</CardContent>
@@ -242,20 +244,19 @@ export default function CloudflarePage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除指向分组</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A
记录。此操作不可撤销。
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,7 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
@@ -42,6 +43,7 @@ import { getErrorMessage } from '../../websites/components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'] as const;
export default function CloudflareSettingsPage() {
const t = useTranslations('cloudflare.settings');
const queryClient = useQueryClient();
const [source, setSource] =
useState<CloudflareConnectionSource>('dns_account');
@@ -85,7 +87,7 @@ export default function CloudflareSettingsPage() {
api_token: source === 'standalone' ? apiToken : '',
}),
onSuccess: async () => {
toast.success('Cloudflare 连接配置已保存');
toast.success(t('saved'));
setAPIToken('');
await refresh();
},
@@ -95,7 +97,7 @@ export default function CloudflareSettingsPage() {
const verifyMutation = useMutation({
mutationFn: () => CloudflareService.verifyConnection(),
onSuccess: async () => {
toast.success('Cloudflare 连接验证成功');
toast.success(t('verified'));
await refresh();
},
onError: (error) => toast.error(getErrorMessage(error)),
@@ -104,7 +106,7 @@ export default function CloudflareSettingsPage() {
const clearMutation = useMutation({
mutationFn: () => CloudflareService.clearConnection(),
onSuccess: async () => {
toast.success('Cloudflare 连接已清除');
toast.success(t('cleared'));
setDNSAccountID('');
setAPIToken('');
await refresh();
@@ -118,13 +120,13 @@ export default function CloudflareSettingsPage() {
<Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' />
返回列表
{t('back')}
</Link>
</Button>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
Cloudflare 连接设置
{t('title')}
</h1>
</div>
</div>
@@ -138,16 +140,15 @@ export default function CloudflareSettingsPage() {
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base'>凭据来源</CardTitle>
<CardTitle className='text-base'>{t('sourceTitle')}</CardTitle>
<CardDescription>
Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API
或页面中回显。
{t('sourceDesc')}
</CardDescription>
</CardHeader>
<CardContent>
<FieldGroup>
<Field>
<FieldLabel htmlFor='cf-source'>连接来源</FieldLabel>
<FieldLabel htmlFor='cf-source'>{t('source')}</FieldLabel>
<Select
value={source}
onValueChange={(value) =>
@@ -160,9 +161,11 @@ export default function CloudflareSettingsPage() {
<SelectContent>
<SelectGroup>
<SelectItem value='dns_account'>
导入现有 DNS 账号
{t('importDns')}
</SelectItem>
<SelectItem value='standalone'>
{t('standalone')}
</SelectItem>
<SelectItem value='standalone'>独立 API Token</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -171,11 +174,11 @@ export default function CloudflareSettingsPage() {
{source === 'dns_account' ? (
<Field>
<FieldLabel htmlFor='cf-dns-account'>
Cloudflare DNS 账号
{t('dnsAccount')}
</FieldLabel>
<Select value={dnsAccountID} onValueChange={setDNSAccountID}>
<SelectTrigger id='cf-dns-account' className='w-full'>
<SelectValue placeholder='选择 DNS 账号' />
<SelectValue placeholder={t('selectAccount')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -188,8 +191,9 @@ export default function CloudflareSettingsPage() {
</SelectContent>
</Select>
<FieldDescription>
仅显示类型为 cloudflare 的 DNS 账号。可前往{' '}
<Link href='/dns-accounts'>DNS 账号</Link> 新增。
{t('dnsHint')}{' '}
<Link href='/dns-accounts'>{t('dnsLink')}</Link>
{t('dnsHintAfter')}
</FieldDescription>
</Field>
) : (
@@ -203,8 +207,8 @@ export default function CloudflareSettingsPage() {
onChange={(event) => setAPIToken(event.target.value)}
placeholder={
connectionQuery.data?.configured
? '留空不会回显现有 Token;保存将替换'
: '输入 Cloudflare API Token'
? t('tokenKeep')
: t('tokenEnter')
}
/>
</Field>
@@ -219,7 +223,7 @@ export default function CloudflareSettingsPage() {
}
>
<Save data-icon='inline-start' />
保存配置
{t('save')}
</Button>
<Button
variant='outline'
@@ -229,7 +233,7 @@ export default function CloudflareSettingsPage() {
}
>
<ShieldCheck data-icon='inline-start' />
测试连接
{t('test')}
</Button>
<Button
variant='destructive'
@@ -239,7 +243,7 @@ export default function CloudflareSettingsPage() {
}
>
<Trash2 data-icon='inline-start' />
清除连接
{t('clear')}
</Button>
</div>
</FieldGroup>
@@ -248,11 +252,11 @@ export default function CloudflareSettingsPage() {
<Alert>
<ShieldCheck />
<AlertTitle>当前状态</AlertTitle>
<AlertTitle>{t('statusTitle')}</AlertTitle>
<AlertDescription>
{connectionQuery.data?.ready
? 'Token 已验证,Cloudflare 指向同步可以执行。'
: '保存配置后仍需测试连接;未验证状态下同步任务会被拒绝。'}
? t('ready')
: t('notReady')}
</AlertDescription>
</Alert>
</div>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -14,18 +16,23 @@ import { formatPercent, formatTrendHour } from './dashboard-utils';
export function CapacityTrendChart({
points,
title = '24 小时容量趋势',
description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。',
title,
description,
}: {
points: CapacityTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('dashboard.capacityTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<CardDescription className='text-xs'>{description}</CardDescription>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -35,7 +42,7 @@ export function CapacityTrendChart({
yAxisValueFormatter={formatPercent}
series={[
{
label: '平均 CPU',
label: t('avgCpu'),
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
@@ -43,7 +50,7 @@ export function CapacityTrendChart({
valueFormatter: formatPercent,
},
{
label: '平均内存',
label: t('avgMemory'),
color: '#2563eb',
values: points.map((point) => point.average_memory_usage_percent),
valueFormatter: formatPercent,
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
import {
Card,
@@ -25,19 +27,20 @@ export function SourceDistributionChart({
}: {
items: TrafficDistributions['source_countries'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>来源分布</CardTitle>
<CardDescription className='text-xs'>
聚合最近 24 小时主要来源国家。
</CardDescription>
<CardTitle className='text-sm font-semibold'>
{t('sourceTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('sourceDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color='#38bdf8'
emptyMessage='暂无来源分布数据'
emptyMessage={t('sourceEmpty')}
/>
</CardContent>
</Card>
@@ -49,22 +52,23 @@ export function StatusCodeDistributionChart({
}: {
items: TrafficDistributions['status_codes'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>状态码分布</CardTitle>
<CardDescription className='text-xs'>
快速判断成功响应是否仍是主流,以及错误码是否有抬升。
</CardDescription>
<CardTitle className='text-sm font-semibold'>
{t('statusTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('statusDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items).map((item) => ({
...item,
label: `HTTP ${item.label}`,
label: t('httpLabel', { code: item.label }),
}))}
color='#f59e0b'
emptyMessage='暂无状态码分布'
emptyMessage={t('statusEmpty')}
/>
</CardContent>
</Card>
@@ -76,19 +80,20 @@ export function TopDomainChart({
}: {
items: TrafficDistributions['top_domains'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>Top Domain</CardTitle>
<CardDescription className='text-xs'>
观察主要流量集中在哪些域名。
</CardDescription>
<CardTitle className='text-sm font-semibold'>
{t('domainTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('domainDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color='#34d399'
emptyMessage='暂无域名分布'
emptyMessage={t('domainEmpty')}
/>
</CardContent>
</Card>
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Button } from '@/components/ui/button';
@@ -33,6 +34,8 @@ import {
import { formatCompactNumber, formatPercent } from './dashboard-utils';
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeHealth');
const tn = useTranslations('nodes');
const sortedNodes = [...nodes].sort((left, right) => {
if (right.active_event_count !== left.active_event_count) {
return right.active_event_count - left.active_event_count;
@@ -54,33 +57,33 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='text-sm font-semibold'>节点健康列表</CardTitle>
<CardDescription className='text-xs'>
按异常数量和资源压力排序。
</CardDescription>
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
<CardDescription className='text-xs'>{t('description')}</CardDescription>
</div>
<Button variant='outline' size='sm' className='h-8 text-xs' asChild>
<Link href='/nodes'>进入节点页</Link>
<Link href='/nodes'>{t('openNodes')}</Link>
</Button>
</CardHeader>
<CardContent>
{sortedNodes.length === 0 ? (
<EmptyStateWithBorder
title='暂无节点'
description='节点接入后,这里会展示系统健康与容量摘要。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>节点</TableHead>
<TableHead className='py-2 h-8'>状态</TableHead>
<TableHead className='py-2 h-8'>CPU / 内存</TableHead>
<TableHead className='py-2 h-8'>请求 / 错误</TableHead>
<TableHead className='py-2 h-8'>活动异常</TableHead>
<TableHead className='py-2 h-8'>最近心跳</TableHead>
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
<TableHead className='py-2 h-8'>{t('colNode')}</TableHead>
<TableHead className='py-2 h-8'>{t('colStatus')}</TableHead>
<TableHead className='py-2 h-8'>{t('colCpuMem')}</TableHead>
<TableHead className='py-2 h-8'>{t('colReqErr')}</TableHead>
<TableHead className='py-2 h-8'>{t('colEvents')}</TableHead>
<TableHead className='py-2 h-8'>{t('colHeartbeat')}</TableHead>
<TableHead className='py-2 h-8 text-right'>
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -96,18 +99,21 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
{node.node_id}
</p>
<p className='text-xs text-muted-foreground'>
{node.geo_name || '未配置地图点位'}
{node.geo_name || t('noGeo')}
</p>
</div>
</TableCell>
<TableCell className='py-3'>
<div className='flex flex-wrap gap-1.5'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, tn)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(
node.openresty_status,
tn,
)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</div>
@@ -130,10 +136,10 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
</TableCell>
<TableCell className='py-3 text-xs text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: node.last_seen_at
? formatRelativeTime(node.last_seen_at)
: '暂无'}
? formatRelativeTime(node.last_seen_at, tn)
: t('na')}
</TableCell>
<TableCell className='py-3 text-right'>
<Button
@@ -142,7 +148,9 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
className='h-8 text-xs'
asChild
>
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button>
</TableCell>
</TableRow>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { DashboardNodeHealth } from '@/lib/services/openflare';
@@ -27,25 +29,26 @@ function buildNodeRankItems(
}
export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeRank');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>Top 节点榜单</CardTitle>
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-6'>
<div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
流量最高节点
{t('traffic')}
</p>
<RankChart
items={buildNodeRankItems(nodes, (node) => node.request_count)}
color='#38bdf8'
emptyMessage='暂无流量榜单'
emptyMessage={t('trafficEmpty')}
/>
</div>
<div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
容量压力节点
{t('pressure')}
</p>
<RankChart
items={buildNodeRankItems(nodes, (node) =>
@@ -59,7 +62,7 @@ export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
)}
color='#ef4444'
valueFormatter={(value) => `${value}%`}
emptyMessage='暂无容量压力数据'
emptyMessage={t('pressureEmpty')}
/>
</div>
</CardContent>
@@ -1,6 +1,7 @@
'use client';
import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
@@ -21,6 +22,7 @@ export function DashboardStatCards({
traffic: DashboardTraffic;
capacity: DashboardCapacity;
}) {
const t = useTranslations('dashboard.stats');
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
@@ -31,7 +33,7 @@ export function DashboardStatCards({
<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'>
24h 请求量
{t('requests24h')}
</span>
<Activity className='size-4 text-primary' />
</CardHeader>
@@ -40,9 +42,11 @@ export function DashboardStatCards({
{formatCompactNumber(traffic.request_count)}
</div>
<p className='text-[10px] text-muted-foreground'>
24h 独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
{traffic.estimated_qps.toFixed(2)}
{t('requestsMeta', {
visitors: formatCompactNumber(traffic.unique_visitors),
errors: formatCompactNumber(traffic.error_count),
qps: traffic.estimated_qps.toFixed(2),
})}
</p>
</CardContent>
</Card>
@@ -50,7 +54,7 @@ export function DashboardStatCards({
<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'>
集群容量
{t('clusterCapacity')}
</span>
<Cpu className='size-4 text-primary' />
</CardHeader>
@@ -66,9 +70,12 @@ export function DashboardStatCards({
className='h-1.5'
/>
<p className='text-[10px] text-muted-foreground'>
平均内存 {formatPercent(capacity.average_memory_usage_percent)} ·
高负载节点 CPU {capacity.high_cpu_nodes} / 内存{' '}
{capacity.high_memory_nodes} / 存储 {capacity.high_storage_nodes}
{t('avgMemoryHighLoad', {
memory: formatPercent(capacity.average_memory_usage_percent),
cpu: capacity.high_cpu_nodes,
memoryNodes: capacity.high_memory_nodes,
storage: capacity.high_storage_nodes,
})}
</p>
</CardContent>
</Card>
@@ -76,7 +83,7 @@ export function DashboardStatCards({
<Card className='border-dashed shadow-none md:col-span-2 xl:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
节点概览
{t('nodeOverview')}
</span>
<Server className='size-4 text-primary' />
</CardHeader>
@@ -85,18 +92,21 @@ export function DashboardStatCards({
{summary.online_nodes}
<span className='text-sm font-normal text-muted-foreground'>
{' '}
/ {summary.total_nodes} 在线
{t('onlineOf', { total: summary.total_nodes })}
</span>
</div>
<Progress value={onlineRate} className='h-1.5' />
<p className='text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1'>
<span className='inline-flex items-center gap-1'>
<Users className='size-3' />
待接入 {summary.pending_nodes}
{t('pending', { count: summary.pending_nodes })}
</span>
<span className='inline-flex items-center gap-1'>
<Gauge className='size-3' />
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes}
{t('unhealthyOffline', {
unhealthy: summary.unhealthy_nodes,
offline: summary.offline_nodes,
})}
</span>
</p>
</CardContent>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type {
@@ -17,11 +19,13 @@ export function TrafficCapacityTrendChart({
networkPoints: NetworkTrendPoint[];
capacityPoints: CapacityTrendPoint[];
}) {
const t = useTranslations('dashboard.trafficCapacity');
const tc = useTranslations('dashboard.capacityTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
24 小时业务流量与容量趋势
{t('title')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
@@ -31,11 +35,11 @@ export function TrafficCapacityTrendChart({
)}
height={180}
summaryScope='total'
summaryHint='近 24 小时 · 来自访问日志'
summaryHint={t('networkHint')}
yAxisValueFormatter={formatBytes}
series={[
{
label: '接收数据',
label: t('received'),
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
@@ -43,7 +47,7 @@ export function TrafficCapacityTrendChart({
valueFormatter: formatBytes,
},
{
label: '已提供数据',
label: t('provided'),
color: '#38bdf8',
values: networkPoints.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
@@ -57,11 +61,11 @@ export function TrafficCapacityTrendChart({
)}
height={180}
summaryScope='average'
summaryHint='近 24 小时 · 宿主机容量 · 平均值'
summaryHint={t('capacityHint')}
yAxisValueFormatter={formatPercent}
series={[
{
label: '平均 CPU',
label: tc('avgCpu'),
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
@@ -71,7 +75,7 @@ export function TrafficCapacityTrendChart({
valueFormatter: formatPercent,
},
{
label: '平均内存',
label: tc('avgMemory'),
color: '#2563eb',
values: capacityPoints.map(
(point) => point.average_memory_usage_percent,
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -14,18 +16,23 @@ import { formatTrendHour } from './dashboard-utils';
export function TrafficTrendChart({
points,
title = '24 小时请求趋势',
description = '按小时拆分请求总量与 2xx/4xx/5xx 状态码请求量,判断各状态是否异常抬升。',
title,
description,
}: {
points: TrafficTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('dashboard.trafficTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<CardDescription className='text-xs'>{description}</CardDescription>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -33,27 +40,27 @@ export function TrafficTrendChart({
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint='近 24 小时'
summaryHint={t('summaryHint')}
series={[
{
label: '请求量',
label: t('requests'),
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: points.map((point) => point.request_count),
},
{
label: '2xx 请求',
label: t('status2xx'),
color: '#22c55e',
values: points.map((point) => point.status_2xx_count),
},
{
label: '4xx 请求',
label: t('status4xx'),
color: '#f97316',
values: points.map((point) => point.status_4xx_count),
},
{
label: '5xx 请求',
label: t('status5xx'),
color: '#ef4444',
values: points.map((point) => point.status_5xx_count),
},
@@ -7,6 +7,7 @@ import { MapChart, ScatterChart } from 'echarts/charts';
import { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useTheme } from 'next-themes';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -276,6 +277,8 @@ export function WorldStageMap({
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const t = useTranslations('dashboard.map');
const tn = useTranslations('nodes');
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const router = useRouter();
@@ -503,7 +506,7 @@ export function WorldStageMap({
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
].join('');
}
@@ -513,14 +516,22 @@ export function WorldStageMap({
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · 预设落点`;
: `${data.geoName} · ${t('presetPin')}`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
`<div>${t('requestsErrors', {
requests: data.requestCount.toLocaleString('zh-CN'),
errors: data.errorCount.toLocaleString('zh-CN'),
})}</div>`,
`<div>${t('eventsStatus', {
events: data.activeEventCount,
status: getNodeStatusLabel(data.status, tn),
})}</div>`,
`<div>${t('openrestyStatus', {
status: getOpenrestyStatusLabel(data.openrestyStatus, tn),
})}</div>`,
].join('');
},
},
@@ -610,6 +621,8 @@ export function WorldStageMap({
mapNodes,
mapPalette,
responsiveMapScale,
t,
tn,
],
);
@@ -617,12 +630,8 @@ export function WorldStageMap({
return (
<div className='flex h-full items-center justify-center'>
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
title={mapFailed ? t('loadFailed') : t('loading')}
description={mapFailed ? t('loadFailedDesc') : t('loadingDesc')}
/>
</div>
);
@@ -654,8 +663,8 @@ export function WorldStageMap({
) : (
<div className='flex h-full items-center justify-center'>
<EmptyState
title='全球地图加载中'
description='正在测量地图容器尺寸...'
title={t('loading')}
description={t('measuring')}
iconSize='sm'
/>
</div>
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic';
import { type ComponentType, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
Activity,
Cpu,
@@ -40,10 +41,10 @@ const WorldStageMap = dynamic(
);
const LEGEND_ITEMS = [
{ dot: 'bg-blue-500', label: '来源' },
{ dot: 'bg-emerald-500', label: '正常' },
{ dot: 'bg-amber-500', label: '承压' },
{ dot: 'bg-destructive', label: '异常' },
{ dot: 'bg-blue-500', key: 'legendSource' },
{ dot: 'bg-emerald-500', key: 'legendHealthy' },
{ dot: 'bg-amber-500', key: 'legendPressure' },
{ dot: 'bg-destructive', key: 'legendUnhealthy' },
] as const;
function SummaryMetric({
@@ -128,6 +129,7 @@ export function WorldStage({
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const t = useTranslations('dashboard.world');
const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false);
@@ -177,62 +179,71 @@ export function WorldStage({
const mapModeLabel =
sourceCountries.length > 0
? '访客来源'
? t('modeVisitorSource')
: geoConfiguredNodes > 0
? '节点坐标'
: '覆盖信号';
? t('modeNodeCoords')
: t('modeCoverage');
const headlineMetrics = [
{
label: '在线覆盖',
label: t('onlineCoverage'),
value: formatPercent(onlineRate),
hint: `${summary.online_nodes}/${summary.total_nodes} 在线`,
hint: t('onlineHint', {
online: summary.online_nodes,
total: summary.total_nodes,
}),
icon: Server,
progress: onlineRate,
},
{
label: '运行健康',
label: t('runHealth'),
value: formatPercent(healthyRate),
hint: `${summary.unhealthy_nodes} 个异常`,
hint: t('unhealthyCount', { count: summary.unhealthy_nodes }),
icon: ShieldCheck,
progress: healthyRate,
},
{
label: '24小时请求',
label: t('requests24h'),
value: formatCompactNumber(traffic.request_count),
hint: `QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点上报`,
hint: t('qpsReported', {
qps: traffic.estimated_qps.toFixed(1),
count: traffic.reported_nodes,
}),
icon: Activity,
},
{
label: '节点坐标',
label: t('nodeCoords'),
value: formatCompactNumber(geoConfiguredNodes),
hint:
geoConfiguredNodes > 0
? `${sourceCountries.length} 个来源国家`
: '未配置时使用预设落点',
? t('sourceCountries', { count: sourceCountries.length })
: t('fallbackPins'),
icon: Network,
},
] as const;
const detailMetrics = [
{
label: '平均 CPU',
label: t('avgCpu'),
value: formatPercent(capacity.average_cpu_usage_percent),
hint: `${capacity.high_cpu_nodes} 个偏高`,
hint: t('highCount', { count: capacity.high_cpu_nodes }),
icon: Cpu,
progress: capacity.average_cpu_usage_percent,
},
{
label: '平均内存',
label: t('avgMemory'),
value: formatPercent(capacity.average_memory_usage_percent),
hint: `${capacity.high_memory_nodes} 个偏高`,
hint: t('highCount', { count: capacity.high_memory_nodes }),
icon: MemoryStick,
progress: capacity.average_memory_usage_percent,
},
{
label: '高存储',
label: t('highStorage'),
value: formatCompactNumber(capacity.high_storage_nodes),
hint: `${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`,
hint: t('offlinePending', {
offline: summary.offline_nodes,
pending: summary.pending_nodes,
}),
icon: HardDrive,
},
] as const;
@@ -244,7 +255,7 @@ export function WorldStage({
<div className='min-w-0'>
<CardTitle className='flex items-center gap-1.5 text-sm font-semibold'>
<Globe2 className='size-4 shrink-0 text-primary' />
全球态势板
{t('title')}
<Badge
variant='outline'
className='ml-1 text-[10px] font-normal text-muted-foreground'
@@ -253,17 +264,17 @@ export function WorldStage({
</Badge>
</CardTitle>
<CardDescription className='mt-1 text-xs'>
汇总节点分布、来源国家热度与最近窗口运行状态。
{t('description')}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5'>
{LEGEND_ITEMS.map((item) => (
<span
key={item.label}
key={item.key}
className='inline-flex items-center gap-1 text-[10px] text-muted-foreground'
>
<span className={cn('size-1.5 rounded-full', item.dot)} />
{item.label}
{t(item.key)}
</span>
))}
</div>
@@ -291,8 +302,8 @@ export function WorldStage({
) : (
<div className='flex h-full items-center justify-center px-4'>
<EmptyState
title='地图准备中'
description='进入可视区域后加载'
title={t('mapPreparing')}
description={t('mapPreparingDesc')}
iconSize='sm'
/>
</div>
@@ -301,7 +312,7 @@ export function WorldStage({
{shouldRenderMap && nodes.length === 0 ? (
<div className='pointer-events-none absolute inset-x-3 bottom-2 z-10'>
<p className='rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm'>
暂无节点,接入后将展示地理分布
{t('noNodesOverlay')}
</p>
</div>
) : null}
@@ -3,6 +3,7 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -19,11 +20,13 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const cleanupSchema = z.object({
keepCount: z.number().int().min(3, '最少保留 3 个历史快照'),
});
function buildCleanupSchema(t: (key: string) => string) {
return z.object({
keepCount: z.number().int().min(3, t('keepMin')),
});
}
type CleanupFormValues = z.infer<typeof cleanupSchema>;
type CleanupFormValues = z.infer<ReturnType<typeof buildCleanupSchema>>;
interface CleanupDialogProps {
open: boolean;
@@ -38,8 +41,10 @@ export function CleanupDialog({
onConfirm,
loading,
}: CleanupDialogProps) {
const t = useTranslations('configVersions');
const tc = useTranslations('common');
const form = useForm<CleanupFormValues>({
resolver: zodResolver(cleanupSchema),
resolver: zodResolver(buildCleanupSchema(t)),
defaultValues: { keepCount: 10 },
});
@@ -57,14 +62,12 @@ export function CleanupDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理历史快照</AlertDialogTitle>
<AlertDialogDescription>
删除旧的历史快照配置,系统将始终保护当前已激活的版本不被删除。
</AlertDialogDescription>
<AlertDialogTitle>{t('cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>{t('cleanupDesc')}</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-2'>
<Label htmlFor='keepCount'>保留最近快照个数</Label>
<Label htmlFor='keepCount'>{t('keepCount')}</Label>
<Input
id='keepCount'
type='number'
@@ -73,7 +76,7 @@ export function CleanupDialog({
{...form.register('keepCount', { valueAsNumber: true })}
/>
<p className='text-xs text-muted-foreground'>
默认为 10 个,最少需保留 3 个。
{t('keepHint')}
</p>
{form.formState.errors.keepCount ? (
<p className='text-xs text-destructive'>
@@ -83,7 +86,7 @@ export function CleanupDialog({
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={loading}>{tc('cancel')}</AlertDialogCancel>
<Button
variant='destructive'
onClick={() => void handleConfirm()}
@@ -92,10 +95,10 @@ export function CleanupDialog({
{loading ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
清理中...
{t('cleaning')}
</>
) : (
'确认清理'
t('confirmCleanup')
)}
</Button>
</AlertDialogFooter>
@@ -1,6 +1,7 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import {
@@ -29,12 +30,13 @@ interface DiffDialogProps {
}
function DiffChipList({ title, items }: { title: string; items: string[] }) {
const t = useTranslations('configVersions');
return (
<div className='space-y-2'>
<div className='flex items-center justify-between gap-2'>
<p className='text-xs font-semibold'>{title}</p>
<Badge variant='outline' className='text-[10px] rounded-full'>
{items.length} 项
{t('itemCount', { count: items.length })}
</Badge>
</div>
{items.length > 0 ? (
@@ -50,16 +52,12 @@ function DiffChipList({ title, items }: { title: string; items: string[] }) {
))}
</div>
) : (
<p className='text-xs text-muted-foreground'>无变更</p>
<p className='text-xs text-muted-foreground'>{t('noChange')}</p>
)}
</div>
);
}
function renderOptionValue(value: string) {
return value === '' ? '空' : value;
}
export function DiffDialog({
open,
onOpenChange,
@@ -67,20 +65,19 @@ export function DiffDialog({
loading,
error,
}: DiffDialogProps) {
const t = useTranslations('configVersions');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>配置差异</DialogTitle>
<DialogDescription>
对比当前待发布配置与已激活版本之间的差异。
</DialogDescription>
<DialogTitle>{t('diffTitle')}</DialogTitle>
<DialogDescription>{t('diffDesc')}</DialogDescription>
</DialogHeader>
{loading ? (
<div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
正在加载差异数据...
{t('loadingDiff')}
</div>
) : error ? (
<p className='text-sm text-destructive'>{error}</p>
@@ -89,15 +86,15 @@ export function DiffDialog({
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
激活版本
{t('activeVersion')}
</p>
<p className='mt-1 text-sm font-medium'>
{diff.active_version || '无'}
{diff.active_version || t('none')}
</p>
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
新增域名
{t('addedDomains')}
</p>
<p className='mt-1 text-lg font-semibold text-emerald-600'>
{diff.added_domains.length}
@@ -105,7 +102,7 @@ export function DiffDialog({
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
删除域名
{t('removedDomains')}
</p>
<p className='mt-1 text-lg font-semibold text-amber-600'>
{diff.removed_domains.length}
@@ -113,7 +110,7 @@ export function DiffDialog({
</div>
<div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
修改域名
{t('modifiedDomains')}
</p>
<p className='mt-1 text-lg font-semibold text-blue-600'>
{diff.modified_domains.length}
@@ -122,25 +119,38 @@ export function DiffDialog({
</div>
<div className='grid gap-4 md:grid-cols-3'>
<DiffChipList title='新增域名' items={diff.added_domains} />
<DiffChipList title='删除域名' items={diff.removed_domains} />
<DiffChipList title='修改域名' items={diff.modified_domains} />
<DiffChipList
title={t('addedDomains')}
items={diff.added_domains}
/>
<DiffChipList
title={t('removedDomains')}
items={diff.removed_domains}
/>
<DiffChipList
title={t('modifiedDomains')}
items={diff.modified_domains}
/>
</div>
<div className='flex flex-wrap gap-2'>
<Badge
variant={diff.main_config_changed ? 'destructive' : 'outline'}
>
主配置 {diff.main_config_changed ? '已变化' : '无变化'}
{diff.main_config_changed
? t('mainChanged')
: t('mainUnchanged')}
</Badge>
<Badge
variant={diff.waf_config_changed ? 'destructive' : 'outline'}
>
WAF {diff.waf_config_changed ? '已变化' : '无变化'}
{diff.waf_config_changed ? t('wafChanged') : t('wafUnchanged')}
</Badge>
<Badge variant='outline'>
网站数 {diff.active_website_count} →{' '}
{diff.current_website_count}
{t('websiteDelta', {
from: diff.active_website_count,
to: diff.current_website_count,
})}
</Badge>
</div>
@@ -150,13 +160,13 @@ export function DiffDialog({
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'>
参数
{t('optionKey')}
</TableHead>
<TableHead className='text-xs font-semibold'>
激活值
{t('activeValue')}
</TableHead>
<TableHead className='text-xs font-semibold'>
待发布值
{t('pendingValue')}
</TableHead>
</TableRow>
</TableHeader>
@@ -167,10 +177,14 @@ export function DiffDialog({
{item.key}
</TableCell>
<TableCell className='text-xs font-mono text-muted-foreground'>
{renderOptionValue(item.previous_value)}
{item.previous_value === ''
? t('emptyValue')
: item.previous_value}
</TableCell>
<TableCell className='text-xs font-mono text-muted-foreground'>
{renderOptionValue(item.current_value)}
{item.current_value === ''
? t('emptyValue')
: item.current_value}
</TableCell>
</TableRow>
))}
@@ -179,7 +193,7 @@ export function DiffDialog({
</div>
) : (
<p className='text-xs text-muted-foreground'>
当前无 OpenResty 参数变化。
{t('noOptionChanges')}
</p>
)}
</div>
@@ -1,6 +1,7 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -36,21 +37,20 @@ export function PreviewSheet({
canPublish,
onPublish,
}: PreviewSheetProps) {
const t = useTranslations('configVersions');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
<SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>发布预览</SheetTitle>
<SheetDescription>
查看待发布配置的渲染结果与支持文件。
</SheetDescription>
<SheetTitle>{t('previewTitle')}</SheetTitle>
<SheetDescription>{t('previewDesc')}</SheetDescription>
</SheetHeader>
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
{loading ? (
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
正在加载预览...
{t('loadingPreview')}
</div>
) : error ? (
<p className='text-sm text-destructive'>{error}</p>
@@ -58,10 +58,10 @@ export function PreviewSheet({
<div className='space-y-5 pb-4'>
<div className='flex flex-wrap gap-2'>
<Badge variant='outline' className='rounded-full text-[10px]'>
规则 {preview.route_count} 条
{t('routeCount', { count: preview.route_count })}
</Badge>
<Badge variant='outline' className='rounded-full text-[10px]'>
网站 {preview.website_count} 个
{t('websiteCount', { count: preview.website_count })}
</Badge>
<Badge
variant='outline'
@@ -72,7 +72,7 @@ export function PreviewSheet({
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>主配置</p>
<p className='text-xs font-semibold'>{t('mainConfig')}</p>
<CodeBlock
code={preview.main_config}
language='nginx'
@@ -81,7 +81,7 @@ export function PreviewSheet({
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>路由配置</p>
<p className='text-xs font-semibold'>{t('routeConfig')}</p>
<CodeBlock
code={preview.rendered_config}
language='nginx'
@@ -92,7 +92,9 @@ export function PreviewSheet({
{preview.support_files.length > 0 ? (
<div className='space-y-2'>
<p className='text-xs font-semibold'>
支持文件 ({preview.support_files.length})
{t('supportFiles', {
count: preview.support_files.length,
})}
</p>
{preview.support_files.map((file) => (
<details
@@ -112,13 +114,13 @@ export function PreviewSheet({
</div>
) : (
<p className='text-xs text-muted-foreground'>
当前发布不需要额外支持文件。
{t('noSupportFiles')}
</p>
)}
{!canPublish ? (
<p className='text-xs text-muted-foreground'>
当前规则与已激活版本一致,无法重复发布。
{t('noChanges')}
</p>
) : null}
</div>
@@ -131,7 +133,7 @@ export function PreviewSheet({
onClick={() => onOpenChange(false)}
disabled={publishing}
>
关闭
{t('close')}
</Button>
<Button
onClick={onPublish}
@@ -140,10 +142,10 @@ export function PreviewSheet({
{publishing ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
发布中...
{t('publishing')}
</>
) : (
'确认发布'
t('confirmPublish')
)}
</Button>
</SheetFooter>
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
@@ -33,6 +34,7 @@ export function VersionSnapshotSheet({
open,
onOpenChange,
}: VersionSnapshotSheetProps) {
const t = useTranslations('configVersions');
const [detail, setDetail] = useState<ConfigVersionDetail | null>(null);
const [loading, setLoading] = useState(false);
@@ -51,8 +53,9 @@ export function VersionSnapshotSheet({
})
.catch((err) => {
if (!cancelled) {
toast.error('加载版本快照失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('snapshotLoadFailed'), {
description:
err instanceof Error ? err.message : t('unknownError'),
});
}
})
@@ -63,32 +66,36 @@ export function VersionSnapshotSheet({
return () => {
cancelled = true;
};
}, [open, version]);
}, [open, version, t]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
<SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>
{version ? `版本 ${version.version}` : '版本快照'}
{version
? t('snapshotVersion', { version: version.version })
: t('snapshotTitle')}
</SheetTitle>
<SheetDescription>查看历史配置版本的完整快照内容。</SheetDescription>
<SheetDescription>{t('snapshotDesc')}</SheetDescription>
</SheetHeader>
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
{loading ? (
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' />
正在加载快照...
{t('loadingSnapshot')}
</div>
) : detail ? (
<div className='space-y-5 pb-4'>
<div className='flex flex-wrap gap-2'>
{detail.is_active ? (
<Badge className='rounded-full text-[10px]'>当前激活</Badge>
<Badge className='rounded-full text-[10px]'>
{t('active')}
</Badge>
) : (
<Badge variant='outline' className='rounded-full text-[10px]'>
历史版本
{t('history')}
</Badge>
)}
<Badge
@@ -101,11 +108,13 @@ export function VersionSnapshotSheet({
<div className='grid gap-3 sm:grid-cols-2 text-xs'>
<div>
<p className='text-muted-foreground'>创建人</p>
<p className='font-medium'>{detail.created_by || '系统'}</p>
<p className='text-muted-foreground'>{t('createdBy')}</p>
<p className='font-medium'>
{detail.created_by || t('system')}
</p>
</div>
<div>
<p className='text-muted-foreground'>创建时间</p>
<p className='text-muted-foreground'>{t('createdAt')}</p>
<p className='font-medium'>
{formatDateTime(detail.created_at)}
</p>
@@ -113,7 +122,7 @@ export function VersionSnapshotSheet({
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>主配置</p>
<p className='text-xs font-semibold'>{t('mainConfig')}</p>
<CodeBlock
code={detail.main_config}
language='nginx'
@@ -122,7 +131,7 @@ export function VersionSnapshotSheet({
</div>
<div className='space-y-2'>
<p className='text-xs font-semibold'>路由配置</p>
<p className='text-xs font-semibold'>{t('routeConfig')}</p>
<CodeBlock
code={detail.rendered_config}
language='nginx'
@@ -135,7 +144,7 @@ export function VersionSnapshotSheet({
<SheetFooter className='px-5 py-4 border-t'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
关闭
{t('close')}
</Button>
</SheetFooter>
</SheetContent>
+67 -48
View File
@@ -10,6 +10,7 @@ import {
RefreshCw,
Trash2,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -68,6 +69,8 @@ function hasConfigDiff(diff: ConfigDiffResult) {
}
export default function ConfigVersionsPage() {
const t = useTranslations('configVersions');
const tc = useTranslations('common');
const [versions, setVersions] = useState<ConfigVersionSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -121,11 +124,11 @@ export default function ConfigVersionsPage() {
const data = await ConfigVersionService.list();
setVersions(data);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
void fetchVersions();
@@ -145,7 +148,7 @@ export default function ConfigVersionsPage() {
setPreview(previewData);
setDiff(diffData);
} catch (err) {
const message = err instanceof Error ? err.message : '加载预览失败';
const message = err instanceof Error ? err.message : t('previewLoadFailed');
setPreviewError(message);
setDiffError(message);
} finally {
@@ -170,7 +173,7 @@ export default function ConfigVersionsPage() {
const diffData = await ConfigVersionService.diff();
setDiff(diffData);
} catch (err) {
setDiffError(err instanceof Error ? err.message : '加载差异失败');
setDiffError(err instanceof Error ? err.message : t('diffLoadFailed'));
} finally {
setDiffLoading(false);
}
@@ -181,7 +184,9 @@ export default function ConfigVersionsPage() {
setPublishing(true);
try {
const version = await ConfigVersionService.publish(force);
toast.success('发布成功', { description: `版本 ${version.version}` });
toast.success(t('publishSuccess'), {
description: t('publishSuccessDesc', { version: version.version }),
});
setPreviewOpen(false);
setPublishConfirmOpen(false);
setForcePublishConfirmOpen(false);
@@ -189,8 +194,8 @@ export default function ConfigVersionsPage() {
setDiff(null);
await fetchVersions();
} catch (err) {
toast.error('发布失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('publishFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setPublishing(false);
@@ -203,12 +208,14 @@ export default function ConfigVersionsPage() {
setActivating(true);
try {
const version = await ConfigVersionService.activate(activateTarget.id);
toast.success('激活成功', { description: `版本 ${version.version}` });
toast.success(t('activateSuccess'), {
description: t('activateSuccessDesc', { version: version.version }),
});
setActivateTarget(null);
await fetchVersions();
} catch (err) {
toast.error('激活失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('activateFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setActivating(false);
@@ -221,14 +228,14 @@ export default function ConfigVersionsPage() {
const result = await ConfigVersionService.cleanup({
keep_count: keepCount,
});
toast.success('清理完成', {
description: `已删除 ${result.deleted_count} 个历史快照`,
toast.success(t('cleanupDone'), {
description: t('cleanupDoneDesc', { count: result.deleted_count }),
});
setCleanupOpen(false);
await fetchVersions();
} catch (err) {
toast.error('清理失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('cleanupFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setCleaning(false);
@@ -241,10 +248,10 @@ export default function ConfigVersionsPage() {
<div className='flex items-center gap-2'>
<History className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>配置版本</h1>
<p className='text-sm text-muted-foreground'>
查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。
</p>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('description')}</p>
</div>
</div>
@@ -258,7 +265,7 @@ export default function ConfigVersionsPage() {
<RefreshCw
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
/>
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -266,7 +273,7 @@ export default function ConfigVersionsPage() {
onClick={() => setCleanupOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
清理旧版本
{t('cleanupOld')}
</Button>
<Button
variant='outline'
@@ -274,18 +281,18 @@ export default function ConfigVersionsPage() {
onClick={() => void handleOpenDiff()}
>
<GitCompare className='size-3.5 mr-1' />
查看差异
{t('viewDiff')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => setForcePublishConfirmOpen(true)}
>
强制发布
{t('forcePublish')}
</Button>
<Button size='sm' onClick={() => void handleOpenPreview()}>
<Eye className='size-3.5 mr-1' />
预览并发布
{t('previewPublish')}
</Button>
</div>
</div>
@@ -299,24 +306,30 @@ export default function ConfigVersionsPage() {
<LoadingStateWithBorder />
) : sortedVersions.length === 0 ? (
<EmptyStateWithBorder
title='暂无历史版本'
description='当前还没有可查看的发布记录,请先触发一次配置发布。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<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>
<TableHead className='text-xs font-semibold'>创建人</TableHead>
<TableHead className='text-xs font-semibold'>
Checksum
{t('colVersion')}
</TableHead>
<TableHead className='text-xs font-semibold'>
创建时间
{t('colStatus')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colAuthor')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChecksum')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colCreatedAt')}
</TableHead>
<TableHead className='text-xs font-semibold text-right'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -336,19 +349,19 @@ export default function ConfigVersionsPage() {
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' />
当前激活
{t('active')}
</Badge>
) : (
<Badge
variant='outline'
className='text-[10px] rounded-full py-0 px-2'
>
历史版本
{t('history')}
</Badge>
)}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{version.created_by || '系统'}
{version.created_by || t('system')}
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground'
@@ -371,7 +384,7 @@ export default function ConfigVersionsPage() {
}}
>
<Eye className='size-3 mr-1' />
快照
{t('snapshot')}
</Button>
{!version.is_active ? (
<Button
@@ -381,7 +394,7 @@ export default function ConfigVersionsPage() {
onClick={() => setActivateTarget(version)}
>
<Play className='size-3 mr-1' />
激活
{t('activate')}
</Button>
) : null}
</div>
@@ -431,13 +444,15 @@ export default function ConfigVersionsPage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
<AlertDialogTitle>{t('confirmPublishTitle')}</AlertDialogTitle>
<AlertDialogDescription>
将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。
{t('confirmPublishDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={publishing}>
{tc('cancel')}
</AlertDialogCancel>
<Button
onClick={() => void handlePublish(false)}
disabled={publishing}
@@ -445,7 +460,7 @@ export default function ConfigVersionsPage() {
{publishing ? (
<Loader2 className='size-4 animate-spin' />
) : (
'确认发布'
t('confirmPublish')
)}
</Button>
</AlertDialogFooter>
@@ -458,13 +473,15 @@ export default function ConfigVersionsPage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认强制发布</AlertDialogTitle>
<AlertDialogTitle>{t('confirmForceTitle')}</AlertDialogTitle>
<AlertDialogDescription>
将忽略配置变化检查并立即生成一个新版本,请确认你确实需要重新发布。
{t('confirmForceDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={publishing}>
{tc('cancel')}
</AlertDialogCancel>
<Button
variant='destructive'
onClick={() => void handlePublish(true)}
@@ -473,7 +490,7 @@ export default function ConfigVersionsPage() {
{publishing ? (
<Loader2 className='size-4 animate-spin' />
) : (
'强制发布'
t('forcePublish')
)}
</Button>
</AlertDialogFooter>
@@ -488,20 +505,22 @@ export default function ConfigVersionsPage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认激活版本</AlertDialogTitle>
<AlertDialogTitle>{t('confirmActivateTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{activateTarget
? `确认将版本 ${activateTarget.version} 设为当前激活版本吗?`
? t('confirmActivateDesc', { version: activateTarget.version })
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={activating}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={activating}>
{tc('cancel')}
</AlertDialogCancel>
<Button onClick={() => void handleActivate()} disabled={activating}>
{activating ? (
<Loader2 className='size-4 animate-spin' />
) : (
'确认激活'
t('confirmActivate')
)}
</Button>
</AlertDialogFooter>
+24 -17
View File
@@ -30,12 +30,16 @@ import type { DnsAccountItem } from '@/lib/services/openflare';
import { DnsAccountService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { DnsAccountCreateDialog } from '../websites/components/dns-account-create-dialog';
import { getErrorMessage } from '../websites/components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
export default function DnsAccountsPage() {
const t = useTranslations('dnsAccounts');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DnsAccountItem | null>(null);
@@ -48,11 +52,11 @@ export default function DnsAccountsPage() {
const deleteMutation = useMutation({
mutationFn: (id: number) => DnsAccountService.deleteById(id),
onSuccess: async () => {
toast.success('DNS 账号已删除');
toast.success(t('deleted'));
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: dnsAccountsQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const accounts = useMemo(
@@ -65,7 +69,7 @@ export default function DnsAccountsPage() {
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>DNS 账号</h1>
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
</div>
<div className='flex items-center gap-2'>
<Button
@@ -74,7 +78,7 @@ export default function DnsAccountsPage() {
onClick={() => setCreateOpen(true)}
>
<Plus className='size-3.5 mr-1' />
添加账号
{t('addAccount')}
</Button>
</div>
</div>
@@ -82,22 +86,23 @@ export default function DnsAccountsPage() {
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base font-semibold'>
DNS 账号列表
{t('listTitle')}
</CardTitle>
<CardDescription>
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
</CardDescription>
<CardDescription>{t('listDesc')}</CardDescription>
</CardHeader>
<CardContent>
{dnsAccountsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Cloud}
description='加载 DNS 账号中...'
description={t('loadingList')}
/>
) : dnsAccountsQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(dnsAccountsQuery.error)}
message={getErrorMessage(
dnsAccountsQuery.error,
t('requestFailed'),
)}
onRetry={() => void dnsAccountsQuery.refetch()}
className='justify-center'
/>
@@ -105,7 +110,7 @@ export default function DnsAccountsPage() {
) : accounts.length === 0 ? (
<EmptyStateWithBorder
icon={Cloud}
description='暂无 DNS 账号,点击右上角「添加账号」开始录入。'
description={t('emptyList')}
/>
) : (
<div className='space-y-3'>
@@ -122,7 +127,9 @@ export default function DnsAccountsPage() {
</span>
</p>
<p className='text-xs text-muted-foreground'>
创建于:{formatDateTime(account.created_at)}
{t('createdAt', {
date: formatDateTime(account.created_at),
})}
</p>
</div>
<Button
@@ -143,7 +150,7 @@ export default function DnsAccountsPage() {
<DnsAccountCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => toast.success('DNS 账号已添加')}
onCreated={() => toast.success(t('created'))}
/>
<AlertDialog
@@ -152,20 +159,20 @@ export default function DnsAccountsPage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 DNS 账号</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除 DNS 账号 {deleteTarget?.name} 吗?
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
删除
{tc('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+3 -1
View File
@@ -6,8 +6,10 @@ import { motion } from 'motion/react';
import { RequireAuth } from '@/components/auth/require-auth';
import { UserFileManager } from '@/components/common/user/file-manager';
import { FolderOpen } from 'lucide-react';
import { useTranslations } from 'next-intl';
export default function UserFilesPage() {
const t = useTranslations('files');
return (
<RequireAuth>
<motion.div
@@ -22,7 +24,7 @@ export default function UserFilesPage() {
<FolderOpen className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
我的文件
{t('myFiles')}
</h1>
</div>
</div>
+27 -25
View File
@@ -2,6 +2,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Network, Plus, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -46,6 +47,8 @@ import { IPGroupsTable } from '../waf/components/ip-groups-table';
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
export default function WafIPGroupsPage() {
const t = useTranslations('ipGroups');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<WAFIPGroup | null>(null);
@@ -90,25 +93,25 @@ export default function WafIPGroupsPage() {
return WafService.createIPGroup(payload);
},
onSuccess: async () => {
toast.success(editingGroup ? 'IP 组已更新' : 'IP 组已创建');
toast.success(editingGroup ? t('updated') : t('created'));
setEditingGroup(null);
setEditorOpen(false);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('operationFailed')));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => WafService.deleteIPGroup(id),
onSuccess: async () => {
toast.success('IP 组已删除');
toast.success(t('deleted'));
setDeleteTarget(null);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('operationFailed')));
},
});
@@ -118,11 +121,11 @@ export default function WafIPGroupsPage() {
setSyncingId(id);
},
onSuccess: async (result) => {
toast.success(result.message || '同步完成');
toast.success(result.message || t('synced'));
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('operationFailed')));
},
onSettled: () => {
setSyncingId(null);
@@ -147,12 +150,12 @@ export default function WafIPGroupsPage() {
setRemovingIp(ip);
},
onSuccess: async (updatedGroup) => {
toast.success('IP 已移除');
toast.success(t('ipRemoved'));
setViewingGroup(updatedGroup);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('operationFailed')));
},
onSettled: () => {
setRemovingIp(null);
@@ -164,19 +167,20 @@ export default function WafIPGroupsPage() {
WafService.testIPGroup({
auto_config: parseAutomaticConfig(
JSON.stringify(group.auto_config ?? {}, null, 2),
t('autoConfigMustBeObject'),
),
}),
onSuccess: (result) => {
setTestResult(result);
toast.success(
result.matched_count > 0
? `规则测试完成,命中 ${result.matched_count} 个 IP`
: '规则测试完成,当前未命中任何 IP',
? t('testHit', { count: result.matched_count })
: t('testMiss'),
);
},
onError: (error) => {
setTestResult(null);
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('operationFailed')));
},
});
@@ -219,7 +223,7 @@ export default function WafIPGroupsPage() {
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Network className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>IP 组</h1>
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
</div>
<div className='flex items-center gap-2'>
<Button
@@ -229,7 +233,7 @@ export default function WafIPGroupsPage() {
onClick={handleCreate}
>
<Plus className='size-3.5 mr-1' />
新建 IP 组
{t('create')}
</Button>
</div>
</div>
@@ -239,11 +243,9 @@ export default function WafIPGroupsPage() {
<div className='flex items-center justify-between gap-3'>
<div>
<CardTitle className='text-base font-semibold'>
IP 组列表
{t('listTitle')}
</CardTitle>
<CardDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</CardDescription>
<CardDescription>{t('listDesc')}</CardDescription>
</div>
<Button
variant='outline'
@@ -257,7 +259,7 @@ export default function WafIPGroupsPage() {
) : (
<RefreshCw className='size-3.5 mr-1' />
)}
刷新
{t('refresh')}
</Button>
</div>
</CardHeader>
@@ -265,12 +267,12 @@ export default function WafIPGroupsPage() {
{groupsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Network}
description='加载 IP 组中...'
description={t('loading')}
/>
) : groupsQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(groupsQuery.error)}
message={getErrorMessage(groupsQuery.error, t('operationFailed'))}
onRetry={handleRefresh}
className='justify-center'
/>
@@ -278,7 +280,7 @@ export default function WafIPGroupsPage() {
) : groups.length === 0 ? (
<EmptyStateWithBorder
icon={Network}
description='暂无 IP 组,请先创建一个。'
description={t('empty')}
/>
) : (
<IPGroupsTable
@@ -334,14 +336,14 @@ export default function WafIPGroupsPage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除 IP 组</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除 IP 组「{deleteTarget?.name}」吗?删除后无法恢复。
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tCommon('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
@@ -350,7 +352,7 @@ export default function WafIPGroupsPage() {
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLink, Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
@@ -40,6 +41,7 @@ export function AgentUpdateDialog({
channel: ReleaseChannel,
) => Promise<void>;
}) {
const t = useTranslations('nodes');
const [channel, setChannel] = useState<ReleaseChannel>('stable');
const releaseQuery = useQuery({
@@ -54,36 +56,42 @@ export function AgentUpdateDialog({
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-xl'>
<DialogHeader>
<DialogTitle>Agent 升级</DialogTitle>
<DialogDescription>
检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。
</DialogDescription>
<DialogTitle>{t('update.title')}</DialogTitle>
<DialogDescription>{t('update.description')}</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-3'>
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>当前版本</p>
<p className='text-xs text-muted-foreground'>
{t('update.currentVersion')}
</p>
<p className='mt-1 text-sm font-medium'>
{node.version || 'unknown'}
</p>
</div>
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>检查通道</p>
<p className='text-xs text-muted-foreground'>
{t('update.channel')}
</p>
<p className='mt-1 text-sm font-medium'>
{channel === 'preview' ? '预览版' : '正式版'}
{channel === 'preview'
? t('channel.preview')
: t('channel.stable')}
</p>
</div>
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>更新状态</p>
<p className='text-xs text-muted-foreground'>
{t('update.updateStatus')}
</p>
<div className='mt-1'>
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
? t('update.waitingPreview')
: t('update.waitingUpdate')
: t('update.notRequested')
}
tone={node.update_requested ? 'warning' : 'info'}
/>
@@ -94,45 +102,57 @@ export function AgentUpdateDialog({
{releaseQuery.isFetching ? (
<div className='flex items-center justify-center py-8 text-sm text-muted-foreground'>
<Loader2 className='size-4 mr-2 animate-spin' />
检查发布版本中...
{t('update.checking')}
</div>
) : releaseQuery.isError ? (
<p className='text-sm text-destructive'>
{getErrorMessage(releaseQuery.error)}
{getErrorMessage(releaseQuery.error, t('requestFailed'))}
</p>
) : release ? (
<div className='space-y-3 rounded-lg border p-4'>
<div className='flex flex-wrap items-center gap-2'>
<NodeStatusBadge
label={
release.has_update ? '发现可升级版本' : '当前已是最新版本'
release.has_update
? t('update.hasUpdate')
: t('update.upToDate')
}
tone={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<NodeStatusBadge label='Preview 发布' tone='warning' />
<NodeStatusBadge
label={t('update.previewRelease')}
tone='warning'
/>
) : (
<NodeStatusBadge label='正式发布' tone='info' />
<NodeStatusBadge
label={t('update.stableRelease')}
tone='info'
/>
)}
</div>
<div className='grid gap-3 sm:grid-cols-2 text-sm'>
<div>
<p className='text-xs text-muted-foreground'>目标版本</p>
<p className='text-xs text-muted-foreground'>
{t('update.targetVersion')}
</p>
<p className='mt-1 font-medium'>
{release.tag_name || '未找到'}
{release.tag_name || t('update.notFound')}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground'>发布时间</p>
<p className='text-xs text-muted-foreground'>
{t('update.publishedAt')}
</p>
<p className='mt-1'>
{release.published_at
? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
? `${formatRelativeTime(release.published_at, t)} · ${formatDateTime(release.published_at)}`
: '—'}
</p>
</div>
</div>
<p className='text-sm text-muted-foreground whitespace-pre-wrap'>
{release.body || '暂无更新说明'}
{release.body || t('update.noNotes')}
</p>
{release.html_url ? (
<a
@@ -141,7 +161,7 @@ export function AgentUpdateDialog({
rel='noreferrer'
className='inline-flex items-center text-sm text-primary hover:underline'
>
查看发布详情
{t('update.viewRelease')}
<ExternalLink className='size-3 ml-1' />
</a>
) : null}
@@ -156,7 +176,7 @@ export function AgentUpdateDialog({
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('stable')}
>
检查正式版
{t('update.checkStable')}
</Button>
<Button
type='button'
@@ -164,7 +184,7 @@ export function AgentUpdateDialog({
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('preview')}
>
检查预览版
{t('update.checkPreview')}
</Button>
<Button
type='button'
@@ -177,10 +197,10 @@ export function AgentUpdateDialog({
onClick={() => void onConfirm(release, channel)}
>
{submitting
? '下发中...'
? t('dispatching')
: channel === 'preview'
? '升级到预览版'
: '升级到正式版'}
? t('update.upgradePreview')
: t('update.upgradeStable')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -26,18 +28,23 @@ function formatDiskRate(bytesPerSecond: number) {
export function DiskIOTrendChart({
points,
title = '24 小时磁盘 IO 趋势',
description = '按小时展示磁盘读写速率(B/s),辅助判断日志放大、缓存抖动或磁盘压力。',
title,
description,
}: {
points: DiskIOTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('nodes.diskIo');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<CardDescription className='text-xs'>{description}</CardDescription>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -45,11 +52,11 @@ export function DiskIOTrendChart({
formatTrendHour(point.bucket_started_at),
)}
summaryScope='average'
summaryHint='近 24 小时 · 平均速率'
summaryHint={t('summaryHint')}
yAxisValueFormatter={formatDiskRate}
series={[
{
label: '磁盘读',
label: t('read'),
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
@@ -59,7 +66,7 @@ export function DiskIOTrendChart({
valueFormatter: formatDiskRate,
},
{
label: '磁盘写',
label: t('write'),
color: '#fb7185',
values: points.map((point) =>
diskBytesToRate(point.disk_write_bytes),
@@ -1,16 +1,20 @@
'use client';
import { useTranslations } from 'next-intl';
import { Progress } from '@/components/ui/progress';
import { formatCompactNumber } from '../../components/dashboard/dashboard-utils';
export function DistributionList({
items,
emptyMessage = '暂无分布数据',
emptyMessage,
}: {
items: Array<{ label: string; value: number }>;
emptyMessage?: string;
}) {
const t = useTranslations('nodes.obs');
const resolvedEmpty = emptyMessage ?? t('distEmpty');
const sortedItems = [...items]
.sort((left, right) => right.value - left.value)
.slice(0, 8);
@@ -19,7 +23,7 @@ export function DistributionList({
if (sortedItems.length === 0) {
return (
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
{emptyMessage}
{resolvedEmpty}
</div>
);
}
@@ -32,7 +36,7 @@ export function DistributionList({
<div key={item.label} className='space-y-1.5'>
<div className='flex items-center justify-between gap-3 text-xs'>
<span className='truncate font-medium'>
{item.label || '未知'}
{item.label || t('unknownLabel')}
</span>
<span className='shrink-0 font-mono tabular-nums text-muted-foreground'>
{formatCompactNumber(item.value)}
@@ -15,6 +15,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -62,6 +63,8 @@ import {
const nodesQueryKey = ['openflare', 'nodes'];
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
@@ -72,29 +75,29 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('节点已更新');
toast.success(t('edge.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发强制同步指令`);
toast.success(t('edge.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const restartMutation = useMutation({
mutationFn: () => NodeService.requestOpenrestyRestart(node.id),
onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发 OpenResty 重启指令`);
toast.success(t('edge.toastRestart', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
@@ -114,22 +117,28 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
}),
onSuccess: async (updated) => {
toast.success(
`已向节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
t('edge.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('节点已删除');
toast.success(t('edge.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
@@ -149,7 +158,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
className='h-8'
onClick={() => setEditorOpen(true)}
>
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -158,7 +167,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -168,7 +177,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='outline'
@@ -177,7 +186,9 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
disabled={restartMutation.isPending}
onClick={() => restartMutation.mutate()}
>
{restartMutation.isPending ? '下发中...' : '重启 OpenResty'}
{restartMutation.isPending
? t('dispatching')
: t('edge.restartOpenresty')}
</Button>
<Button
variant='secondary'
@@ -186,7 +197,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 Agent'}
{node.update_requested ? t('viewUpgrade') : t('edge.upgrade')}
</Button>
<Button
variant='outline'
@@ -195,7 +206,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
删除
{t('delete')}
</Button>
</>
);
@@ -206,74 +217,74 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title='运行状态'
description='节点在线情况与 OpenResty 健康'
title={t('edge.runStatus')}
description={t('edge.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='运行状态'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label='OpenResty 健康'>
<NodeInfoRow label={t('edge.openrestyHealth')}>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近心跳'>
<NodeInfoRow label={t('edge.lastSeen')}>
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='IP 地址'>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
<NodeInfoRow label='地图点位'>
{node.geo_name || '未配置'}
<NodeInfoRow label={t('edge.geo')}>
{node.geo_name || t('notConfigured')}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title='配置同步'
description='版本追平与应用结果摘要'
title={t('edge.syncTitle')}
description={t('edge.syncDesc')}
action={
<Button variant='outline' size='sm' className='h-8' asChild>
<Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
>
<FileText className='size-3.5 mr-1.5' />
应用记录
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label='当前配置版本'>
{node.current_version || '未应用'}
<NodeInfoRow label={t('edge.currentVersion')}>
{node.current_version || t('notApplied')}
</NodeInfoRow>
<NodeInfoRow label='最近应用'>
<NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近应用时间'>
<NodeInfoRow label={t('edge.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='Agent 版本'>
<NodeInfoRow label={t('edge.agentVersion')}>
{node.version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label='Nginx 版本'>
<NodeInfoRow label={t('edge.nginxVersion')}>
{node.ext_version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label='自动更新'>
{node.auto_update_enabled ? '已启用' : '手动'}
<NodeInfoRow label={t('edge.autoUpdate')}>
{node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow>
</div>
</NodeSectionCard>
@@ -282,22 +293,22 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='edge' />
<NodeSectionCard
title='运行消息'
description='OpenResty 与 Agent 上报的状态说明'
title={t('edge.runtimeMessages')}
description={t('edge.runtimeMessagesDesc')}
>
<div className='space-y-4 text-sm'>
<div>
<p className='mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground'>
OpenResty 状态消息
{t('edge.openrestyMessage')}
</p>
<p className='rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap'>
{node.openresty_message || '当前未上报额外错误。'}
{node.openresty_message || t('edge.noExtraError')}
</p>
</div>
{node.restart_openresty_requested ? (
<div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'>
<Activity className='size-4' />
已等待节点在下一次心跳后执行 OpenResty 重启。
{t('edge.restartPending')}
</div>
) : null}
</div>
@@ -313,28 +324,32 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
typeTone='info'
statusBadges={[
{
label: getNodeStatusLabel(node.status),
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getOpenrestyStatusLabel(node.openresty_status),
label: getOpenrestyStatusLabel(node.openresty_status, t),
tone: getOpenrestyStatusTone(node.openresty_status),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: 'Agent 版本', value: node.version || 'unknown', icon: Cpu },
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: '当前配置',
value: node.current_version || '未应用',
label: t('edge.kpiAgent'),
value: node.version || 'unknown',
icon: Cpu,
},
{
label: t('kpiCurrentConfig'),
value: node.current_version || t('notApplied'),
icon: Package,
},
{
label: '最近心跳',
label: t('kpiLastSeen'),
value: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
@@ -343,7 +358,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
nodeId={node.id}
node={node}
variant='edge'
connectionHint='OpenResty 当前连接'
connectionHint={t('edge.connectionHint')}
/>
}
manage={manageTab}
@@ -373,22 +388,21 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
{t('deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Copy } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -28,38 +29,6 @@ import {
type InstallVariant = 'edge' | 'relay' | 'tunnel';
const variantMeta: Record<
InstallVariant,
{
title: string;
description: string;
tokenLabel: string;
scriptLabel?: string;
dockerLabel: string;
}
> = {
edge: {
title: '边缘节点部署',
description: '使用 Agent Token 将边缘节点接入控制端。',
tokenLabel: 'Agent Token',
dockerLabel: 'Docker 容器部署',
},
relay: {
title: '中继部署命令',
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
tokenLabel: 'Discovery Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
tunnel: {
title: '隧道部署命令',
description: '使用 Tunnel Token 将 openflared 客户端接入控制端。',
tokenLabel: 'Tunnel Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
};
export function InstallCommand({
node,
variant,
@@ -67,7 +36,40 @@ export function InstallCommand({
node: NodeItem;
variant: InstallVariant;
}) {
const t = useTranslations('nodes.install');
const tn = useTranslations('nodes');
const [serverUrl, setServerUrl] = useState('');
const variantMeta: Record<
InstallVariant,
{
title: string;
description: string;
tokenLabel: string;
scriptLabel?: string;
dockerLabel: string;
}
> = {
edge: {
title: t('edgeTitle'),
description: t('edgeDesc'),
tokenLabel: t('agentToken'),
dockerLabel: t('dockerLabel'),
},
relay: {
title: t('relayTitle'),
description: t('relayDesc'),
tokenLabel: t('discoveryToken'),
scriptLabel: t('scriptLabel'),
dockerLabel: t('dockerLabel'),
},
tunnel: {
title: t('tunnelTitle'),
description: t('tunnelDesc'),
tokenLabel: t('tunnelToken'),
scriptLabel: t('scriptLabel'),
dockerLabel: t('dockerLabel'),
},
};
const meta = variantMeta[variant];
const statusQuery = useQuery({
@@ -123,7 +125,7 @@ export function InstallCommand({
await navigator.clipboard.writeText(value);
toast.success(message);
} catch {
toast.error('复制失败,请手动选择命令文本。');
toast.error(t('copyFailed'));
}
};
@@ -143,11 +145,11 @@ export function InstallCommand({
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(scriptCommand, '部署脚本命令已复制')
void handleCopy(scriptCommand, t('copiedScript'))
}
>
<Copy className='size-3.5 mr-1' />
复制脚本
{t('copyScript')}
</Button>
) : null}
{dockerCommand ? (
@@ -156,11 +158,11 @@ export function InstallCommand({
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(dockerCommand, 'Docker 部署命令已复制')
void handleCopy(dockerCommand, t('copiedDocker'))
}
>
<Copy className='size-3.5 mr-1' />
复制 Docker
{t('copyDocker')}
</Button>
) : null}
</div>
@@ -168,20 +170,20 @@ export function InstallCommand({
<CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground'>Node ID</p>
<p className='text-xs text-muted-foreground'>{t('nodeId')}</p>
<p className='mt-1 text-sm break-all font-medium'>{node.node_id}</p>
</div>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground'>{meta.tokenLabel}</p>
<p className='mt-1 text-sm break-all font-medium'>
{node.access_token || '暂无'}
{node.access_token || tn('na')}
</p>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor={`server-url-${variant}`}>
Server URL(控制端地址)
{t('serverUrl')}
</Label>
<Input
id={`server-url-${variant}`}
@@ -190,17 +192,17 @@ export function InstallCommand({
placeholder='https://openflare.example.com'
/>
<p className='text-xs text-muted-foreground'>
部署脚本和容器将使用此地址连接 OpenFlare 控制端。
{t('serverUrlHint')}
</p>
</div>
{!node.access_token ? (
<p className='text-sm text-muted-foreground'>
节点尚未生成接入 Token,请稍后刷新。
{t('noToken')}
</p>
) : !normalizedServerUrl ? (
<p className='text-sm text-muted-foreground'>
请填写有效的 Server URL 以生成部署命令。
{t('needServerUrl')}
</p>
) : (
<>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -17,18 +19,23 @@ import {
export function NetworkTrendChart({
points,
title = '24 小时网络趋势',
description = '按小时展示已提供/接收数据(访问日志);摘要为近 24 小时总量。',
title,
description,
}: {
points: NetworkTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('nodes.networkTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<CardDescription className='text-xs'>{description}</CardDescription>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -36,11 +43,11 @@ export function NetworkTrendChart({
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint='近 24 小时'
summaryHint={t('summaryHint')}
yAxisValueFormatter={formatBytes}
series={[
{
label: '接收数据',
label: t('received'),
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
@@ -48,7 +55,7 @@ export function NetworkTrendChart({
valueFormatter: formatBytes,
},
{
label: '已提供数据',
label: t('provided'),
color: '#38bdf8',
values: points.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { type ReactNode, useCallback, useMemo } from 'react';
import type { LucideIcon } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -28,16 +29,7 @@ export type NodeDetailKpi = {
icon?: LucideIcon;
};
const TAB_CONFIGS: NodeDetailTabConfig[] = [
{
id: 'overview',
label: '概览',
},
{
id: 'manage',
label: '状态与部署',
},
];
const TAB_IDS: NodeDetailTabId[] = ['overview', 'manage'];
function resolveTab(value: string | null): NodeDetailTabId | null {
if (value === 'overview' || value === 'dashboard') {
@@ -70,6 +62,7 @@ export function NodeDetailShell({
manage: ReactNode;
defaultTab?: NodeDetailTabId;
}) {
const t = useTranslations('nodes');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -110,7 +103,7 @@ export function NodeDetailShell({
className='mt-0.5 h-8 w-8 shrink-0 p-0'
asChild
>
<Link href='/nodes' aria-label='返回节点列表'>
<Link href='/nodes' aria-label={t('backToList')}>
<ArrowLeft className='size-4' />
</Link>
</Button>
@@ -162,16 +155,16 @@ export function NodeDetailShell({
variant='line'
className='h-auto w-full justify-start gap-6 bg-transparent p-0'
>
{TAB_CONFIGS.map((tab) => (
{TAB_IDS.map((tabId) => (
<TabsTrigger
key={tab.id}
value={tab.id}
key={tabId}
value={tabId}
className={cn(
'h-auto flex-none rounded-none px-0 pb-3 pt-1 text-sm font-semibold',
'data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground',
)}
>
{tab.label}
{tabId === 'overview' ? t('tabOverview') : t('tabManage')}
</TabsTrigger>
))}
</TabsList>
@@ -2,6 +2,7 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -30,49 +31,51 @@ import type {
NodeType,
} from '@/lib/services/openflare';
const nodeSchema = z
.object({
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z.string().trim().min(1, '请输入节点名称').max(255),
ip: z.string(),
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_name: z.string(),
geo_latitude: z.string(),
geo_longitude: z.string(),
relay_bind_port: z.string(),
relay_vhost_http_port: z.string(),
})
.superRefine((value, context) => {
if (value.ip_manual_override && !value.ip.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['ip'],
message: '锁定 IP 时必须填写节点 IP',
});
}
if (value.node_type === 'tunnel_relay') {
const bindPort = Number(value.relay_bind_port);
const vhostPort = Number(value.relay_vhost_http_port);
if (!Number.isFinite(bindPort) || bindPort <= 0) {
function buildNodeSchema(t: (key: string) => string) {
return z
.object({
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z.string().trim().min(1, t('editor.errName')).max(255),
ip: z.string(),
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_name: z.string(),
geo_latitude: z.string(),
geo_longitude: z.string(),
relay_bind_port: z.string(),
relay_vhost_http_port: z.string(),
})
.superRefine((value, context) => {
if (value.ip_manual_override && !value.ip.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_bind_port'],
message: '请输入有效的绑定端口',
path: ['ip'],
message: t('editor.errLockIp'),
});
}
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: '请输入有效的 VHost 端口',
});
if (value.node_type === 'tunnel_relay') {
const bindPort = Number(value.relay_bind_port);
const vhostPort = Number(value.relay_vhost_http_port);
if (!Number.isFinite(bindPort) || bindPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_bind_port'],
message: t('editor.errBindPort'),
});
}
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: t('editor.errVhostPort'),
});
}
}
}
});
});
}
type NodeFormValues = z.infer<typeof nodeSchema>;
type NodeFormValues = z.infer<ReturnType<typeof buildNodeSchema>>;
const defaultForm: NodeFormValues = {
node_type: 'edge_node',
@@ -156,8 +159,10 @@ export function NodeEditorDialog({
onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
}) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
resolver: zodResolver(buildNodeSchema(t)),
defaultValues: defaultForm,
});
@@ -179,10 +184,10 @@ export function NodeEditorDialog({
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
<DialogDescription>
预创建节点后可在详情页查看专属 Token 与部署命令。
</DialogDescription>
<DialogTitle>
{node ? t('editor.editTitle') : t('editor.createTitle')}
</DialogTitle>
<DialogDescription>{t('editor.description')}</DialogDescription>
</DialogHeader>
<form
@@ -190,7 +195,7 @@ export function NodeEditorDialog({
className='space-y-4'
>
<div className='space-y-2'>
<Label>节点类型</Label>
<Label>{t('editor.nodeType')}</Label>
<Select
value={nodeType}
disabled={Boolean(node)}
@@ -202,18 +207,22 @@ export function NodeEditorDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='edge_node'>Edge 节点</SelectItem>
<SelectItem value='tunnel_relay'>Relay 节点</SelectItem>
<SelectItem value='tunnel_client'>Tunnel 节点</SelectItem>
<SelectItem value='edge_node'>{t('editor.typeEdge')}</SelectItem>
<SelectItem value='tunnel_relay'>
{t('editor.typeRelay')}
</SelectItem>
<SelectItem value='tunnel_client'>
{t('editor.typeTunnel')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='node-name'>节点名称</Label>
<Label htmlFor='node-name'>{t('editor.name')}</Label>
<Input
id='node-name'
placeholder='例如 edge-hk-01'
placeholder={t('editor.namePlaceholder')}
{...form.register('name')}
/>
{form.formState.errors.name ? (
@@ -224,10 +233,10 @@ export function NodeEditorDialog({
</div>
<div className='space-y-2'>
<Label htmlFor='node-ip'>节点 IP</Label>
<Label htmlFor='node-ip'>{t('editor.ip')}</Label>
<Input
id='node-ip'
placeholder='可选,接入后自动上报'
placeholder={t('editor.ipPlaceholder')}
{...form.register('ip')}
/>
{form.formState.errors.ip ? (
@@ -239,9 +248,9 @@ export function NodeEditorDialog({
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>锁定 IP</p>
<p className='text-sm font-medium'>{t('editor.lockIp')}</p>
<p className='text-xs text-muted-foreground'>
启用后管理端不再自动覆盖 IP
{t('editor.lockIpDesc')}
</p>
</div>
<Switch
@@ -254,9 +263,9 @@ export function NodeEditorDialog({
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>自动更新 Agent</p>
<p className='text-sm font-medium'>{t('editor.autoUpdate')}</p>
<p className='text-xs text-muted-foreground'>
启用后节点将自动拉取正式版更新
{t('editor.autoUpdateDesc')}
</p>
</div>
<Switch
@@ -270,7 +279,7 @@ export function NodeEditorDialog({
{nodeType === 'tunnel_relay' ? (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='relay-bind-port'>绑定端口</Label>
<Label htmlFor='relay-bind-port'>{t('editor.bindPort')}</Label>
<Input
id='relay-bind-port'
{...form.register('relay_bind_port')}
@@ -282,7 +291,9 @@ export function NodeEditorDialog({
) : null}
</div>
<div className='space-y-2'>
<Label htmlFor='relay-vhost-port'>VHost 端口</Label>
<Label htmlFor='relay-vhost-port'>
{t('editor.vhostPort')}
</Label>
<Input
id='relay-vhost-port'
{...form.register('relay_vhost_http_port')}
@@ -298,8 +309,10 @@ export function NodeEditorDialog({
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>手动地图点位</p>
<p className='text-xs text-muted-foreground'>用于总览地图展示</p>
<p className='text-sm font-medium'>{t('editor.manualGeo')}</p>
<p className='text-xs text-muted-foreground'>
{t('editor.manualGeoDesc')}
</p>
</div>
<Switch
checked={geoManualOverride}
@@ -312,15 +325,15 @@ export function NodeEditorDialog({
{geoManualOverride ? (
<div className='grid gap-3 sm:grid-cols-3'>
<div className='space-y-2 sm:col-span-3'>
<Label htmlFor='geo-name'>位置名称</Label>
<Label htmlFor='geo-name'>{t('editor.geoName')}</Label>
<Input id='geo-name' {...form.register('geo_name')} />
</div>
<div className='space-y-2'>
<Label htmlFor='geo-lat'>纬度</Label>
<Label htmlFor='geo-lat'>{t('editor.latitude')}</Label>
<Input id='geo-lat' {...form.register('geo_latitude')} />
</div>
<div className='space-y-2 sm:col-span-2'>
<Label htmlFor='geo-lng'>经度</Label>
<Label htmlFor='geo-lng'>{t('editor.longitude')}</Label>
<Input id='geo-lng' {...form.register('geo_longitude')} />
</div>
</div>
@@ -333,10 +346,14 @@ export function NodeEditorDialog({
onClick={onClose}
disabled={submitting}
>
取消
{tc('cancel')}
</Button>
<Button type='submit' disabled={submitting}>
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
{submitting
? t('editor.saving')
: node
? t('editor.saveEdit')
: t('editor.createAction')}
</Button>
</DialogFooter>
</form>
@@ -3,6 +3,7 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -120,18 +121,19 @@ function SystemProfileCard({
profile: NodeSystemProfile;
nodeName: string;
}) {
const t = useTranslations('nodes');
return (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-4 rounded-lg border px-4 py-4'>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
主机名
{t('obs.hostname')}
</p>
<p className='mt-2 text-sm'>{profile.hostname || nodeName}</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
操作系统
{t('obs.os')}
</p>
<p className='mt-2 text-sm'>
{profile.os_name || 'unknown'}
@@ -140,7 +142,7 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
内核 / 架构
{t('obs.kernelArch')}
</p>
<p className='mt-2 text-sm'>
{profile.kernel_version || 'unknown'} ·{' '}
@@ -149,25 +151,27 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
在线时长
{t('obs.uptime')}
</p>
<p className='mt-2 text-sm'>
{formatUptime(profile.uptime_seconds, t)}
</p>
<p className='mt-2 text-sm'>{formatUptime(profile.uptime_seconds)}</p>
</div>
</div>
<div className='space-y-4 rounded-lg border px-4 py-4'>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
CPU
{t('obs.cpu')}
</p>
<p className='mt-2 text-sm'>{profile.cpu_model || 'unknown'}</p>
<p className='mt-1 text-xs text-muted-foreground'>
{profile.cpu_cores || 0} 核
{t('obs.cpuCores', { count: profile.cpu_cores || 0 })}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
总内存
{t('obs.totalMemory')}
</p>
<p className='mt-2 text-sm'>
{formatBytes(profile.total_memory_bytes)}
@@ -175,7 +179,7 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
总存储
{t('obs.totalStorage')}
</p>
<p className='mt-2 text-sm'>
{formatBytes(profile.total_disk_bytes)}
@@ -183,7 +187,7 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
上报时间
{t('obs.reportedAt')}
</p>
<p className='mt-2 text-sm'>
{isMeaningfulTime(profile.reported_at)
@@ -211,16 +215,15 @@ function HealthEventTimeline({
onCleanup: () => void;
cleanupPending: boolean;
}) {
const t = useTranslations('nodes');
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex-row items-center justify-between space-y-0'>
<div>
<CardTitle className='text-base font-semibold'>
健康事件时间线
{t('obs.eventsTitle')}
</CardTitle>
<CardDescription>
保留活动与已恢复事件,帮助判断运行状态。
</CardDescription>
<CardDescription>{t('obs.eventsDesc')}</CardDescription>
</div>
<Button
variant='outline'
@@ -230,7 +233,7 @@ function HealthEventTimeline({
onClick={onCleanup}
>
<Trash2 className='size-3.5 mr-1' />
清理日志
{t('obs.cleanupLogs')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
@@ -239,9 +242,9 @@ function HealthEventTimeline({
<div className='flex flex-wrap gap-2'>
{(
[
['all', '全部事件'],
['active', '活动中'],
['resolved', '已恢复'],
['all', t('obs.filterAll')],
['active', t('obs.filterActive')],
['resolved', t('obs.filterResolved')],
] as const
).map(([filter, label]) => (
<Button
@@ -261,7 +264,7 @@ function HealthEventTimeline({
{events.length === 0 ? (
<p className='text-sm text-muted-foreground'>
当前筛选下没有健康事件。
{t('obs.filterEmpty')}
</p>
) : null}
@@ -276,28 +279,32 @@ function HealthEventTimeline({
tone={getHealthEventTone(event)}
/>
<NodeStatusBadge
label={event.status === 'active' ? '活动中' : '已恢复'}
label={
event.status === 'active'
? t('obs.filterActive')
: t('obs.filterResolved')
}
tone={event.status === 'active' ? 'warning' : 'success'}
/>
</div>
<p className='mt-2 text-sm text-muted-foreground'>
{event.message || '暂无详细消息'}
{event.message || t('obs.noMessage')}
</p>
<div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'>
<p>
首次触发:
{t('obs.firstTriggered')}
{isMeaningfulTime(event.first_triggered_at)
? ` ${formatDateTime(event.first_triggered_at)}`
: ' —'}
</p>
<p>
最近触发:
{t('obs.lastTriggered')}
{isMeaningfulTime(event.last_triggered_at)
? ` ${formatDateTime(event.last_triggered_at)}`
: ' —'}
</p>
<p>
恢复时间:
{t('obs.resolvedAt')}
{isMeaningfulTime(event.resolved_at) && event.resolved_at
? ` ${formatDateTime(event.resolved_at)}`
: ' —'}
@@ -308,7 +315,7 @@ function HealthEventTimeline({
</>
) : (
<p className='text-sm text-muted-foreground'>
节点当前还没有上报健康事件记录。
{t('obs.noEvents')}
</p>
)}
</CardContent>
@@ -320,13 +327,16 @@ export function NodeObservability({
nodeId,
node,
variant = 'edge',
connectionHint = 'OpenResty 当前连接',
connectionHint,
}: {
nodeId: number;
node?: NodeItem;
variant?: NodeObservabilityVariant;
connectionHint?: string;
}) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const resolvedConnectionHint = connectionHint ?? t('edge.connectionHint');
const queryClient = useQueryClient();
const [healthEventFilter, setHealthEventFilter] =
useState<HealthEventFilter>('all');
@@ -344,8 +354,8 @@ export function NodeObservability({
onSuccess: async (result) => {
toast.success(
result.deleted_count > 0
? `已清理 ${result.deleted_count} 条健康事件日志`
: '当前没有可清理的健康事件日志',
? t('obs.cleaned', { count: result.deleted_count })
: t('obs.nothingToClean'),
);
setCleanupOpen(false);
await Promise.all([
@@ -357,7 +367,7 @@ export function NodeObservability({
}),
]);
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const observability = observabilityQuery.data ?? null;
@@ -432,7 +442,7 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardContent className='flex items-center justify-center py-10 text-sm text-muted-foreground'>
<Loader2 className='size-4 mr-2 animate-spin' />
加载运行观测数据中...
{t('obs.loading')}
</CardContent>
</Card>
);
@@ -443,7 +453,7 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardContent className='py-6'>
<p className='text-sm text-destructive'>
{getErrorMessage(observabilityQuery.error)}
{getErrorMessage(observabilityQuery.error, t('requestFailed'))}
</p>
</CardContent>
</Card>
@@ -455,20 +465,20 @@ export function NodeObservability({
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-3'>
<SummaryStat
label='运行诊断'
label={t('obs.diag')}
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '运行稳定'
? t('obs.activeIssues', { count: activeHealthEvents.length })
: t('obs.stable')
}
hint={
latestHealthEvent
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
: t('obs.noActiveEvents')
}
/>
<SummaryStat
label='系统核心'
label={t('obs.systemCore')}
value={profile?.hostname || '—'}
hint={
profile
@@ -477,9 +487,9 @@ export function NodeObservability({
}
/>
<SummaryStat
label='在线时长'
value={formatUptime(profile?.uptime_seconds)}
hint='来自节点系统画像上报'
label={t('obs.uptime')}
value={formatUptime(profile?.uptime_seconds, t)}
hint={t('obs.uptimeFromProfile')}
/>
</div>
@@ -487,16 +497,16 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
系统画像
{t('obs.profileTitle')}
</CardTitle>
<CardDescription>节点上报的主机与硬件信息</CardDescription>
<CardDescription>{t('obs.profileDesc')}</CardDescription>
</CardHeader>
<CardContent>
{profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} />
) : (
<p className='text-sm text-muted-foreground'>
节点已接入,但尚未上报完整系统画像。
{t('obs.noProfile')}
</p>
)}
</CardContent>
@@ -505,45 +515,50 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
实时资源快照
{t('obs.snapshotTitle')}
</CardTitle>
<CardDescription>最近一次 metrics 上报</CardDescription>
<CardDescription>{t('obs.snapshotDesc')}</CardDescription>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label='CPU'
label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetric.captured_at)
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
? t('obs.snapshotAgo', {
time: formatRelativeTime(
latestMetric.captured_at,
t,
),
})
: undefined
}
/>
<MetricBar
label='内存'
label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label='存储'
label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label='连接数'
label={t('obs.connections')}
value={formatMetricCount(
latestMetric.openresty_connections,
)}
progress={null}
hint={connectionHint}
hint={resolvedConnectionHint}
/>
</div>
) : (
<p className='text-sm text-muted-foreground'>
节点已接入,但尚未上报资源快照。
{t('obs.noSnapshot')}
</p>
)}
</CardContent>
@@ -562,21 +577,23 @@ export function NodeObservability({
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
{t('obs.cleanupDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
{cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -589,43 +606,57 @@ export function NodeObservability({
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<SummaryStat
label='运行诊断'
label={t('obs.diag')}
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '运行稳定'
? t('obs.activeIssues', { count: activeHealthEvents.length })
: t('obs.stable')
}
hint={
latestHealthEvent
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
: t('obs.noActiveEvents')
}
/>
<SummaryStat
label='查询窗口请求'
label={t('obs.windowRequests')}
value={formatMetricCount(trafficSummary?.request_count)}
hint={
trafficSummary
? `查询窗口独立访客 ${formatMetricCount(trafficSummary.unique_visitor_count)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
? t('obs.windowVisitorsHint', {
visitors: formatMetricCount(
trafficSummary.unique_visitor_count,
),
rate: trafficSummary.error_rate_percent.toFixed(1),
})
: t('obs.noWindowSummary')
}
/>
<SummaryStat
label='容量压力'
value={healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'}
label={t('obs.capacityPressure')}
value={
healthSummary?.has_capacity_risk
? t('obs.needsAttention')
: t('obs.normalRange')
}
hint={
latestMetric
? `CPU ${formatPercent(latestMetric.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
: '当前没有资源快照'
? t('obs.cpuStorage', {
cpu: formatPercent(latestMetric.cpu_usage_percent),
storage: formatPercent(storageUsageRatio),
})
: t('obs.noResourceSnapshot')
}
/>
<SummaryStat
label='来源信号'
label={t('obs.sourceSignal')}
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
: '当前没有来源分布数据'
? t('obs.requestTimes', {
count: topSourceCountry.value.toLocaleString('zh-CN'),
})
: t('obs.noSourceDist')
}
/>
</div>
@@ -633,14 +664,16 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-3'>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>系统信息</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('obs.systemInfo')}
</CardTitle>
</CardHeader>
<CardContent>
{profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} />
) : (
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报完整系统画像。
{t('obs.noProfileLong')}
</p>
)}
</CardContent>
@@ -648,41 +681,45 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>实时资源</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('obs.realtimeResources')}
</CardTitle>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label='CPU'
label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetric.captured_at)
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
? t('obs.snapshotAgo', {
time: formatRelativeTime(latestMetric.captured_at, t),
})
: undefined
}
/>
<MetricBar
label='内存'
label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label='存储'
label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label='连接数'
label={t('obs.connections')}
value={formatMetricCount(latestMetric.openresty_connections)}
progress={null}
hint={connectionHint}
hint={resolvedConnectionHint}
/>
</div>
) : (
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报资源快照。
{t('obs.noSnapshotLong')}
</p>
)}
</CardContent>
@@ -690,7 +727,9 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>网络流量</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('obs.networkTraffic')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{latestMetric ? (
@@ -698,18 +737,20 @@ export function NodeObservability({
{node ? (
<div className='flex flex-wrap gap-2'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
<NodeStatusBadge
label={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '无活动异常'
? t('obs.activeIssues', {
count: activeHealthEvents.length,
})
: t('obs.noActiveIssue')
}
tone={activeHealthEvents.length ? 'warning' : 'success'}
/>
@@ -719,16 +760,14 @@ export function NodeObservability({
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3 sm:col-span-2'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
OpenResty 连接
{t('obs.openrestyConn')}
</p>
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
<p>
当前连接:
{t('obs.currentConn')}
{latestMetric.openresty_connections ?? 0}
</p>
<p className='text-xs'>
业务吞吐请看访问日志趋势(已提供/接收数据)
</p>
<p className='text-xs'>{t('obs.throughputHint')}</p>
</div>
</div>
</div>
@@ -736,35 +775,42 @@ export function NodeObservability({
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
查询窗口独立访客
{t('obs.windowVisitors')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.unique_visitor_count)}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? `请求 ${formatMetricCount(trafficSummary.request_count)} · 估算 QPS ${trafficSummary.estimated_qps.toFixed(2)}`
: '暂无窗口流量摘要'}
? t('obs.reqQps', {
requests: formatMetricCount(
trafficSummary.request_count,
),
qps: trafficSummary.estimated_qps.toFixed(2),
})
: t('obs.noTrafficSummary')}
</p>
</div>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
查询窗口错误
{t('obs.windowErrors')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.error_count)}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '暂无错误率摘要'}
? t('obs.errorRate', {
rate: trafficSummary.error_rate_percent.toFixed(1),
})
: t('obs.noErrorSummary')}
</p>
</div>
</div>
</>
) : (
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报网络流量相关快照。
{t('obs.noNetworkSnapshot')}
</p>
)}
</CardContent>
@@ -774,11 +820,11 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-2'>
<TrafficTrendChart
points={trends?.traffic_24h ?? []}
description='按小时聚合该节点的请求量和错误量。'
description={t('obs.trafficTrendDesc')}
/>
<CapacityTrendChart
points={trends?.capacity_24h ?? []}
description='观察该节点 CPU 与内存使用率在 24 小时内的变化。'
description={t('obs.capacityTrendDesc')}
/>
</div>
@@ -791,50 +837,47 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
请求结构分布
{t('obs.structureTitle')}
</CardTitle>
<CardDescription>
聚合最近 24
小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
</CardDescription>
<CardDescription>{t('obs.structureDesc')}</CardDescription>
</CardHeader>
<CardContent>
<div className='mb-6 grid gap-4 md:grid-cols-3'>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
主状态码
{t('obs.mainStatus')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{dominantStatusCode?.label ?? '—'}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{dominantStatusCode
? `${dominantStatusCode.value} 次`
: '暂无状态码分布'}
? t('obs.times', { count: dominantStatusCode.value })
: t('obs.noStatusDist')}
</p>
</div>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
Top Domain
{t('obs.topDomain')}
</p>
<p className='mt-3 truncate text-2xl font-semibold'>
{dominantDomain?.label ?? '—'}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{dominantDomain
? `${dominantDomain.value} 次`
: '暂无域名分布'}
? t('obs.times', { count: dominantDomain.value })
: t('obs.noDomainDist')}
</p>
</div>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
已恢复事件
{t('obs.resolvedEvents')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{resolvedHealthEvents.length}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
最近 24 小时已恢复健康事件
{t('obs.resolvedHint')}
</p>
</div>
</div>
@@ -842,20 +885,20 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-2'>
<div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
状态码分布
{t('obs.statusDist')}
</p>
<DistributionList
items={statusCodeDistribution}
emptyMessage='暂无状态码分布'
emptyMessage={t('obs.noStatusDist')}
/>
</div>
<div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
Top Domain
{t('obs.topDomain')}
</p>
<DistributionList
items={topDomains}
emptyMessage='暂无域名分布'
emptyMessage={t('obs.noDomainDist')}
/>
</div>
</div>
@@ -875,21 +918,23 @@ export function NodeObservability({
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
{t('obs.cleanupDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
{cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -2,16 +2,17 @@
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
const filters: Array<{ key: NodeFilter; label: string; href: string }> = [
{ key: 'all', label: '全部节点', href: '/nodes' },
{ key: 'edge', label: 'Edge', href: '/nodes?filter=edge' },
{ key: 'relay', label: 'Relay', href: '/nodes?filter=relay' },
{ key: 'tunnel', label: 'Tunnel', href: '/nodes?filter=tunnel' },
const filters: Array<{ key: NodeFilter; href: string }> = [
{ key: 'all', href: '/nodes' },
{ key: 'edge', href: '/nodes?filter=edge' },
{ key: 'relay', href: '/nodes?filter=relay' },
{ key: 'tunnel', href: '/nodes?filter=tunnel' },
];
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter {
@@ -44,23 +45,33 @@ export function filterNodesByType<T extends { node_type: string }>(
}
}
export function getFilterDescription(filter: NodeFilter) {
export function getFilterDescription(
filter: NodeFilter,
t: (key: string) => string,
) {
switch (filter) {
case 'relay':
return '当前仅展示 Relay 节点。';
return t('filter.descRelay');
case 'tunnel':
return '当前仅展示 Tunnel 节点。';
return t('filter.descTunnel');
case 'edge':
return '当前仅展示 Edge 节点。';
return t('filter.descEdge');
case 'all':
default:
return '当前展示全部节点。';
return t('filter.descAll');
}
}
export function NodeTypeFilter() {
const t = useTranslations('nodes');
const searchParams = useSearchParams();
const activeFilter = getNodeFilter(searchParams);
const filterLabels: Record<NodeFilter, string> = {
all: t('filter.all'),
edge: 'Edge',
relay: 'Relay',
tunnel: 'Tunnel',
};
return (
<div className='flex flex-wrap gap-2'>
@@ -75,7 +86,7 @@ export function NodeTypeFilter() {
: 'border-border text-muted-foreground hover:bg-muted/50',
)}
>
{item.label}
{filterLabels[item.key]}
</Link>
))}
</div>
@@ -11,6 +11,11 @@ export const FLARED_WS_CONNECTED_LAST_SEEN =
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
export type NodeMessageT = (
key: string,
values?: Record<string, string | number | Date>,
) => string;
export function isWSConnectedLastSeen(value: string | null | undefined) {
return (
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
@@ -27,7 +32,7 @@ export function isMeaningfulTime(
);
}
export function formatRelativeTime(value: string) {
export function formatRelativeTime(value: string, t: NodeMessageT) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
@@ -35,16 +40,16 @@ export function formatRelativeTime(value: string) {
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
if (diffMinutes < 1) return t('relative.justNow');
if (diffMinutes < 60) return t('relative.minutesAgo', { count: diffMinutes });
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} 小时前`;
if (diffHours < 24) return t('relative.hoursAgo', { count: diffHours });
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays} 天前`;
if (diffDays < 30) return t('relative.daysAgo', { count: diffDays });
return `${Math.floor(diffDays / 30)} 个月前`;
return t('relative.monthsAgo', { count: Math.floor(diffDays / 30) });
}
export function getNodeStatusTone(status: NodeStatus): StatusTone {
@@ -53,10 +58,10 @@ export function getNodeStatusTone(status: NodeStatus): StatusTone {
return 'danger';
}
export function getNodeStatusLabel(status: NodeStatus) {
if (status === 'online') return '在线';
if (status === 'pending') return '待接入';
return '离线';
export function getNodeStatusLabel(status: NodeStatus, t: NodeMessageT) {
if (status === 'online') return t('status.online');
if (status === 'pending') return t('status.pending');
return t('status.offline');
}
export function getApplyTone(result: ApplyResult): StatusTone {
@@ -66,11 +71,11 @@ export function getApplyTone(result: ApplyResult): StatusTone {
return 'warning';
}
export function getApplyLabel(result: ApplyResult) {
if (result === 'success') return '成功';
if (result === 'warning') return '警告';
if (result === 'failed') return '失败';
return '暂无';
export function getApplyLabel(result: ApplyResult, t: NodeMessageT) {
if (result === 'success') return t('apply.success');
if (result === 'warning') return t('apply.warning');
if (result === 'failed') return t('apply.failed');
return t('apply.none');
}
export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
@@ -79,10 +84,13 @@ export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
return 'warning';
}
export function getOpenrestyStatusLabel(status: OpenrestyStatus) {
if (status === 'healthy') return '健康';
if (status === 'unhealthy') return '异常';
return '未知';
export function getOpenrestyStatusLabel(
status: OpenrestyStatus,
t: NodeMessageT,
) {
if (status === 'healthy') return t('health.healthy');
if (status === 'unhealthy') return t('health.unhealthy');
return t('health.unknown');
}
export function getRelayStatusTone(
@@ -93,10 +101,13 @@ export function getRelayStatusTone(
return 'warning';
}
export function getRelayStatusLabel(status: string | null | undefined) {
if (status === 'healthy') return '健康';
if (status === 'unhealthy') return '异常';
return '未知';
export function getRelayStatusLabel(
status: string | null | undefined,
t: NodeMessageT,
) {
if (status === 'healthy') return t('health.healthy');
if (status === 'unhealthy') return t('health.unhealthy');
return t('health.unknown');
}
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
@@ -105,8 +116,8 @@ export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
return 'Edge';
}
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
export function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export function getServerUrl(value: string) {
@@ -264,7 +275,10 @@ export function formatUsageRatio(used?: number | null, total?: number | null) {
return Math.max(0, Math.min(100, (used / total) * 100));
}
export function formatUptime(seconds?: number | null) {
export function formatUptime(
seconds: number | null | undefined,
t: NodeMessageT,
) {
if (!seconds || seconds <= 0) {
return '—';
}
@@ -274,12 +288,12 @@ export function formatUptime(seconds?: number | null) {
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} 天 ${hours} 小时`;
return t('uptime.daysHours', { days, hours });
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`;
return t('uptime.hoursMinutes', { hours, minutes });
}
return `${minutes} 分钟`;
return t('uptime.minutes', { minutes });
}
export function getHealthEventTone(event: {
@@ -302,17 +316,17 @@ export function getHealthEventLabel(event: { event_type: string }) {
return event.event_type.replaceAll('_', ' ');
}
export function getFlaredStatusLabel(node: NodeItem) {
export function getFlaredStatusLabel(node: NodeItem, t: NodeMessageT) {
if (isWSConnectedLastSeen(node.last_seen_at)) {
return 'WS 已连接';
return t('status.wsConnected');
}
if (node.status === 'online') {
return '运行中';
return t('status.running');
}
if (node.status === 'pending') {
return '待接入';
return t('status.pending');
}
return '离线';
return t('status.offline');
}
export function getFlaredStatusTone(node: NodeItem): StatusTone {
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { Pencil, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
@@ -42,19 +43,24 @@ export function NodesTable({
onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>节点</TableHead>
<TableHead className='py-2 h-8'>状态</TableHead>
<TableHead className='py-2 h-8'>Version</TableHead>
<TableHead className='py-2 h-8'>运行健康</TableHead>
<TableHead className='py-2 h-8'>当前版本</TableHead>
<TableHead className='py-2 h-8'>最近应用</TableHead>
<TableHead className='py-2 h-8'>最近心跳</TableHead>
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
<TableHead className='py-2 h-8'>{t('table.node')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.status')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.version')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.runHealth')}</TableHead>
<TableHead className='py-2 h-8'>
{t('table.currentVersion')}
</TableHead>
<TableHead className='py-2 h-8'>{t('table.latestApply')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.lastSeen')}</TableHead>
<TableHead className='py-2 h-8 text-right'>
{t('table.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -70,17 +76,19 @@ export function NodesTable({
/>
</div>
<p className='text-xs text-muted-foreground'>
IP:{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{t('ipLine', { ip: node.ip || '—' })}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</p>
<p className='text-xs text-muted-foreground'>
位置:{node.geo_name || '未配置地图点位'}
{t('locationLine', {
location: node.geo_name || t('noGeo'),
})}
</p>
</div>
</TableCell>
<TableCell className='py-3'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</TableCell>
@@ -90,41 +98,45 @@ export function NodesTable({
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)}
/>
) : node.node_type === 'tunnel_client' ? (
<NodeStatusBadge
label={node.status === 'online' ? '运行中' : '未知'}
label={
node.status === 'online' ? t('running') : t('unknown')
}
tone={node.status === 'online' ? 'success' : 'warning'}
/>
) : (
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
)}
</TableCell>
<TableCell className='py-3 text-muted-foreground'>
{node.current_version ||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
(node.node_type === 'tunnel_relay'
? t('liveConfig')
: t('notApplied'))}
</TableCell>
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<span className='text-sm text-muted-foreground'>—</span>
) : (
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
)}
</TableCell>
<TableCell className='py-3 text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</TableCell>
<TableCell className='py-3'>
<div className='flex flex-wrap justify-end gap-1'>
@@ -134,7 +146,9 @@ export function NodesTable({
className='h-7 text-xs'
asChild
>
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button>
<Button
variant='outline'
@@ -143,7 +157,7 @@ export function NodesTable({
onClick={() => onEdit(node)}
>
<Pencil className='size-3 mr-1' />
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -153,7 +167,7 @@ export function NodesTable({
onClick={() => onDelete(node)}
>
<Trash2 className='size-3 mr-1' />
删除
{t('delete')}
</Button>
</div>
</TableCell>
@@ -18,6 +18,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -78,6 +79,8 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
@@ -106,7 +109,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
mutationFn: async (port: string) => {
const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
throw new Error('端口号必须在 1 ~ 65535 范围内');
throw new Error(t('relay.invalidPort'));
}
const portCfg = configs['relay_frps_web_ui_port'];
await services.adminSystemConfig.updateSystemConfig(
@@ -121,21 +124,23 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('FRPS WebUI 端口配置已更新');
toast.success(t('relay.toastPortSaved'));
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : '更新端口失败'),
toast.error(
error instanceof Error ? error.message : t('relay.portSaveFailed'),
),
});
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('中继节点已更新');
toast.success(t('relay.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const webServerMutation = useMutation({
@@ -155,19 +160,19 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
relay_web_server_enabled: enabled,
}),
onSuccess: async () => {
toast.success('FRPS WebUI 设置已更新');
toast.success(t('relay.toastWebui'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向中继节点 ${updated.name} 下发强制同步指令`);
toast.success(t('relay.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
@@ -187,22 +192,28 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
}),
onSuccess: async (updated) => {
toast.success(
`已向中继节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
t('relay.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('中继节点已删除');
toast.success(t('relay.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
@@ -236,7 +247,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
className='h-8'
onClick={() => setEditorOpen(true)}
>
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -245,7 +256,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -255,7 +266,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='secondary'
@@ -264,7 +275,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 Relay'}
{node.update_requested ? t('viewUpgrade') : t('relay.upgrade')}
</Button>
<Button
variant='outline'
@@ -273,7 +284,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
删除
{t('delete')}
</Button>
</>
);
@@ -284,76 +295,76 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title='中继运行状态'
description='frps 在线情况与负载摘要'
title={t('relay.runStatus')}
description={t('relay.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='运行状态'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label='中继健康'>
<NodeInfoRow label={t('relay.health')}>
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近心跳'>
<NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='活动连接 / 代理数'>
<NodeInfoRow label={t('relay.connections')}>
{node.relay_frps_connections ?? '—'} /{' '}
{node.relay_frps_proxy_count ?? '—'}
</NodeInfoRow>
<NodeInfoRow label='IP 地址'>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title='网络端口'
description='frps 控制面与 HTTP 虚拟主机配置'
title={t('relay.portsTitle')}
description={t('relay.portsDesc')}
action={
<Button variant='outline' size='sm' className='h-8' asChild>
<Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
>
<FileText className='size-3.5 mr-1.5' />
应用记录
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label='绑定控制端口'>
<NodeInfoRow label={t('relay.bindPort')}>
{node.relay_bind_port || '—'}
</NodeInfoRow>
<NodeInfoRow label='VHost HTTP 端口'>
<NodeInfoRow label={t('relay.vhostPort')}>
{node.relay_vhost_http_port || '—'}
</NodeInfoRow>
<NodeInfoRow label='Agent 接入地址'>
<NodeInfoRow label={t('relay.agentAddr')}>
<span className='break-all'>
{node.relay_agent_access_addr || '—'}
</span>
</NodeInfoRow>
<NodeInfoRow label='最近应用'>
<NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label='Relay 版本'>
<NodeInfoRow label={t('relay.relayVersion')}>
{node.version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label='frps 核心版本'>
<NodeInfoRow label={t('relay.frpsVersion')}>
{node.ext_version || 'unknown'}
</NodeInfoRow>
</div>
@@ -361,15 +372,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</div>
<NodeSectionCard
title='FRPS WebUI'
description='控制 frps 内置 Web 管理界面是否启用及其监听端口'
title={t('relay.webuiTitle')}
description={t('relay.webuiDesc')}
>
<div className='space-y-4'>
<div className='flex items-center justify-between rounded-xl border px-4 py-4'>
<div className='space-y-1'>
<Label>启用 Web 管理界面</Label>
<Label>{t('relay.enableWebui')}</Label>
<p className='text-xs text-muted-foreground'>
开启后,节点将启动 frps 的内置管理服务。
{t('relay.enableWebuiHint')}
</p>
</div>
<Switch
@@ -381,7 +392,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<div className='rounded-xl border p-4 space-y-3'>
<Label htmlFor='web_server_port' className='text-xs font-semibold'>
Web 管理界面端口
{t('relay.webuiPort')}
</Label>
<div className='flex max-w-sm items-center gap-2'>
<Input
@@ -391,7 +402,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
max={65535}
value={webServerPort}
onChange={(e) => setWebServerPort(e.target.value)}
placeholder='例如: 17500'
placeholder={t('relay.webuiPortPlaceholder')}
className='bg-card text-xs h-9'
disabled={
savePortMutation.isPending || systemConfigsQuery.isLoading
@@ -408,13 +419,12 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
{savePortMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
'保存'
tc('save')
)}
</Button>
</div>
<p className='text-[11px] text-muted-foreground'>
当前实际监听端口:{resolvedPort}{' '}
(修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
{t('relay.actualPort', { port: resolvedPort })}
</p>
</div>
@@ -426,7 +436,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
rel='noopener noreferrer'
className='inline-flex items-center font-medium text-primary hover:underline'
>
打开 FRPS WebUI
{t('relay.openWebui')}
<ExternalLink className='size-3.5 ml-1.5' />
</a>
<span className='text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border'>
@@ -438,16 +448,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
className='size-7 text-muted-foreground hover:text-foreground'
onClick={() => {
void navigator.clipboard.writeText(webUiUrl);
toast.success('链接已复制到剪贴板');
toast.success(t('relay.copiedLink'));
}}
title='复制链接'
title={t('relay.copyLink')}
>
<Copy className='size-3.5' />
</Button>
</div>
) : (
<p className='text-sm text-muted-foreground'>
WebUI 已禁用或未配置绑定端口。
{t('relay.webuiDisabled')}
</p>
)}
</div>
@@ -455,15 +465,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='relay' />
<NodeSectionCard title='节点元数据'>
<NodeSectionCard title={t('relay.metadata')}>
<div className='divide-y'>
<NodeInfoRow label='节点 ID'>
<NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label='创建时间'>
<NodeInfoRow label={t('relay.createdAt')}>
{formatDateTime(node.created_at)}
</NodeInfoRow>
<NodeInfoRow label='更新时间'>
<NodeInfoRow label={t('relay.updatedAt')}>
{formatDateTime(node.updated_at)}
</NodeInfoRow>
</div>
@@ -479,34 +489,34 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
typeTone='warning'
statusBadges={[
{
label: getNodeStatusLabel(node.status),
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getRelayStatusLabel(node.relay_status),
label: getRelayStatusLabel(node.relay_status, t),
tone: getRelayStatusTone(node.relay_status),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: 'Relay 版本',
label: t('relay.relayVersion'),
value: node.version || 'unknown',
icon: Server,
},
{
label: 'frps 核心',
label: t('relay.kpiFrps'),
value: node.ext_version || 'unknown',
icon: Network,
},
{
label: '最近心跳',
label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
@@ -514,7 +524,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<NodeObservability
nodeId={node.id}
variant='compact'
connectionHint='中继承载活动连接数'
connectionHint={t('relay.connectionHint')}
/>
}
manage={manageTab}
@@ -544,22 +554,21 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle>
<AlertDialogTitle>{t('relay.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除中继节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
{t('relay.deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -15,6 +15,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -63,6 +64,8 @@ import {
const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
@@ -73,20 +76,20 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('隧道节点已更新');
toast.success(t('tunnel.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向隧道节点 ${updated.name} 下发强制同步指令`);
toast.success(t('tunnel.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
@@ -106,22 +109,28 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
}),
onSuccess: async (updated) => {
toast.success(
`已向隧道节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
t('tunnel.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('隧道节点已删除');
toast.success(t('tunnel.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
@@ -141,7 +150,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
className='h-8'
onClick={() => setEditorOpen(true)}
>
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -150,7 +159,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -160,7 +169,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='secondary'
@@ -169,7 +178,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 openflared'}
{node.update_requested ? t('viewUpgrade') : t('tunnel.upgrade')}
</Button>
<Button
variant='outline'
@@ -178,7 +187,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
删除
{t('delete')}
</Button>
</>
);
@@ -189,81 +198,83 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title='隧道运行状态'
description='openflared 连接与在线情况'
title={t('tunnel.runStatus')}
description={t('tunnel.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='运行状态'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label='flared 状态'>
<NodeInfoRow label={t('tunnel.flaredStatus')}>
<NodeStatusBadge
label={getFlaredStatusLabel(node)}
label={getFlaredStatusLabel(node, t)}
tone={getFlaredStatusTone(node)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近心跳'>
<NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WebSocket 长连接已建立'
? t('wsEstablished')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='IP 地址'>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
<NodeInfoRow label='自动更新'>
{node.auto_update_enabled ? '已启用' : '手动'}
<NodeInfoRow label={t('tunnel.autoUpdate')}>
{node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title='配置同步'
description='隧道客户端配置追平状态'
title={t('tunnel.syncTitle')}
description={t('tunnel.syncDesc')}
action={
<Button variant='outline' size='sm' className='h-8' asChild>
<Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
>
<FileText className='size-3.5 mr-1.5' />
应用记录
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label='当前配置版本'>
{node.current_version || '未应用'}
<NodeInfoRow label={t('tunnel.currentVersion')}>
{node.current_version || t('notApplied')}
</NodeInfoRow>
<NodeInfoRow label='最近应用'>
<NodeInfoRow label={t('tunnel.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近应用时间'>
<NodeInfoRow label={t('tunnel.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
: t('na')}
</NodeInfoRow>
{node.latest_apply_checksum ? (
<NodeInfoRow label='同步文件数'>
{node.latest_support_file_count} 个
<NodeInfoRow label={t('tunnel.fileCount')}>
{t('tunnel.fileCountValue', {
count: node.latest_support_file_count,
})}
</NodeInfoRow>
) : null}
<NodeInfoRow label='升级状态'>
<NodeInfoRow label={t('tunnel.upgradeStatus')}>
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
? t('update.waitingPreview')
: t('update.waitingUpdate')
: t('update.notRequested')
}
tone={node.update_requested ? 'warning' : 'info'}
/>
@@ -274,20 +285,23 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='tunnel' />
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'>
<NodeSectionCard
title={t('tunnel.credentials')}
description={t('tunnel.credentialsDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='Tunnel Token'>
<NodeInfoRow label={t('tunnel.tunnelToken')}>
<span className='font-mono text-xs break-all'>
{node.access_token || '暂无'}
{node.access_token || t('na')}
</span>
</NodeInfoRow>
<NodeInfoRow label='节点 ID'>
<NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label='创建时间'>
<NodeInfoRow label={t('tunnel.createdAt')}>
{formatDateTime(node.created_at)}
</NodeInfoRow>
<NodeInfoRow label='更新时间'>
<NodeInfoRow label={t('tunnel.updatedAt')}>
{formatDateTime(node.updated_at)}
</NodeInfoRow>
</div>
@@ -303,34 +317,34 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
typeTone='info'
statusBadges={[
{
label: getNodeStatusLabel(node.status),
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getFlaredStatusLabel(node),
label: getFlaredStatusLabel(node, t),
tone: getFlaredStatusTone(node),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: 'openflared',
label: t('tunnel.kpiOpenflared'),
value: node.version || 'unknown',
icon: Package,
},
{
label: '当前配置',
value: node.current_version || '未应用',
label: t('kpiCurrentConfig'),
value: node.current_version || t('notApplied'),
icon: KeyRound,
},
{
label: '最近心跳',
label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
@@ -338,7 +352,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<NodeObservability
nodeId={node.id}
variant='compact'
connectionHint='隧道并发连接数'
connectionHint={t('tunnel.connectionHint')}
/>
}
manage={manageTab}
@@ -368,22 +382,21 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle>
<AlertDialogTitle>{t('tunnel.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除隧道节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
{t('tunnel.deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -20,6 +21,7 @@ import { getErrorMessage } from '../components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodeDetailPageClient() {
const t = useTranslations('nodes');
const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? '';
@@ -41,7 +43,7 @@ export function NodeDetailPageClient() {
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={Server}
description='缺少节点 ID,请从节点列表进入详情页。'
description={t('missingId')}
/>
</div>
);
@@ -50,7 +52,10 @@ export function NodeDetailPageClient() {
if (nodesQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Server} description='加载节点详情中...' />
<LoadingStateWithBorder
icon={Server}
description={t('loadingDetail')}
/>
</div>
);
}
@@ -60,7 +65,7 @@ export function NodeDetailPageClient() {
<div className='py-6 px-1'>
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
onRetry={() => void nodesQuery.refetch()}
className='justify-center'
/>
@@ -75,13 +80,10 @@ export function NodeDetailPageClient() {
<Button variant='ghost' size='sm' className='h-8 px-2' asChild>
<Link href='/nodes'>
<ArrowLeft className='size-4 mr-1' />
返回节点列表
{t('backToList')}
</Link>
</Button>
<EmptyStateWithBorder
icon={Server}
description='节点不存在,可能已被删除或 ID 无效。'
/>
<EmptyStateWithBorder icon={Server} description={t('notFound')} />
</div>
);
}
+20 -20
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -44,6 +45,8 @@ import { getErrorMessage } from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodesPageClient() {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
@@ -75,25 +78,25 @@ export function NodesPageClient() {
return NodeService.createNode(payload);
},
onSuccess: async () => {
toast.success(editingNode ? '节点已更新' : '节点已创建');
toast.success(editingNode ? t('updated') : t('created'));
setEditingNode(null);
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('requestFailed')));
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => NodeService.deleteNode(id),
onSuccess: async () => {
toast.success('节点已删除');
toast.success(t('deleted'));
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('requestFailed')));
},
});
@@ -116,11 +119,11 @@ export function NodesPageClient() {
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Server className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>节点管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
</div>
<div className='flex items-center gap-2'>
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
<Link href='/apply-logs'>应用记录</Link>
<Link href='/apply-logs'>{t('applyLogs')}</Link>
</Button>
<Button
variant='secondary'
@@ -129,7 +132,7 @@ export function NodesPageClient() {
onClick={handleCreate}
>
<Plus className='size-3.5 mr-1' />
新增节点
{t('create')}
</Button>
</div>
</div>
@@ -139,10 +142,10 @@ export function NodesPageClient() {
<div className='flex items-center justify-between gap-3'>
<div>
<CardTitle className='text-base font-semibold'>
节点列表
{t('listTitle')}
</CardTitle>
<CardDescription>
{getFilterDescription(nodeFilter)}
{getFilterDescription(nodeFilter, t)}
</CardDescription>
</div>
<Button
@@ -157,7 +160,7 @@ export function NodesPageClient() {
) : (
<RefreshCw className='size-3.5 mr-1' />
)}
立即刷新
{t('refreshNow')}
</Button>
</div>
</CardHeader>
@@ -167,12 +170,12 @@ export function NodesPageClient() {
{nodesQuery.isLoading ? (
<LoadingStateWithBorder
icon={Server}
description='加载节点列表中...'
description={t('loadingList')}
/>
) : nodesQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
onRetry={handleRefresh}
className='justify-center'
/>
@@ -181,9 +184,7 @@ export function NodesPageClient() {
<EmptyStateWithBorder
icon={Server}
description={
nodes.length === 0
? '暂无节点,请先创建一个节点。'
: '当前筛选无结果'
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
}
/>
) : (
@@ -218,15 +219,14 @@ export function NodesPageClient() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{deleteTarget?.name}
」吗?删除后该节点需要重新创建并重新接入。
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
@@ -235,7 +235,7 @@ export function NodesPageClient() {
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -25,21 +25,13 @@ import {
type OriginMutationPayload,
OriginService,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
const originSchema = z.object({
name: z.string().max(255),
address: z
.string()
.trim()
.min(1, '请输入源站地址')
.refine(
(value) => !/[/?#]/.test(value) && !value.includes('://'),
'源站地址格式不合法',
),
remark: z.string().max(255),
});
type OriginFormValues = z.infer<typeof originSchema>;
type OriginFormValues = {
name: string;
address: string;
remark: string;
};
const originsQueryKey = ['openflare', 'origins'] as const;
@@ -73,7 +65,21 @@ export function OriginEditorDialog({
origin,
onSaved,
}: OriginEditorDialogProps) {
const t = useTranslations('origins');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const originSchema = z.object({
name: z.string().max(255),
address: z
.string()
.trim()
.min(1, t('addressRequired'))
.refine(
(value) => !/[/?#]/.test(value) && !value.includes('://'),
t('addressInvalid'),
),
remark: z.string().max(255),
});
const form = useForm<OriginFormValues>({
resolver: zodResolver(originSchema),
defaultValues: toFormValues(origin),
@@ -91,7 +97,7 @@ export function OriginEditorDialog({
: OriginService.create(payload);
},
onSuccess: async () => {
toast.success(origin ? '源站已更新' : '源站已创建');
toast.success(origin ? t('updated') : t('created'));
await queryClient.invalidateQueries({ queryKey: originsQueryKey });
if (origin) {
await queryClient.invalidateQueries({
@@ -102,7 +108,7 @@ export function OriginEditorDialog({
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '保存失败');
toast.error(error instanceof Error ? error.message : t('saveFailed'));
},
});
@@ -110,10 +116,8 @@ export function OriginEditorDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{origin ? '编辑源站' : '新增源站'}</DialogTitle>
<DialogDescription>
源站作为规则里的可复用地址目录,协议和端口仍由规则决定。
</DialogDescription>
<DialogTitle>{origin ? t('editOrigin') : t('create')}</DialogTitle>
<DialogDescription>{t('editorDesc')}</DialogDescription>
</DialogHeader>
<form
@@ -122,7 +126,7 @@ export function OriginEditorDialog({
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className='space-y-1.5'>
<Label htmlFor='address'>源站地址</Label>
<Label htmlFor='address'>{t('address')}</Label>
<Input
id='address'
placeholder='origin.internal'
@@ -135,22 +139,22 @@ export function OriginEditorDialog({
) : null}
</div>
<div className='space-y-1.5'>
<Label htmlFor='name'>源站名</Label>
<Label htmlFor='name'>{t('name')}</Label>
<Input
id='name'
placeholder='主站源站'
placeholder={t('namePlaceholder')}
{...form.register('name')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='remark'>备注</Label>
<Label htmlFor='remark'>{t('remark')}</Label>
<Textarea id='remark' rows={2} {...form.register('remark')} />
</div>
</form>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
{tc('cancel')}
</Button>
<Button
type='submit'
@@ -160,12 +164,12 @@ export function OriginEditorDialog({
{mutation.isPending ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
保存中...
{t('saving')}
</>
) : origin ? (
'保存修改'
t('saveChanges')
) : (
'新增源站'
t('create')
)}
</Button>
</DialogFooter>
@@ -33,9 +33,13 @@ import {
import { OriginService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { OriginEditorDialog } from '../components/origin-editor-dialog';
export function OriginDetailPageClient() {
const t = useTranslations('origins');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const originId = searchParams.get('id')?.trim() ?? '';
@@ -54,14 +58,14 @@ export function OriginDetailPageClient() {
const deleteMutation = useMutation({
mutationFn: () => OriginService.deleteById(parsedId),
onSuccess: async () => {
toast.success('源站已删除');
toast.success(t('deleted'));
await queryClient.invalidateQueries({
queryKey: ['openflare', 'origins'],
});
window.location.href = '/origins';
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '删除失败');
toast.error(error instanceof Error ? error.message : t('deleteFailed'));
},
});
@@ -70,7 +74,7 @@ export function OriginDetailPageClient() {
if (!enabled) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder description='缺少有效的源站 ID。' />
<EmptyStateWithBorder description={t('missingId')} />
</div>
);
}
@@ -78,7 +82,7 @@ export function OriginDetailPageClient() {
if (originQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={MapPin} description='加载源站详情...' />
<LoadingStateWithBorder icon={MapPin} description={t('loadingDetail')} />
</div>
);
}
@@ -90,7 +94,7 @@ export function OriginDetailPageClient() {
message={
originQuery.error instanceof Error
? originQuery.error.message
: '加载失败'
: t('loadFailed')
}
onRetry={() => void originQuery.refetch()}
/>
@@ -104,10 +108,10 @@ export function OriginDetailPageClient() {
<Button variant='ghost' size='sm' asChild>
<Link href='/origins'>
<ArrowLeft className='size-4 mr-1' />
返回列表
{t('backToList')}
</Link>
</Button>
<EmptyStateWithBorder description='源站不存在或已被删除。' />
<EmptyStateWithBorder description={t('notFound')} />
</div>
);
}
@@ -119,7 +123,7 @@ export function OriginDetailPageClient() {
<Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild>
<Link href='/origins'>
<ArrowLeft className='size-4 mr-1' />
返回列表
{t('backToList')}
</Link>
</Button>
<div className='flex items-center gap-2'>
@@ -136,7 +140,7 @@ export function OriginDetailPageClient() {
size='sm'
onClick={() => setEditorOpen(true)}
>
编辑源站
{t('editOrigin')}
</Button>
<Button
variant='destructive'
@@ -144,7 +148,7 @@ export function OriginDetailPageClient() {
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1' />
删除源站
{t('deleteOrigin')}
</Button>
</div>
</div>
@@ -152,57 +156,57 @@ export function OriginDetailPageClient() {
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
绑定规则
{t('boundRoutes')}
</p>
<Badge variant='outline' className='mt-2 text-[10px]'>
{origin.route_count} 条规则
{t('routeCount', { count: origin.route_count })}
</Badge>
</div>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
创建时间
{t('createdAt')}
</p>
<p className='mt-2 text-sm'>{formatDateTime(origin.created_at)}</p>
</div>
<div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
更新时间
{t('updatedAt')}
</p>
<p className='mt-2 text-sm'>{formatDateTime(origin.updated_at)}</p>
</div>
<div className='rounded-lg border border-dashed px-4 py-3 sm:col-span-2 xl:col-span-1'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
备注
{t('remark')}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{origin.remark || '暂无备注'}
{origin.remark || t('noRemark')}
</p>
</div>
</div>
<div className='border border-dashed rounded-lg overflow-hidden bg-background'>
<div className='px-4 py-3 border-b border-dashed'>
<h2 className='text-sm font-semibold'>关联规则</h2>
<h2 className='text-sm font-semibold'>{t('relatedRoutes')}</h2>
<p className='text-xs text-muted-foreground mt-1'>
展示当前源站作为主源站绑定的规则。
{t('relatedRoutesDesc')}
</p>
</div>
{origin.routes.length === 0 ? (
<EmptyStateWithBorder
title='暂无关联规则'
description='当前源站还没有被任何规则引用。'
title={t('noRelatedRoutes')}
description={t('noRelatedRoutesDesc')}
/>
) : (
<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'>{t('domain')}</TableHead>
<TableHead className='text-xs font-semibold'>
源站地址
{t('address')}
</TableHead>
<TableHead className='text-xs font-semibold'>状态</TableHead>
<TableHead className='text-xs font-semibold'>{t('status')}</TableHead>
<TableHead className='text-xs font-semibold'>
更新时间
{t('updatedAt')}
</TableHead>
</TableRow>
</TableHeader>
@@ -217,7 +221,7 @@ export function OriginDetailPageClient() {
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{route.enabled ? '启用' : '停用'}
{route.enabled ? t('enabled') : t('disabled')}
</Badge>
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
@@ -240,18 +244,18 @@ export function OriginDetailPageClient() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除源站</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除源站 {origin.name} 吗?
{t('deleteDesc', { name: origin.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => deleteMutation.mutate()}
>
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+27 -21
View File
@@ -24,11 +24,15 @@ import { Button } from '@/components/ui/button';
import { type OriginItem, OriginService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { OriginEditorDialog } from './components/origin-editor-dialog';
const originsQueryKey = ['openflare', 'origins'] as const;
export default function OriginsPage() {
const t = useTranslations('origins');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
@@ -44,12 +48,12 @@ export default function OriginsPage() {
const deleteMutation = useMutation({
mutationFn: (id: number) => OriginService.deleteById(id),
onSuccess: async () => {
toast.success('源站已删除');
toast.success(t('deleted'));
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: originsQueryKey });
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '删除失败');
toast.error(error instanceof Error ? error.message : t('deleteFailed'));
},
});
@@ -59,10 +63,8 @@ export default function OriginsPage() {
<div className='flex items-center gap-2'>
<MapPin className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>源站</h1>
<p className='text-sm text-muted-foreground'>
集中维护规则复用的源站地址,减少批量改地址时的重复操作。
</p>
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
</div>
</div>
<div className='flex gap-2'>
@@ -75,7 +77,7 @@ export default function OriginsPage() {
<RefreshCw
className={`size-3.5 mr-1 ${originsQuery.isFetching ? 'animate-spin' : ''}`}
/>
刷新
{t('refresh')}
</Button>
<Button
size='sm'
@@ -85,7 +87,7 @@ export default function OriginsPage() {
}}
>
<Plus className='size-3.5 mr-1' />
新增源站
{t('create')}
</Button>
</div>
</div>
@@ -95,7 +97,7 @@ export default function OriginsPage() {
message={
originsQuery.error instanceof Error
? originsQuery.error.message
: '加载失败'
: t('loadFailed')
}
onRetry={() => void originsQuery.refetch()}
/>
@@ -106,8 +108,8 @@ export default function OriginsPage() {
<LoadingStateWithBorder />
) : origins.length === 0 ? (
<EmptyStateWithBorder
title='暂无源站'
description='点击右上角新增源站,后续规则可直接复用这些地址。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className='grid gap-0 md:grid-cols-2'>
@@ -128,20 +130,24 @@ export default function OriginsPage() {
: 'text-amber-600 border-amber-500/20'
}`}
>
{origin.route_count} 条规则
{t('routeCount', { count: origin.route_count })}
</Badge>
</div>
<p className='text-sm'>{origin.address}</p>
<p className='text-sm text-muted-foreground'>
{origin.remark || '暂无备注'}
{origin.remark || t('noRemark')}
</p>
<p className='text-xs text-muted-foreground'>
最后更新:{formatDateTime(origin.updated_at)}
{t('lastUpdated', {
date: formatDateTime(origin.updated_at),
})}
</p>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href={`/origins/detail?id=${origin.id}`}>详情</Link>
<Link href={`/origins/detail?id=${origin.id}`}>
{t('detail')}
</Link>
</Button>
<Button
variant='outline'
@@ -151,7 +157,7 @@ export default function OriginsPage() {
setEditorOpen(true);
}}
>
编辑
{t('edit')}
</Button>
<Button
variant='destructive'
@@ -159,7 +165,7 @@ export default function OriginsPage() {
onClick={() => setDeleteTarget(origin)}
>
<Trash2 className='size-3.5 mr-1' />
删除
{tc('delete')}
</Button>
</div>
</div>
@@ -183,20 +189,20 @@ export default function OriginsPage() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除源站</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除源站 {deleteTarget?.name} 吗?
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}}
>
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+14 -8
View File
@@ -2,6 +2,7 @@
import { useQuery } from '@tanstack/react-query';
import { LayoutDashboard, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
@@ -24,6 +25,7 @@ import { getErrorMessage } from './nodes/components/node-utils';
const dashboardQueryKey = ['openflare', 'dashboard', 'overview'];
export default function OpenFlareDashboardPage() {
const t = useTranslations('dashboard');
const overviewQuery = useQuery({
queryKey: dashboardQueryKey,
queryFn: () => DashboardService.getOverview(),
@@ -37,11 +39,13 @@ export default function OpenFlareDashboardPage() {
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<LayoutDashboard className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>总览</h1>
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
</div>
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
{overview?.generated_at ? (
<span>数据生成于 {formatDateTime(overview.generated_at)}</span>
<span>
{t('generatedAt', { time: formatDateTime(overview.generated_at) })}
</span>
) : null}
<Button
variant='outline'
@@ -53,25 +57,27 @@ export default function OpenFlareDashboardPage() {
<RefreshCw
className={`size-3.5 mr-1.5 ${overviewQuery.isFetching ? 'animate-spin' : ''}`}
/>
刷新
{t('refresh')}
</Button>
</div>
</div>
{overviewQuery.isLoading ? (
<LoadingStateWithBorder
title='加载总览数据'
description='正在聚合节点健康、流量与容量指标...'
title={t('loadingTitle')}
description={t('loadingDesc')}
/>
) : overviewQuery.isError ? (
<ErrorInline
message={`总览看板加载失败:${getErrorMessage(overviewQuery.error)}`}
message={t('loadFailed', {
error: getErrorMessage(overviewQuery.error, t('requestFailed')),
})}
onRetry={() => overviewQuery.refetch()}
/>
) : !overview ? (
<EmptyStateWithBorder
title='暂无总览数据'
description='系统已经启动,但还没有可展示的总览聚合结果。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<>
@@ -3,6 +3,7 @@
import { useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UploadCloud } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -76,6 +77,8 @@ export function DeploymentUploadDialog({
rootDir,
entryFile,
}: DeploymentUploadDialogProps) {
const t = useTranslations('pages.upload');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
@@ -96,14 +99,14 @@ export function DeploymentUploadDialog({
const uploadMutation = useMutation({
mutationFn: () => {
if (!file) throw new Error('请选择部署包');
if (!file) throw new Error(t('selectFile'));
return PagesService.uploadDeployment(projectId, {
file,
onProgress: setUploadProgress,
});
},
onSuccess: async () => {
toast.success('部署包上传成功');
toast.success(t('success'));
await Promise.all([
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId),
@@ -114,7 +117,7 @@ export function DeploymentUploadDialog({
handleOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '上传失败');
toast.error(error instanceof Error ? error.message : t('failed'));
setUploadProgress(null);
},
});
@@ -122,7 +125,7 @@ export function DeploymentUploadDialog({
const handleFileSelect = (selected: File | null) => {
if (!selected) return;
if (!isSupportedPagesPackage(selected.name)) {
toast.error('仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式的部署包');
toast.error(t('unsupported'));
return;
}
setFile(selected);
@@ -132,15 +135,13 @@ export function DeploymentUploadDialog({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>上传部署包</DialogTitle>
<DialogDescription>
上传不可变的静态资源压缩包,完成后可在部署历史中激活。
</DialogDescription>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor='pages-package'>本地部署包</FieldLabel>
<FieldLabel htmlFor='pages-package'>{t('localPackage')}</FieldLabel>
<button
type='button'
className={cn(
@@ -165,7 +166,7 @@ export function DeploymentUploadDialog({
>
<UploadCloud className='size-8 text-muted-foreground' />
<span className='text-sm font-medium'>
拖拽部署包到此处,或点击选择文件
{t('dropHint')}
</span>
<span className='text-xs text-muted-foreground'>
zip、tar.gz、tar.xz、tar.bz2、tar、7z
@@ -183,20 +184,23 @@ export function DeploymentUploadDialog({
/>
{file ? (
<FieldDescription>
已选择 {file.name}({formatBytes(file.size)})
{t('selected', {
name: file.name,
size: formatBytes(file.size),
})}
</FieldDescription>
) : (
<FieldDescription>请选择一个受支持的压缩包。</FieldDescription>
<FieldDescription>{t('chooseArchive')}</FieldDescription>
)}
</Field>
<Field>
<FieldLabel>部署入口</FieldLabel>
<FieldLabel>{t('entry')}</FieldLabel>
<div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'>
{pagesEntryPath(rootDir, entryFile)}
</div>
<FieldDescription>
入口来自项目设置;部署包上传不会覆盖该配置。
{t('entryHint')}
</FieldDescription>
</Field>
@@ -204,10 +208,10 @@ export function DeploymentUploadDialog({
<Field>
<div className='flex items-center justify-between text-xs text-muted-foreground'>
<span>
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'}
{uploadProgress >= 100 ? t('processing') : t('progress')}
</span>
<span>
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`}
{uploadProgress >= 100 ? t('pleaseWait') : `${uploadProgress}%`}
</span>
</div>
<Progress value={Math.min(uploadProgress, 100)} />
@@ -221,7 +225,7 @@ export function DeploymentUploadDialog({
variant='outline'
onClick={() => handleOpenChange(false)}
>
取消
{tCommon('cancel')}
</Button>
<Button
type='button'
@@ -233,7 +237,7 @@ export function DeploymentUploadDialog({
) : (
<UploadCloud data-icon='inline-start' />
)}
{uploadMutation.isPending ? '上传中...' : '上传并创建部署'}
{uploadMutation.isPending ? t('uploading') : t('submit')}
</Button>
</DialogFooter>
</DialogContent>
@@ -3,6 +3,7 @@
import { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -35,6 +36,8 @@ export function ProjectEditorDialog({
onOpenChange,
project,
}: ProjectEditorDialogProps) {
const t = useTranslations('pages');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
const form = usePagesProjectForm(project);
@@ -50,7 +53,7 @@ export function ProjectEditorDialog({
: PagesService.createProject(payload);
},
onSuccess: async () => {
toast.success(project ? '项目已更新' : '项目已创建');
toast.success(project ? t('updated') : t('created'));
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
if (project) {
await queryClient.invalidateQueries({
@@ -60,7 +63,7 @@ export function ProjectEditorDialog({
onOpenChange(false);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '保存失败');
toast.error(error instanceof Error ? error.message : t('saveFailed'));
},
});
@@ -69,11 +72,9 @@ export function ProjectEditorDialog({
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{project ? '编辑 Pages 项目' : '新建 Pages 项目'}
{project ? t('editTitle') : t('createTitle')}
</DialogTitle>
<DialogDescription>
配置静态站点托管参数,上传部署包后在代理规则中选择 Pages 上游。
</DialogDescription>
<DialogDescription>{t('editorDesc')}</DialogDescription>
</DialogHeader>
<form
@@ -86,7 +87,7 @@ export function ProjectEditorDialog({
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
{tCommon('cancel')}
</Button>
<Button
type='submit'
@@ -96,12 +97,12 @@ export function ProjectEditorDialog({
{mutation.isPending ? (
<>
<Loader2 className='size-4 animate-spin mr-1' />
保存中...
{t('saving')}
</>
) : project ? (
'保存修改'
t('saveChanges')
) : (
'创建项目'
t('createProject')
)}
</Button>
</DialogFooter>
@@ -1,6 +1,8 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useMemo } from 'react';
import { useForm, type UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
@@ -9,46 +11,50 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { type PagesProject } from '@/lib/services/openflare';
export const pagesProjectSchema = z
.object({
name: z.string().trim().min(1, '请输入项目名称').max(255),
slug: z.string().trim().max(255).optional().or(z.literal('')),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'],
message: '回退路径必须以 / 开头',
});
}
if (data.api_proxy_enabled) {
if (!data.api_proxy_path.startsWith('/')) {
function createPagesProjectSchema(t: (key: string) => string) {
return z
.object({
name: z.string().trim().min(1, t('form.nameRequired')).max(255),
slug: z.string().trim().max(255).optional().or(z.literal('')),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
entry_file: z.string().trim().min(1, t('form.entryRequired')).max(512),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: '匹配路径必须以 / 开头',
path: ['spa_fallback_path'],
message: t('form.fallbackPathPrefix'),
});
}
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: '后端地址必须以 http:// 或 https:// 开头',
});
if (data.api_proxy_enabled) {
if (!data.api_proxy_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: t('form.matchPathPrefix'),
});
}
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: t('form.backendPrefix'),
});
}
}
}
});
});
}
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
export type PagesProjectFormValues = z.infer<
ReturnType<typeof createPagesProjectSchema>
>;
export function toFormValues(
project?: PagesProject | null,
@@ -110,8 +116,10 @@ export function buildProjectPayload(
}
export function usePagesProjectForm(project?: PagesProject | null) {
const t = useTranslations('pages');
const schema = useMemo(() => createPagesProjectSchema((key) => t(key)), [t]);
return useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema),
resolver: zodResolver(schema),
defaultValues: toFormValues(project),
});
}
@@ -125,6 +133,7 @@ export function ProjectFormFields({
form,
idPrefix = '',
}: ProjectFormFieldsProps) {
const t = useTranslations('pages');
const spaEnabled = form.watch('spa_fallback_enabled');
const apiEnabled = form.watch('api_proxy_enabled');
const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name);
@@ -133,7 +142,7 @@ export function ProjectFormFields({
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('name')}>项目名称</Label>
<Label htmlFor={fieldId('name')}>{t('form.name')}</Label>
<Input id={fieldId('name')} {...form.register('name')} />
{form.formState.errors.name ? (
<p className='text-xs text-destructive'>
@@ -142,10 +151,10 @@ export function ProjectFormFields({
) : null}
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('slug')}>项目标识</Label>
<Label htmlFor={fieldId('slug')}>{t('form.slug')}</Label>
<Input
id={fieldId('slug')}
placeholder='留空自动生成'
placeholder={t('form.slugPlaceholder')}
{...form.register('slug')}
/>
</div>
@@ -153,14 +162,14 @@ export function ProjectFormFields({
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('entry_file')}>入口文件</Label>
<Label htmlFor={fieldId('entry_file')}>{t('form.entryFile')}</Label>
<Input id={fieldId('entry_file')} {...form.register('entry_file')} />
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('root_dir')}>根目录</Label>
<Label htmlFor={fieldId('root_dir')}>{t('form.rootDir')}</Label>
<Input
id={fieldId('root_dir')}
placeholder='可选'
placeholder={t('form.optional')}
{...form.register('root_dir')}
/>
</div>
@@ -168,9 +177,9 @@ export function ProjectFormFields({
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>SPA fallback</p>
<p className='text-sm font-medium'>{t('form.spaFallback')}</p>
<p className='text-xs text-muted-foreground'>
未命中静态文件时回退到指定路径
{t('form.spaFallbackDesc')}
</p>
</div>
<Switch
@@ -184,7 +193,9 @@ export function ProjectFormFields({
</div>
{spaEnabled ? (
<div className='space-y-1.5'>
<Label htmlFor={fieldId('spa_fallback_path')}>回退路径</Label>
<Label htmlFor={fieldId('spa_fallback_path')}>
{t('form.fallbackPath')}
</Label>
<Input
id={fieldId('spa_fallback_path')}
{...form.register('spa_fallback_path')}
@@ -194,9 +205,9 @@ export function ProjectFormFields({
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>API 反向代理</p>
<p className='text-sm font-medium'>{t('form.apiProxy')}</p>
<p className='text-xs text-muted-foreground'>
为静态站点附加 API 反代规则
{t('form.apiProxyDesc')}
</p>
</div>
<Switch
@@ -209,21 +220,27 @@ export function ProjectFormFields({
{apiEnabled ? (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_path')}>匹配路径</Label>
<Label htmlFor={fieldId('api_proxy_path')}>
{t('form.matchPath')}
</Label>
<Input
id={fieldId('api_proxy_path')}
{...form.register('api_proxy_path')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_pass')}>后端地址</Label>
<Label htmlFor={fieldId('api_proxy_pass')}>
{t('form.backend')}
</Label>
<Input
id={fieldId('api_proxy_pass')}
{...form.register('api_proxy_pass')}
/>
</div>
<div className='space-y-1.5 md:col-span-2'>
<Label htmlFor={fieldId('api_proxy_rewrite')}>重写规则</Label>
<Label htmlFor={fieldId('api_proxy_rewrite')}>
{t('form.rewrite')}
</Label>
<Input
id={fieldId('api_proxy_rewrite')}
{...form.register('api_proxy_rewrite')}

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