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` 注册。 - 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。
- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。 - 回调/`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] ## [Unreleased]
### 新增
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。
### 🛠 修复 ### 🛠 修复
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。 - 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。 - 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
+1
View File
@@ -33,6 +33,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) | | **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) | | **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) |
| **日志存储** | 访问日志与可观测时序走可切换日志主库(随业务主库或 ClickHouse);关闭 ClickHouse 后仍可写可查 | [日志存储解耦](./logstore.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'; 'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -60,6 +61,7 @@ export function AccessLogDetailDialog({
item: AccessLogItem | null; item: AccessLogItem | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const t = useTranslations('accessLogs');
// Keep last selected item while the dialog closes to avoid empty-state flash. // Keep last selected item while the dialog closes to avoid empty-state flash.
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item); const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
if (item && item !== displayItem) { if (item && item !== displayItem) {
@@ -71,21 +73,23 @@ export function AccessLogDetailDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'> <DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
<DialogHeader> <DialogHeader>
<DialogTitle>访问日志详情</DialogTitle> <DialogTitle>{t('detail.title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('detail.description')}</DialogDescription>
本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。
</DialogDescription>
</DialogHeader> </DialogHeader>
{activeItem ? ( {activeItem ? (
<div className='grid gap-4 sm:grid-cols-2'> <div className='grid gap-4 sm:grid-cols-2'>
<DetailField label='日志 ID' value={activeItem.id || '—'} mono />
<DetailField <DetailField
label='请求时间' label={t('detail.logId')}
value={activeItem.id || '—'}
mono
/>
<DetailField
label={t('detail.requestTime')}
value={formatDateTime(activeItem.logged_at)} value={formatDateTime(activeItem.logged_at)}
/> />
<DetailField <DetailField
label='入库时间' label={t('detail.storedAt')}
value={ value={
activeItem.created_at activeItem.created_at
? formatDateTime(activeItem.created_at) ? formatDateTime(activeItem.created_at)
@@ -93,23 +97,29 @@ export function AccessLogDetailDialog({
} }
/> />
<DetailField <DetailField
label='节点' label={t('detail.node')}
value={activeItem.node_name || activeItem.node_id || '—'} value={activeItem.node_name || activeItem.node_id || '—'}
/> />
<DetailField <DetailField
label='节点 ID' label={t('detail.nodeId')}
value={activeItem.node_id || '—'} value={activeItem.node_id || '—'}
mono mono
/> />
<DetailField <DetailField
label='客户端 IP' label={t('detail.clientIp')}
value={activeItem.remote_addr || '—'} value={activeItem.remote_addr || '—'}
mono mono
/> />
<DetailField label='地区' value={activeItem.region || '—'} />
<DetailField label='域名' value={activeItem.host || '—'} />
<DetailField <DetailField
label='状态码' label={t('detail.region')}
value={activeItem.region || '—'}
/>
<DetailField
label={t('detail.host')}
value={activeItem.host || '—'}
/>
<DetailField
label={t('detail.status')}
value={ value={
<Badge variant='outline' className='text-[10px]'> <Badge variant='outline' className='text-[10px]'>
{activeItem.status_code} {activeItem.status_code}
@@ -117,12 +127,13 @@ export function AccessLogDetailDialog({
} }
/> />
<DetailField <DetailField
label='缓存' label={t('detail.cache')}
value={ value={
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline' className='text-[10px]'> <Badge variant='outline' className='text-[10px]'>
{cacheOutcomeLabel( {cacheOutcomeLabel(
resolveCacheOutcome(activeItem.cache_status), resolveCacheOutcome(activeItem.cache_status),
(key) => t(key),
)} )}
</Badge> </Badge>
<span className='font-mono text-xs text-muted-foreground'> <span className='font-mono text-xs text-muted-foreground'>
@@ -132,19 +143,19 @@ export function AccessLogDetailDialog({
} }
/> />
<DetailField <DetailField
label='出站流量' label={t('detail.bytesSent')}
value={formatBytes(activeItem.bytes_sent ?? 0)} value={formatBytes(activeItem.bytes_sent ?? 0)}
/> />
<DetailField <DetailField
label='入站流量' label={t('detail.requestLength')}
value={formatBytes(activeItem.request_length ?? 0)} value={formatBytes(activeItem.request_length ?? 0)}
/> />
<DetailField <DetailField
label='请求耗时' label={t('detail.duration')}
value={formatRequestTimeMs(activeItem.request_time_ms)} value={formatRequestTimeMs(activeItem.request_time_ms)}
/> />
<DetailField <DetailField
label='路径' label={t('detail.path')}
value={activeItem.path || '—'} value={activeItem.path || '—'}
full full
mono mono
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { CalendarIcon, ChevronDown, Search } from 'lucide-react'; import { CalendarIcon, ChevronDown, Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar'; import { Calendar } from '@/components/ui/calendar';
@@ -91,6 +92,7 @@ function DateTimePicker({
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
const t = useTranslations('accessLogs.filters');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const current = value ? new Date(value) : undefined; const current = value ? new Date(value) : undefined;
@@ -121,7 +123,7 @@ function DateTimePicker({
{current ? ( {current ? (
format(current, 'yyyy-MM-dd HH:mm') format(current, 'yyyy-MM-dd HH:mm')
) : ( ) : (
<span className='text-muted-foreground'>选择时间</span> <span className='text-muted-foreground'>{t('pickTime')}</span>
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -153,12 +155,13 @@ export function AccessLogFilters({
onSearch, onSearch,
onReset, onReset,
}: AccessLogFiltersProps) { }: AccessLogFiltersProps) {
const t = useTranslations('accessLogs.filters');
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
return ( return (
<div className='space-y-3'> <div className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'> <div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
<FilterField label='来源 IP'> <FilterField label={t('remoteAddr')}>
<Input <Input
value={draft.remoteAddr} value={draft.remoteAddr}
onChange={(e) => onChange={(e) =>
@@ -167,22 +170,22 @@ export function AccessLogFilters({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(); if (e.key === 'Enter') onSearch();
}} }}
placeholder='按 IP 搜索' placeholder={t('remoteAddrPlaceholder')}
className='h-9 text-xs' className='h-9 text-xs'
/> />
</FilterField> </FilterField>
<FilterField label='访问域名'> <FilterField label={t('host')}>
<Input <Input
value={draft.host} value={draft.host}
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })} onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(); if (e.key === 'Enter') onSearch();
}} }}
placeholder='按域名搜索' placeholder={t('hostPlaceholder')}
className='h-9 text-xs' className='h-9 text-xs'
/> />
</FilterField> </FilterField>
<FilterField label='状态码'> <FilterField label={t('statusCode')}>
<Input <Input
value={draft.statusCode} value={draft.statusCode}
onChange={(e) => onChange={(e) =>
@@ -194,7 +197,7 @@ export function AccessLogFilters({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(); if (e.key === 'Enter') onSearch();
}} }}
placeholder='输入状态码,如 404' placeholder={t('statusCodePlaceholder')}
className='h-9 text-xs' className='h-9 text-xs'
/> />
</FilterField> </FilterField>
@@ -207,7 +210,7 @@ export function AccessLogFilters({
size='sm' size='sm'
className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground' className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground'
> >
更多筛选 {t('more')}
<ChevronDown <ChevronDown
className={`size-3.5 transition-transform ${ className={`size-3.5 transition-transform ${
moreOpen ? 'rotate-180' : '' moreOpen ? 'rotate-180' : ''
@@ -217,7 +220,7 @@ export function AccessLogFilters({
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<div className='grid gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3'> <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'> <div className='relative'>
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' /> <Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
<Input <Input
@@ -228,12 +231,12 @@ export function AccessLogFilters({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(); if (e.key === 'Enter') onSearch();
}} }}
placeholder='按 node_id 搜索' placeholder={t('nodeIdPlaceholder')}
className='pl-8 h-9 text-xs' className='pl-8 h-9 text-xs'
/> />
</div> </div>
</FilterField> </FilterField>
<FilterField label='请求路径'> <FilterField label={t('path')}>
<Input <Input
value={draft.path} value={draft.path}
onChange={(e) => onChange={(e) =>
@@ -242,11 +245,11 @@ export function AccessLogFilters({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(); if (e.key === 'Enter') onSearch();
}} }}
placeholder='按路径搜索' placeholder={t('pathPlaceholder')}
className='h-9 text-xs' className='h-9 text-xs'
/> />
</FilterField> </FilterField>
<FilterField label='时间范围'> <FilterField label={t('timeRange')}>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
<DateTimePicker <DateTimePicker
value={draft.since} 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='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]'> <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 <Select
value={String(pageSize)} value={String(pageSize)}
onValueChange={(value) => onValueChange={(value) =>
@@ -281,7 +286,7 @@ export function AccessLogFilters({
<SelectContent> <SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => ( {PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}> <SelectItem key={option} value={String(option)}>
{option} 条 {t('pageSizeItem', { count: option })}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -289,10 +294,10 @@ export function AccessLogFilters({
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
<Button size='sm' onClick={onSearch}> <Button size='sm' onClick={onSearch}>
筛选 {t('apply')}
</Button> </Button>
<Button variant='outline' size='sm' onClick={onReset}> <Button variant='outline' size='sm' onClick={onReset}>
清空 {t('clear')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -19,43 +19,43 @@ export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
export const OVERVIEW_RANGE_OPTIONS: { export const OVERVIEW_RANGE_OPTIONS: {
value: OverviewRangeHours; value: OverviewRangeHours;
label: string; labelKey: `range.h${OverviewRangeHours}`;
}[] = [ }[] = [
{ value: 24, label: '24 小时' }, { value: 24, labelKey: 'range.h24' },
{ value: 168, label: '7 天' }, { value: 168, labelKey: 'range.h168' },
{ value: 360, label: '15 天' }, { value: 360, labelKey: 'range.h360' },
{ value: 720, label: '30 天' }, { value: 720, labelKey: 'range.h720' },
]; ];
export const RATE_LIMIT_RANGE_OPTIONS: { export const RATE_LIMIT_RANGE_OPTIONS: {
value: RateLimitRangeHours; value: RateLimitRangeHours;
label: string; labelKey: 'range.h24' | 'range.h72';
}[] = [ }[] = [
{ value: 24, label: '24 小时' }, { value: 24, labelKey: 'range.h24' },
{ value: 72, label: '3 天' }, { value: 72, labelKey: 'range.h72' },
]; ];
export const DETAIL_SORT_OPTIONS = [ export const DETAIL_SORT_OPTIONS = [
{ value: 'logged_at:desc', label: '时间从新到旧' }, { value: 'logged_at:desc', labelKey: 'sort.logged_at:desc' },
{ value: 'logged_at:asc', label: '时间从旧到新' }, { value: 'logged_at:asc', labelKey: 'sort.logged_at:asc' },
{ value: 'status_code:desc', label: '状态码从高到低' }, { value: 'status_code:desc', labelKey: 'sort.status_code:desc' },
{ value: 'status_code:asc', label: '状态码从低到高' }, { value: 'status_code:asc', labelKey: 'sort.status_code:asc' },
{ value: 'remote_addr:asc', label: 'IP 正序' }, { value: 'remote_addr:asc', labelKey: 'sort.remote_addr:asc' },
{ value: 'remote_addr:desc', label: 'IP 倒序' }, { value: 'remote_addr:desc', labelKey: 'sort.remote_addr:desc' },
]; ] as const;
export const IP_SORT_OPTIONS = [ export const IP_SORT_OPTIONS = [
{ value: 'total_requests:desc', label: '请求数从高到低' }, { value: 'total_requests:desc', labelKey: 'sort.total_requests:desc' },
{ value: 'total_requests:asc', label: '请求数从低到高' }, { value: 'total_requests:asc', labelKey: 'sort.total_requests:asc' },
{ value: 'request_length:desc', label: '入站从高到低' }, { value: 'request_length:desc', labelKey: 'sort.request_length:desc' },
{ value: 'request_length:asc', label: '入站从低到高' }, { value: 'request_length:asc', labelKey: 'sort.request_length:asc' },
{ value: 'bytes_sent:desc', label: '出站从高到低' }, { value: 'bytes_sent:desc', labelKey: 'sort.bytes_sent:desc' },
{ value: 'bytes_sent:asc', label: '出站从低到高' }, { value: 'bytes_sent:asc', labelKey: 'sort.bytes_sent:asc' },
{ value: 'success_ratio:desc', label: '2xx 比例从高到低' }, { value: 'success_ratio:desc', labelKey: 'sort.success_ratio:desc' },
{ value: 'success_ratio:asc', label: '2xx 比例从低到高' }, { value: 'success_ratio:asc', labelKey: 'sort.success_ratio:asc' },
{ value: 'last_seen_at:desc', label: '最后访问从新到旧' }, { value: 'last_seen_at:desc', labelKey: 'sort.last_seen_at:desc' },
{ value: 'last_seen_at:asc', label: '最后访问从旧到新' }, { value: 'last_seen_at:asc', labelKey: 'sort.last_seen_at:asc' },
]; ] as const;
export function parseSortValue(value: string) { export function parseSortValue(value: string) {
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':'); const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
@@ -72,10 +72,16 @@ export function formatCompactNumber(value: number) {
}).format(value); }).format(value);
} }
export function formatOverviewRangeHint(hours: number) { export function formatOverviewRangeHint(
if (hours <= 24) return '近 24 小时'; hours: number,
if (hours % 24 === 0) return `近 ${hours / 24} 天`; t: (
return `近 ${hours} 小时`; 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) { export function formatOverviewTrendLabel(value: string, hours: number) {
@@ -112,13 +118,9 @@ export function resolveCacheOutcome(
return 'uncached'; return 'uncached';
} }
export function cacheOutcomeLabel(outcome: CacheOutcome) { export function cacheOutcomeLabel(
switch (outcome) { outcome: CacheOutcome,
case 'hit': t: (key: `cache.${CacheOutcome}`) => string,
return '命中'; ) {
case 'origin': return t(`cache.${outcome}`);
return '回源';
default:
return '未缓存';
}
} }
@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { import {
AlertDialog, AlertDialog,
@@ -38,6 +39,8 @@ export function CleanupDialog({
onConfirm, onConfirm,
loading, loading,
}: CleanupDialogProps) { }: CleanupDialogProps) {
const t = useTranslations('accessLogs.cleanupDialog');
const tCommon = useTranslations('common');
const [mode, setMode] = useState<string>('7'); const [mode, setMode] = useState<string>('7');
const [customDays, setCustomDays] = useState('14'); const [customDays, setCustomDays] = useState('14');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -49,7 +52,7 @@ export function CleanupDialog({
: Number.parseInt(mode, 10); : Number.parseInt(mode, 10);
if (!Number.isFinite(retentionDays) || retentionDays < 1) { if (!Number.isFinite(retentionDays) || retentionDays < 1) {
setError('保留天数必须大于 0'); setError(t('invalidDays'));
return; return;
} }
@@ -61,15 +64,13 @@ export function CleanupDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>清理访问日志</AlertDialogTitle> <AlertDialogTitle>{t('title')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>{t('description')}</AlertDialogDescription>
删除早于指定保留天数的访问日志记录,操作不可恢复。
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className='space-y-3'> <div className='space-y-3'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label>保留策略</Label> <Label>{t('policy')}</Label>
<Select value={mode} onValueChange={setMode}> <Select value={mode} onValueChange={setMode}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -77,17 +78,17 @@ export function CleanupDialog({
<SelectContent> <SelectContent>
{CLEANUP_PRESETS.map((days) => ( {CLEANUP_PRESETS.map((days) => (
<SelectItem key={days} value={String(days)}> <SelectItem key={days} value={String(days)}>
保留最近 {days} 天 {t('keepDays', { days })}
</SelectItem> </SelectItem>
))} ))}
<SelectItem value='custom'>自定义天数</SelectItem> <SelectItem value='custom'>{t('custom')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{mode === 'custom' ? ( {mode === 'custom' ? (
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='customDays'>自定义保留天数</Label> <Label htmlFor='customDays'>{t('customDays')}</Label>
<Input <Input
id='customDays' id='customDays'
type='number' type='number'
@@ -103,7 +104,9 @@ export function CleanupDialog({
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel> <AlertDialogCancel disabled={loading}>
{tCommon('cancel')}
</AlertDialogCancel>
<Button <Button
variant='destructive' variant='destructive'
onClick={handleConfirm} onClick={handleConfirm}
@@ -112,10 +115,10 @@ export function CleanupDialog({
{loading ? ( {loading ? (
<> <>
<Loader2 className='size-4 animate-spin mr-1' /> <Loader2 className='size-4 animate-spin mr-1' />
清理中... {t('cleaning')}
</> </>
) : ( ) : (
'确认清理' t('confirm')
)} )}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error'; import { ErrorInline } from '@/components/layout/error';
@@ -65,9 +66,12 @@ function PaginationBar({
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
}) { }) {
const t = useTranslations('accessLogs.detail');
return ( return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'> <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'> <div className='flex gap-2'>
<Button <Button
variant='outline' variant='outline'
@@ -75,7 +79,7 @@ function PaginationBar({
disabled={loading || page <= 0} disabled={loading || page <= 0}
onClick={onPrev} onClick={onPrev}
> >
上一页 {t('prev')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -83,7 +87,7 @@ function PaginationBar({
disabled={loading || !hasMore} disabled={loading || !hasMore}
onClick={onNext} onClick={onNext}
> >
下一页 {t('next')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -113,6 +117,7 @@ export function DetailTab({
onNextPage: () => void; onNextPage: () => void;
isFetching: boolean; isFetching: boolean;
}) { }) {
const t = useTranslations('accessLogs');
const [selected, setSelected] = useState<AccessLogItem | null>(null); const [selected, setSelected] = useState<AccessLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false); 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='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'> <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}> <Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'> <SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue /> <SelectValue />
@@ -128,7 +133,7 @@ export function DetailTab({
<SelectContent> <SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => ( {DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {t(option.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -137,25 +142,25 @@ export function DetailTab({
{error ? ( {error ? (
<div className='p-4'> <div className='p-4'>
<ErrorInline <ErrorInline
message={error.message || '加载失败'} message={error.message || t('loadFailed')}
onRetry={onRetry} onRetry={onRetry}
/> />
</div> </div>
) : loading ? ( ) : loading ? (
<LoadingStateWithBorder /> <LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? ( ) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' /> <EmptyStateWithBorder title={t('detail.empty')} />
) : ( ) : (
<Table> <Table>
<TableHeader className='bg-muted/40'> <TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'> <TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead> <TableHead className='text-xs'>{t('detail.time')}</TableHead>
<TableHead className='text-xs'>节点</TableHead> <TableHead className='text-xs'>{t('detail.node')}</TableHead>
<TableHead className='text-xs'>IP</TableHead> <TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead> <TableHead className='text-xs'>{t('detail.host')}</TableHead>
<TableHead className='text-xs'>路径</TableHead> <TableHead className='text-xs'>{t('detail.path')}</TableHead>
<TableHead className='text-xs'>缓存</TableHead> <TableHead className='text-xs'>{t('detail.cache')}</TableHead>
<TableHead className='text-xs'>状态码</TableHead> <TableHead className='text-xs'>{t('detail.status')}</TableHead>
<TableHead className='w-12 text-center text-xs' /> <TableHead className='w-12 text-center text-xs' />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -190,7 +195,7 @@ export function DetailTab({
className='text-[10px]' className='text-[10px]'
title={item.cache_status || undefined} title={item.cache_status || undefined}
> >
{cacheOutcomeLabel(outcome)} {cacheOutcomeLabel(outcome, (key) => t(key))}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -214,7 +219,7 @@ export function DetailTab({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='text-xs'> <TooltipContent side='top' className='text-xs'>
查看详情 {t('detail.view')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react'; import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { RankChart } from '@/components/data/rank-chart'; import { RankChart } from '@/components/data/rank-chart';
@@ -50,9 +51,12 @@ import {
type OverviewRangeHours, type OverviewRangeHours,
} from './access-log-utils'; } from './access-log-utils';
const trendChartConfig = { function useTrendChartConfig() {
requests: { label: '请求数', color: 'hsl(var(--primary))' }, const t = useTranslations('accessLogs');
} satisfies ChartConfig; return {
requests: { label: t('ip.requests'), color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
}
function resolveBucketMinutes(hours: number) { function resolveBucketMinutes(hours: number) {
if (hours <= 24) return 30; if (hours <= 24) return 30;
@@ -104,6 +108,7 @@ function MiniRankCard({
items: { label: string; value: number }[]; items: { label: string; value: number }[];
color: string; color: string;
}) { }) {
const t = useTranslations('accessLogs.analysis');
return ( return (
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<p className='mb-2 text-sm font-medium'>{title}</p> <p className='mb-2 text-sm font-medium'>{title}</p>
@@ -111,7 +116,7 @@ function MiniRankCard({
items={items} items={items}
color={color} color={color}
className='!h-[220px]' className='!h-[220px]'
emptyMessage={`暂无 ${title} 数据`} emptyMessage={t('emptySeries', { title })}
/> />
</div> </div>
); );
@@ -126,6 +131,7 @@ function AddToIPGroupPanel({
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const t = useTranslations('accessLogs.analysis');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [selectedGroupId, setSelectedGroupId] = useState<string>(''); const [selectedGroupId, setSelectedGroupId] = useState<string>('');
@@ -179,7 +185,7 @@ function AddToIPGroupPanel({
const groupId = Number.parseInt(selectedGroupId, 10); const groupId = Number.parseInt(selectedGroupId, 10);
const group = addableGroups.find((item) => item.id === groupId); const group = addableGroups.find((item) => item.id === groupId);
if (!group) { if (!group) {
toast.error('请选择要加入的 IP 组'); toast.error(t('selectGroup'));
return; return;
} }
try { try {
@@ -187,10 +193,10 @@ function AddToIPGroupPanel({
group, group,
nextList: [...(group.ip_list ?? []), ip.trim()], nextList: [...(group.ip_list ?? []), ip.trim()],
}); });
toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`); toast.success(t('added', { ip, name: group.name }));
setSelectedGroupId(''); setSelectedGroupId('');
} catch (error) { } 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(), (entry) => entry.trim() !== ip.trim(),
), ),
}); });
toast.success(`已从 IP 组「${group.name}」移除 ${ip}`); toast.success(t('removed', { name: group.name, ip }));
} catch (error) { } 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'> <DialogContent className='max-w-lg'>
<DialogHeader> <DialogHeader>
<DialogTitle>将 IP 加入 IP 组</DialogTitle> <DialogTitle>{t('joinTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
目标 IP: {t('targetIp')}
<span className='font-mono text-foreground'>{ip}</span> <span className='font-mono text-foreground'>{ip}</span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{groupsQuery.isLoading ? ( {groupsQuery.isLoading ? (
<LoadingStateWithBorder title='加载 IP 组' /> <LoadingStateWithBorder title={t('loadingGroups')} />
) : groupsQuery.isError ? ( ) : groupsQuery.isError ? (
<ErrorInline <ErrorInline
message={ message={
groupsQuery.error instanceof Error groupsQuery.error instanceof Error
? groupsQuery.error.message ? groupsQuery.error.message
: '加载 IP 组失败' : t('loadGroupsFailed')
} }
onRetry={() => void groupsQuery.refetch()} onRetry={() => void groupsQuery.refetch()}
/> />
@@ -243,10 +249,10 @@ function AddToIPGroupPanel({
{matchedGroups.length > 0 ? ( {matchedGroups.length > 0 ? (
<div className='space-y-2 rounded-lg border border-dashed p-3'> <div className='space-y-2 rounded-lg border border-dashed p-3'>
<p className='text-sm font-medium text-foreground'> <p className='text-sm font-medium text-foreground'>
该 IP 已存在于以下 IP 组 {t('alreadyIn')}
</p> </p>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
可选择删除,或继续添加到其他 IP 组。 {t('alreadyInHint')}
</p> </p>
<div className='space-y-2'> <div className='space-y-2'>
{matchedGroups.map((group) => ( {matchedGroups.map((group) => (
@@ -259,7 +265,10 @@ function AddToIPGroupPanel({
{group.name} {group.name}
</p> </p>
<p className='text-[11px] text-muted-foreground'> <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> </p>
</div> </div>
{group.type === 'manual' ? ( {group.type === 'manual' ? (
@@ -275,13 +284,13 @@ function AddToIPGroupPanel({
) : ( ) : (
<> <>
<Trash2 className='mr-1 size-3.5' /> <Trash2 className='mr-1 size-3.5' />
删除 {t('delete')}
</> </>
)} )}
</Button> </Button>
) : ( ) : (
<Badge variant='outline' className='text-[10px]'> <Badge variant='outline' className='text-[10px]'>
不可手动删除 {t('cannotDelete')}
</Badge> </Badge>
)} )}
</div> </div>
@@ -290,15 +299,15 @@ function AddToIPGroupPanel({
</div> </div>
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
该 IP 尚未加入任何 IP 组。 {t('notInAny')}
</p> </p>
)} )}
<div className='space-y-2'> <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 ? ( {addableGroups.length === 0 ? (
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
没有可写入的手动 IP 组(或已全部包含该 IP)。 {t('noWritable')}
</p> </p>
) : ( ) : (
<Select <Select
@@ -306,7 +315,7 @@ function AddToIPGroupPanel({
onValueChange={setSelectedGroupId} onValueChange={setSelectedGroupId}
> >
<SelectTrigger className='h-9 text-xs'> <SelectTrigger className='h-9 text-xs'>
<SelectValue placeholder='选择手动 IP 组' /> <SelectValue placeholder={t('selectManual')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{addableGroups.map((group) => ( {addableGroups.map((group) => (
@@ -323,7 +332,7 @@ function AddToIPGroupPanel({
<DialogFooter> <DialogFooter>
<Button variant='outline' onClick={onClose}> <Button variant='outline' onClick={onClose}>
关闭 {t('close')}
</Button> </Button>
<Button <Button
onClick={() => void handleAdd()} onClick={() => void handleAdd()}
@@ -336,10 +345,10 @@ function AddToIPGroupPanel({
{updateMutation.isPending ? ( {updateMutation.isPending ? (
<> <>
<Loader2 className='mr-1 size-3.5 animate-spin' /> <Loader2 className='mr-1 size-3.5 animate-spin' />
处理中... {t('processing')}
</> </>
) : ( ) : (
'加入所选 IP 组' t('joinSelected')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -361,6 +370,8 @@ export function IpAnalysisPanel({
*/ */
initialHours?: number; initialHours?: number;
}) { }) {
const t = useTranslations('accessLogs');
const trendChartConfig = useTrendChartConfig();
const [ipGroupOpen, setIpGroupOpen] = useState(false); const [ipGroupOpen, setIpGroupOpen] = useState(false);
const [rangeHours, setRangeHours] = useState(() => const [rangeHours, setRangeHours] = useState(() =>
clampAnalysisHours(initialHours), clampAnalysisHours(initialHours),
@@ -368,8 +379,8 @@ export function IpAnalysisPanel({
const bucketMinutes = resolveBucketMinutes(rangeHours); const bucketMinutes = resolveBucketMinutes(rangeHours);
const rangeHint = isOverviewPreset(rangeHours) const rangeHint = isOverviewPreset(rangeHours)
? formatOverviewRangeHint(rangeHours) ? formatOverviewRangeHint(rangeHours, (key, values) => t(key, values))
: `近 ${rangeHours} 小时`; : t('analysis.recentHours', { hours: rangeHours });
const trendQuery = useQuery({ const trendQuery = useQuery({
queryKey: [ queryKey: [
@@ -411,7 +422,7 @@ export function IpAnalysisPanel({
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching; const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
if (!ip) { if (!ip) {
return <EmptyStateWithBorder description='没有有效 IP。' />; return <EmptyStateWithBorder description={t('analysis.invalidIp')} />;
} }
return ( return (
@@ -423,7 +434,8 @@ export function IpAnalysisPanel({
variant='outline' variant='outline'
onClick={() => setIpGroupOpen(true)} onClick={() => setIpGroupOpen(true)}
> >
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组 <ShieldPlus className='mr-1 size-3.5' />
{t('analysis.addToGroup')}
</Button> </Button>
<ToggleGroup <ToggleGroup
type='single' type='single'
@@ -441,14 +453,14 @@ export function IpAnalysisPanel({
value={String(option.value)} value={String(option.value)}
className='px-2.5 text-xs' className='px-2.5 text-xs'
> >
{option.label} {t(option.labelKey)}
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
</ToggleGroup> </ToggleGroup>
</div> </div>
{isLoadingIP ? ( {isLoadingIP ? (
<LoadingStateWithBorder title='加载 IP 分析' /> <LoadingStateWithBorder title={t('analysis.loading')} />
) : ( ) : (
<div className='space-y-4'> <div className='space-y-4'>
{analysisQuery.isError ? ( {analysisQuery.isError ? (
@@ -456,34 +468,34 @@ export function IpAnalysisPanel({
message={ message={
analysisQuery.error instanceof Error analysisQuery.error instanceof Error
? analysisQuery.error.message ? analysisQuery.error.message
: '加载 IP 分析失败' : t('analysis.loadFailed')
} }
onRetry={() => void analysisQuery.refetch()} onRetry={() => void analysisQuery.refetch()}
/> />
) : analysis ? ( ) : analysis ? (
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'> <div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<MetricCard <MetricCard
label='总请求' label={t('analysis.totalRequests')}
value={formatCompactNumber(analysis.summary.total_requests)} value={formatCompactNumber(analysis.summary.total_requests)}
/> />
<MetricCard <MetricCard
label='错误数' label={t('analysis.errors')}
value={formatCompactNumber(analysis.summary.error_count)} value={formatCompactNumber(analysis.summary.error_count)}
/> />
<MetricCard <MetricCard
label='已提供带宽' label={t('analysis.bandwidth')}
value={formatBytes(analysis.summary.bandwidth_served)} value={formatBytes(analysis.summary.bandwidth_served)}
/> />
<MetricCard <MetricCard
label='接收数据' label={t('analysis.received')}
value={formatBytes(analysis.summary.bytes_received)} value={formatBytes(analysis.summary.bytes_received)}
/> />
<MetricCard <MetricCard
label='独立域名' label={t('analysis.uniqueHosts')}
value={formatCompactNumber(analysis.summary.unique_hosts)} value={formatCompactNumber(analysis.summary.unique_hosts)}
/> />
<MetricCard <MetricCard
label='独立路径' label={t('analysis.uniquePaths')}
value={formatCompactNumber(analysis.summary.unique_paths)} value={formatCompactNumber(analysis.summary.unique_paths)}
/> />
</div> </div>
@@ -492,9 +504,13 @@ export function IpAnalysisPanel({
<div className='space-y-3 rounded-lg border border-dashed p-4'> <div className='space-y-3 rounded-lg border border-dashed p-4'>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<div> <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'> <p className='text-xs text-muted-foreground'>
{ip} · {rangeHint} · {bucketMinutes} 分钟桶 {t('analysis.trendMeta', {
ip,
range: rangeHint,
minutes: bucketMinutes,
})}
</p> </p>
</div> </div>
<Button <Button
@@ -506,7 +522,7 @@ export function IpAnalysisPanel({
void analysisQuery.refetch(); void analysisQuery.refetch();
}} }}
> >
刷新 {t('analysis.refresh')}
</Button> </Button>
</div> </div>
@@ -515,13 +531,13 @@ export function IpAnalysisPanel({
message={ message={
trendQuery.error instanceof Error trendQuery.error instanceof Error
? trendQuery.error.message ? trendQuery.error.message
: '加载趋势失败' : t('analysis.trendFailed')
} }
onRetry={() => void trendQuery.refetch()} onRetry={() => void trendQuery.refetch()}
/> />
) : trendChartData.every((point) => point.requests === 0) ? ( ) : trendChartData.every((point) => point.requests === 0) ? (
<EmptyStateWithBorder <EmptyStateWithBorder
description={`该 IP 在${rangeHint}内没有访问记录。`} description={t('analysis.trendEmpty', { range: rangeHint })}
/> />
) : ( ) : (
<ChartContainer <ChartContainer
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { import {
@@ -31,19 +32,20 @@ export function IpDetailDialog({
setDisplayAddr(remoteAddr); setDisplayAddr(remoteAddr);
setDisplayRegion(region); setDisplayRegion(region);
} }
const t = useTranslations('accessLogs.ip');
const ip = remoteAddr ?? displayAddr ?? ''; const ip = remoteAddr ?? displayAddr ?? '';
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'> <DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
<DialogHeader> <DialogHeader>
<DialogTitle>IP 详情</DialogTitle> <DialogTitle>{t('dialogTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
<span className='font-mono text-foreground'>{ip || '—'}</span> <span className='font-mono text-foreground'>{ip || '—'}</span>
{displayRegion ? ( {displayRegion ? (
<span className='text-muted-foreground'> · {displayRegion}</span> <span className='text-muted-foreground'> · {displayRegion}</span>
) : null} ) : null}
。查看该 IP 的访问趋势、分布与 WAF IP 组操作。 {t('dialogDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<IpAnalysisPanel <IpAnalysisPanel
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Eye } from 'lucide-react'; import { Eye } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error'; import { ErrorInline } from '@/components/layout/error';
@@ -72,20 +73,21 @@ const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
export function validateCustomTimeRange( export function validateCustomTimeRange(
sinceLocal: string, sinceLocal: string,
untilLocal: string, untilLocal: string,
t?: (key: 'ip.needRange' | 'ip.invalidTime' | 'ip.endAfterStart' | 'ip.maxRange') => string,
): string | null { ): string | null {
if (!sinceLocal.trim() || !untilLocal.trim()) { if (!sinceLocal.trim() || !untilLocal.trim()) {
return '请填写开始与结束时间'; return t ? t('ip.needRange') : 'needRange';
} }
const sinceMs = new Date(sinceLocal).getTime(); const sinceMs = new Date(sinceLocal).getTime();
const untilMs = new Date(untilLocal).getTime(); const untilMs = new Date(untilLocal).getTime();
if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) { if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
return '时间格式无效'; return t ? t('ip.invalidTime') : 'invalidTime';
} }
if (untilMs <= sinceMs) { if (untilMs <= sinceMs) {
return '结束时间必须晚于开始时间'; return t ? t('ip.endAfterStart') : 'endAfterStart';
} }
if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) { if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
return '时间范围不能超过 30 天'; return t ? t('ip.maxRange') : 'maxRange';
} }
return null; return null;
} }
@@ -128,11 +130,12 @@ function PaginationBar({
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
}) { }) {
const t = useTranslations('accessLogs.ip');
return ( return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'> <div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
当前第 {page + 1} 页 {t('page', { page: page + 1 })}
{typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''} {typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}
</p> </p>
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
@@ -141,7 +144,7 @@ function PaginationBar({
disabled={loading || page <= 0} disabled={loading || page <= 0}
onClick={onPrev} onClick={onPrev}
> >
上一页 {t('prev')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -149,7 +152,7 @@ function PaginationBar({
disabled={loading || !hasMore} disabled={loading || !hasMore}
onClick={onNext} onClick={onNext}
> >
下一页 {t('next')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -201,6 +204,7 @@ export function IpTab({
onNextPage: () => void; onNextPage: () => void;
isFetching: boolean; isFetching: boolean;
}) { }) {
const t = useTranslations('accessLogs');
const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null); const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [draftSince, setDraftSince] = useState(customSince); const [draftSince, setDraftSince] = useState(customSince);
@@ -232,13 +236,15 @@ export function IpTab({
const rangeHint = const rangeHint =
timeMode === 'custom' && customSince && customUntil timeMode === 'custom' && customSince && customUntil
? '自定义区间' ? t('ip.customRange')
: timeMode === 'custom' : timeMode === 'custom'
? '自定义(未应用)' ? t('ip.customPending')
: formatOverviewRangeHint(hours); : formatOverviewRangeHint(hours, (key, values) => t(key, values));
const handleApplyCustom = () => { const handleApplyCustom = () => {
const message = validateCustomTimeRange(draftSince, draftUntil); const message = validateCustomTimeRange(draftSince, draftUntil, (key) =>
t(key),
);
if (message) { if (message) {
setCustomError(message); setCustomError(message);
return; return;
@@ -253,11 +259,11 @@ export function IpTab({
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'> <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='flex flex-wrap items-center justify-between gap-3'>
<div className='space-y-1'> <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'> <p className='text-xs text-muted-foreground'>
当前:{rangeHint} {t('ip.current', { range: rangeHint })}
{data?.total_ip != null {data?.total_ip != null
? ` · ${formatCompactNumber(data.total_ip)} 个 IP` ? t('ip.total', { count: data.total_ip })
: ''} : ''}
</p> </p>
</div> </div>
@@ -281,7 +287,7 @@ export function IpTab({
value={String(option.value)} value={String(option.value)}
className='px-2.5 text-xs' className='px-2.5 text-xs'
> >
{option.label} {t(option.labelKey)}
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
</ToggleGroup> </ToggleGroup>
@@ -291,7 +297,7 @@ export function IpTab({
className='h-8 text-xs' className='h-8 text-xs'
onClick={() => onTimeModeChange('custom')} onClick={() => onTimeModeChange('custom')}
> >
自定义 {t('ip.custom')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -300,7 +306,7 @@ export function IpTab({
<div className='space-y-2'> <div className='space-y-2'>
<div className='flex flex-wrap items-end gap-3'> <div className='flex flex-wrap items-end gap-3'>
<div className='space-y-1'> <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 <Input
type='datetime-local' type='datetime-local'
className='h-9 w-52 text-xs' className='h-9 w-52 text-xs'
@@ -312,7 +318,7 @@ export function IpTab({
/> />
</div> </div>
<div className='space-y-1'> <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 <Input
type='datetime-local' type='datetime-local'
className='h-9 w-52 text-xs' className='h-9 w-52 text-xs'
@@ -328,14 +334,14 @@ export function IpTab({
className='h-9 text-xs' className='h-9 text-xs'
onClick={handleApplyCustom} onClick={handleApplyCustom}
> >
应用 {t('ip.apply')}
</Button> </Button>
</div> </div>
{customError ? ( {customError ? (
<p className='text-xs text-destructive'>{customError}</p> <p className='text-xs text-destructive'>{customError}</p>
) : ( ) : (
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。 {t('ip.applyHint')}
</p> </p>
)} )}
</div> </div>
@@ -343,7 +349,7 @@ export function IpTab({
<div className='flex flex-wrap items-center gap-3'> <div className='flex flex-wrap items-center gap-3'>
<div className='space-y-1'> <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}> <Select value={sort} onValueChange={onSortChange}>
<SelectTrigger className='h-8 w-48 text-xs'> <SelectTrigger className='h-8 w-48 text-xs'>
<SelectValue /> <SelectValue />
@@ -351,14 +357,14 @@ export function IpTab({
<SelectContent> <SelectContent>
{IP_SORT_OPTIONS.map((option) => ( {IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {t(option.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className='space-y-1'> <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 <Select
value={String(pageSize)} value={String(pageSize)}
onValueChange={(value) => onValueChange={(value) =>
@@ -382,30 +388,38 @@ export function IpTab({
<div className='rounded-lg border border-dashed overflow-hidden bg-background'> <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'> <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> </div>
{error ? ( {error ? (
<div className='p-4'> <div className='p-4'>
<ErrorInline <ErrorInline
message={error.message || '加载失败'} message={error.message || t('loadFailed')}
onRetry={onRetry} onRetry={onRetry}
/> />
</div> </div>
) : loading ? ( ) : loading ? (
<LoadingStateWithBorder /> <LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? ( ) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无 IP 数据' /> <EmptyStateWithBorder title={t('ip.empty')} />
) : ( ) : (
<Table> <Table>
<TableHeader className='bg-muted/40'> <TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'> <TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead> <TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>地区</TableHead> <TableHead className='text-xs'>{t('ip.region')}</TableHead>
<TableHead className='text-xs text-right'>请求数</TableHead> <TableHead className='text-xs text-right'>
<TableHead className='text-xs text-right'>2xx 比例</TableHead> {t('ip.requests')}
<TableHead className='text-xs text-right'>入站</TableHead> </TableHead>
<TableHead className='text-xs text-right'>出站</TableHead> <TableHead className='text-xs text-right'>
<TableHead className='text-xs'>最后访问</TableHead> {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' /> <TableHead className='w-12 text-center text-xs' />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -453,7 +467,7 @@ export function IpTab({
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='text-xs'> <TooltipContent side='top' className='text-xs'>
查看 IP 详情 {t('ip.view')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TableCell> </TableCell>
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { EChartsOption } from 'echarts'; import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
@@ -278,6 +279,7 @@ export function OverviewTab({
onHostsChange: (hosts: string[]) => void; onHostsChange: (hosts: string[]) => void;
onRetry: () => void; onRetry: () => void;
}) { }) {
const t = useTranslations('accessLogs.overview');
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<OverviewToolbar <OverviewToolbar
@@ -289,18 +291,18 @@ export function OverviewTab({
{loading ? ( {loading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
title='加载访问概览' title={t('loadingTitle')}
description='正在聚合请求量、访问量与带宽趋势...' description={t('loadingDesc')}
/> />
) : error ? ( ) : error ? (
<ErrorInline <ErrorInline
message={error.message || '加载访问概览失败'} message={error.message || t('loadFailed')}
onRetry={onRetry} onRetry={onRetry}
/> />
) : !data ? ( ) : !data ? (
<EmptyStateWithBorder <EmptyStateWithBorder
title='暂无概览数据' title={t('emptyTitle')}
description='当前时间范围内没有可展示的访问统计。' description={t('emptyDesc')}
/> />
) : ( ) : (
<OverviewContent data={data} hours={hours} /> <OverviewContent data={data} hours={hours} />
@@ -316,13 +318,14 @@ function OverviewContent({
data: AccessLogOverview; data: AccessLogOverview;
hours: number; hours: number;
}) { }) {
const t = useTranslations('accessLogs');
const requestLabels = data.trends.requests.map((point) => const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours), formatOverviewTrendLabel(point.bucket_started_at, hours),
); );
const requestValues = data.trends.requests.map((point) => point.value); const requestValues = data.trends.requests.map((point) => point.value);
const visitValues = data.trends.visits.map((point) => point.value); const visitValues = data.trends.visits.map((point) => point.value);
const bandwidthValues = data.trends.bandwidth.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 ( return (
<> <>
@@ -339,7 +342,7 @@ function OverviewContent({
<SparklineMetricCard <SparklineMetricCard
title='Total Visits' title='Total Visits'
value={formatCompactNumber(data.summary.total_visits)} value={formatCompactNumber(data.summary.total_visits)}
hint={`${hint} · 独立访客`} hint={t('overview.uniqueVisitors', { hint })}
color='#38bdf8' color='#38bdf8'
fillColor='rgba(56, 189, 248, 0.16)' fillColor='rgba(56, 189, 248, 0.16)'
labels={requestLabels} labels={requestLabels}
@@ -348,7 +351,7 @@ function OverviewContent({
<SparklineMetricCard <SparklineMetricCard
title='Bandwidth Served' title='Bandwidth Served'
value={formatBytes(data.summary.bandwidth_served)} value={formatBytes(data.summary.bandwidth_served)}
hint={`${hint} · 已提供数据`} hint={t('overview.served', { hint })}
color='#34d399' color='#34d399'
fillColor='rgba(52, 211, 153, 0.16)' fillColor='rgba(52, 211, 153, 0.16)'
labels={requestLabels} labels={requestLabels}
@@ -363,7 +366,7 @@ function OverviewContent({
Requests over time Requests over time
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
观察请求量是否出现异常抬升或回落。 {t('overview.requestsHint')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -373,7 +376,7 @@ function OverviewContent({
showSummary={false} showSummary={false}
series={[ series={[
{ {
label: '请求量', label: t('overview.requests'),
color: '#f59e0b', color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)', fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area', variant: 'area',
@@ -387,32 +390,32 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard <PieDistributionCard
title='Requests by device type' title='Requests by device type'
description='按设备类型统计请求占比。' description={t('overview.deviceDesc')}
items={data.device_types ?? []} items={data.device_types ?? []}
emptyMessage='暂无设备类型数据' emptyMessage={t('overview.deviceEmpty')}
/> />
<PieDistributionCard <PieDistributionCard
title='Status code' title='Status code'
description='按 HTTP 状态码统计请求占比。' description={t('overview.statusDesc')}
items={data.status_codes ?? []} items={data.status_codes ?? []}
emptyMessage='暂无状态码分布数据' emptyMessage={t('overview.statusEmpty')}
/> />
</div> </div>
<div className='grid gap-6 xl:grid-cols-3'> <div className='grid gap-6 xl:grid-cols-3'>
<RankCard <RankCard
title='Top Paths' title='Top Paths'
description='访问量最高的请求路径。' description={t('overview.topPathsDesc')}
items={toRankItems(data.top_paths)} items={toRankItems(data.top_paths)}
/> />
<RankCard <RankCard
title='Top Hosts' title='Top Hosts'
description='流量集中的访问域名。' description={t('overview.topHostsDesc')}
items={toRankItems(data.top_hosts)} items={toRankItems(data.top_hosts)}
/> />
<RankCard <RankCard
title='Top IPs' title='Top IPs'
description='请求次数最多的来源 IP。' description={t('overview.topIpsDesc')}
items={toRankItems(data.top_ips)} items={toRankItems(data.top_ips)}
/> />
</div> </div>
@@ -420,17 +423,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'> <div className='grid gap-6 xl:grid-cols-3'>
<RankCard <RankCard
title='Top browsers' title='Top browsers'
description='按浏览器聚合的请求排行。' description={t('overview.browsersDesc')}
items={toRankItems(data.top_browsers)} items={toRankItems(data.top_browsers)}
/> />
<RankCard <RankCard
title='Top Operating System' title='Top Operating System'
description='按操作系统聚合的请求排行。' description={t('overview.osDesc')}
items={toRankItems(data.top_operating_systems)} items={toRankItems(data.top_operating_systems)}
/> />
<RankCard <RankCard
title='Top User-Agent' title='Top User-Agent'
description='原始 User-Agent 请求排行。' description={t('overview.uaDesc')}
items={toRankItems(data.top_user_agents)} items={toRankItems(data.top_user_agents)}
/> />
</div> </div>
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ChevronDown, Filter, Loader2, X } from 'lucide-react'; import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -71,6 +72,7 @@ export function OverviewHostFilter({
hosts: string[]; hosts: string[];
onHostsChange: (hosts: string[]) => void; onHostsChange: (hosts: string[]) => void;
}) { }) {
const t = useTranslations('accessLogs.toolbar');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>( const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
@@ -160,10 +162,14 @@ export function OverviewHostFilter({
hosts.length > 0 ? 'border-primary text-primary' : undefined, hosts.length > 0 ? 'border-primary text-primary' : undefined,
)} )}
title={ title={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选' hosts.length > 0
? t('hostsFiltered', { count: hosts.length })
: t('filterByHost')
} }
aria-label={ aria-label={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选' hosts.length > 0
? t('hostsFiltered', { count: hosts.length })
: t('filterByHost')
} }
> >
<Filter className='size-3.5' /> <Filter className='size-3.5' />
@@ -171,7 +177,7 @@ export function OverviewHostFilter({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align='end' className='w-96 space-y-3 p-3'> <PopoverContent align='end' className='w-96 space-y-3 p-3'>
<div className='flex items-center justify-between gap-2'> <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 ? ( {hosts.length > 0 ? (
<Button <Button
type='button' type='button'
@@ -181,25 +187,25 @@ export function OverviewHostFilter({
onClick={() => onHostsChange([])} onClick={() => onHostsChange([])}
> >
<X className='mr-1 size-3' /> <X className='mr-1 size-3' />
清除 {t('clear')}
</Button> </Button>
) : null} ) : null}
</div> </div>
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder='搜索 Zone 或域名' placeholder={t('searchPlaceholder')}
className='h-8 text-xs' className='h-8 text-xs'
/> />
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'> <div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
{zonesQuery.isLoading ? ( {zonesQuery.isLoading ? (
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'> <div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' /> <Loader2 className='size-3.5 animate-spin' />
加载域名… {t('loadingHosts')}
</div> </div>
) : zonesQuery.isError ? ( ) : zonesQuery.isError ? (
<div className='space-y-2 py-2'> <div className='space-y-2 py-2'>
<p className='text-xs text-destructive'>加载域名失败</p> <p className='text-xs text-destructive'>{t('loadHostsFailed')}</p>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -207,12 +213,12 @@ export function OverviewHostFilter({
className='h-7 text-xs' className='h-7 text-xs'
onClick={() => void zonesQuery.refetch()} onClick={() => void zonesQuery.refetch()}
> >
重试 {t('retry')}
</Button> </Button>
</div> </div>
) : filteredZones.length === 0 ? ( ) : filteredZones.length === 0 ? (
<p className='py-6 text-center text-xs text-muted-foreground'> <p className='py-6 text-center text-xs text-muted-foreground'>
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'} {zones.length === 0 ? t('noRegistered') : t('noMatch')}
</p> </p>
) : ( ) : (
filteredZones.map((zone) => { filteredZones.map((zone) => {
@@ -247,7 +253,7 @@ export function OverviewHostFilter({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
toggleZone(zone.domains, checked) toggleZone(zone.domains, checked)
} }
aria-label={`选择 Zone ${zone.zoneName}`} aria-label={t('selectZone', { name: zone.zoneName })}
/> />
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
@@ -286,7 +292,7 @@ export function OverviewHostFilter({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
toggleHost(domain, checked) toggleHost(domain, checked)
} }
aria-label={`选择域名 ${domain}`} aria-label={t('selectHost', { name: domain })}
/> />
<span className='min-w-0 flex-1 truncate font-mono'> <span className='min-w-0 flex-1 truncate font-mono'>
{domain} {domain}
@@ -311,7 +317,7 @@ export function OverviewToolbar({
hosts, hosts,
onHoursChange, onHoursChange,
onHostsChange, onHostsChange,
rangeOptions = OVERVIEW_RANGE_OPTIONS, rangeOptions,
}: { }: {
hours: OverviewRangeHours | RateLimitRangeHours; hours: OverviewRangeHours | RateLimitRangeHours;
hosts: string[]; hosts: string[];
@@ -319,11 +325,20 @@ export function OverviewToolbar({
onHostsChange: (hosts: string[]) => void; onHostsChange: (hosts: string[]) => void;
rangeOptions?: ReadonlyArray<{ value: number; label: string }>; 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 ( return (
<div className='flex flex-wrap items-center justify-end gap-2'> <div className='flex flex-wrap items-center justify-end gap-2'>
{hosts.length > 0 ? ( {hosts.length > 0 ? (
<Badge variant='secondary' className='max-w-[260px] truncate'> <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> </Badge>
) : null} ) : null}
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} /> <OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
@@ -338,7 +353,7 @@ export function OverviewToolbar({
size='sm' size='sm'
className='justify-end' className='justify-end'
> >
{rangeOptions.map((option) => ( {options.map((option) => (
<ToggleGroupItem <ToggleGroupItem
key={option.value} key={option.value}
value={String(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 { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react'; import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { LoadingStateWithBorder } from '@/components/layout/loading'; import { LoadingStateWithBorder } from '@/components/layout/loading';
@@ -45,6 +46,7 @@ function resolveTab(value: string | null): AccessLogTab {
} }
function AccessLogsPageContent() { function AccessLogsPageContent() {
const t = useTranslations('accessLogs');
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -152,14 +154,14 @@ function AccessLogsPageContent() {
mutationFn: (retentionDays: number) => mutationFn: (retentionDays: number) =>
AccessLogService.cleanup({ retention_days: retentionDays }), AccessLogService.cleanup({ retention_days: retentionDays }),
onSuccess: async (result) => { onSuccess: async (result) => {
toast.success(`已清理 ${result.deleted_count} 条日志`); toast.success(t('cleaned', { count: result.deleted_count }));
setCleanupOpen(false); setCleanupOpen(false);
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['openflare', 'access-logs'], queryKey: ['openflare', 'access-logs'],
}); });
}, },
onError: (error) => { 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'> <div className='flex items-center gap-2'>
<ScrollText className='size-5 text-primary' /> <ScrollText className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1> <h1 className='text-2xl font-semibold tracking-tight'>
<p className='text-sm text-muted-foreground'> {t('title')}
查看访问概览、IP 明细与请求日志。 </h1>
</p> <p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
</div> </div>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
@@ -229,7 +231,7 @@ function AccessLogsPageContent() {
<RefreshCw <RefreshCw
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`} className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
/> />
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -237,16 +239,16 @@ function AccessLogsPageContent() {
onClick={() => setCleanupOpen(true)} onClick={() => setCleanupOpen(true)}
> >
<Trash2 className='size-3.5 mr-1' /> <Trash2 className='size-3.5 mr-1' />
清理日志 {t('cleanup')}
</Button> </Button>
</div> </div>
</div> </div>
<Tabs value={tab} onValueChange={handleTabChange}> <Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-lg grid-cols-3'> <TabsList className='grid w-full max-w-lg grid-cols-3'>
<TabsTrigger value='overview'>概览</TabsTrigger> <TabsTrigger value='overview'>{t('tabOverview')}</TabsTrigger>
<TabsTrigger value='ips'>IP 明细</TabsTrigger> <TabsTrigger value='ips'>{t('tabIps')}</TabsTrigger>
<TabsTrigger value='list'>日志明细</TabsTrigger> <TabsTrigger value='list'>{t('tabList')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='overview' className='mt-4'> <TabsContent value='overview' className='mt-4'>
@@ -343,12 +345,13 @@ function AccessLogsPageContent() {
} }
export default function AccessLogsPage() { export default function AccessLogsPage() {
const t = useTranslations('accessLogs');
return ( return (
<Suspense <Suspense
fallback={ fallback={
<LoadingStateWithBorder <LoadingStateWithBorder
title='加载访问日志' title={t('loadingTitle')}
description='正在准备页面...' description={t('loadingDesc')}
/> />
} }
> >
@@ -2,6 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react'; import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
@@ -52,6 +53,7 @@ interface CacheManagerProps {
} }
export function CacheManager({ refreshTrigger }: CacheManagerProps) { export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const t = useTranslations('admin.database.cache');
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null); const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
const [loadingCache, setLoadingCache] = useState<boolean>(true); const [loadingCache, setLoadingCache] = useState<boolean>(true);
const [savingConfig, setSavingConfig] = useState<boolean>(false); const [savingConfig, setSavingConfig] = useState<boolean>(false);
@@ -73,13 +75,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
setTtlMinutes(data.ttl_minutes.toString()); setTtlMinutes(data.ttl_minutes.toString());
setLruEnabled(data.lru_enabled); setLruEnabled(data.lru_enabled);
} catch (err) { } catch (err) {
toast.error('获取磁盘缓存状态失败', { toast.error(t('fetchCacheStatusFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setLoadingCache(false); setLoadingCache(false);
} }
}, []); }, [t]);
// 保存配置 // 保存配置
const handleSaveConfig = async (e: React.FormEvent) => { const handleSaveConfig = async (e: React.FormEvent) => {
@@ -87,14 +89,14 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const size = parseInt(maxSizeMB, 10); const size = parseInt(maxSizeMB, 10);
const ttl = parseInt(ttlMinutes, 10); const ttl = parseInt(ttlMinutes, 10);
if (isNaN(size) || size < 1) { if (isNaN(size) || size < 1) {
toast.error('保存失败', { toast.error(t('saveFailed'), {
description: '最大容量限制必须是大于等于 1 的整数', description: t('maxSizeMustBePositive'),
}); });
return; return;
} }
if (isNaN(ttl) || ttl < 0) { if (isNaN(ttl) || ttl < 0) {
toast.error('保存失败', { toast.error(t('saveFailed'), {
description: '默认过期时间必须是大于等于 0 的整数', description: t('ttlMustBeNonNegative'),
}); });
return; return;
} }
@@ -105,11 +107,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
ttl_minutes: ttl, ttl_minutes: ttl,
lru_enabled: lruEnabled, lru_enabled: lruEnabled,
}); });
toast.success('保存成功', { description: '磁盘缓存策略已热更新' }); toast.success(t('saveSuccess'), {
description: t('cachePolicyHotUpdated'),
});
await fetchCacheStatus(true); await fetchCacheStatus(true);
} catch (err) { } catch (err) {
toast.error('保存配置失败', { toast.error(t('saveConfigFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setSavingConfig(false); setSavingConfig(false);
@@ -121,12 +125,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
setClearingCache(true); setClearingCache(true);
try { try {
await services.adminCache.clearCache(); await services.adminCache.clearCache();
toast.success('清空成功', { description: '缓存数据已全部清除' }); toast.success(t('clearSuccess'), { description: t('cacheDataCleared') });
setShowClearConfirm(false); setShowClearConfirm(false);
await fetchCacheStatus(true); await fetchCacheStatus(true);
} catch (err) { } catch (err) {
toast.error('清空缓存失败', { toast.error(t('clearCacheFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setClearingCache(false); setClearingCache(false);
@@ -149,10 +153,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<div className='space-y-0.5'> <div className='space-y-0.5'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<HardDrive className='size-4 text-primary animate-pulse' /> <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> </div>
<CardDescription className='text-[11px]'> <CardDescription className='text-[11px]'>
管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略 {t('cacheManagementDesc')}
</CardDescription> </CardDescription>
</div> </div>
@@ -166,7 +172,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<RefreshCw <RefreshCw
className={`size-3 ${loadingCache ? 'animate-spin' : ''}`} className={`size-3 ${loadingCache ? 'animate-spin' : ''}`}
/> />
刷新状态 {t('refreshStatus')}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className='pt-4'> <CardContent className='pt-4'>
@@ -181,13 +187,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* 左边:状态区 (2/5 cols) */} {/* 左边:状态区 (2/5 cols) */}
<div className='lg:col-span-2 space-y-4'> <div className='lg:col-span-2 space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'> <h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
运行状态 {t('runtimeStatus')}
</h4> </h4>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <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'> <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'> <p className='text-[10px] text-muted-foreground font-medium mb-1'>
已用空间 {t('usedSpace')}
</p> </p>
<p className='text-xl font-bold tracking-tight text-foreground'> <p className='text-xl font-bold tracking-tight text-foreground'>
{formatBytes(cacheStatus.total_size)} {formatBytes(cacheStatus.total_size)}
@@ -196,12 +202,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* Key数量 */} {/* 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'> <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'> <p className='text-[10px] text-muted-foreground font-medium mb-1'>
缓存键数量 {t('cacheKeysCount')}
</p> </p>
<p className='text-xl font-bold tracking-tight text-foreground'> <p className='text-xl font-bold tracking-tight text-foreground'>
{formatNumber(cacheStatus.keys_count)}{' '} {formatNumber(cacheStatus.keys_count)}{' '}
<span className='text-xs text-muted-foreground font-normal'> <span className='text-xs text-muted-foreground font-normal'>
个文件 {t('files')}
</span> </span>
</p> </p>
</div> </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'> <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'> <p className='text-[10px] text-muted-foreground font-medium mb-1.5'>
缓存基准目录 {t('cacheBaseDirectory')}
</p> </p>
<code <code
className='text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate' 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' className='h-8 text-xs font-medium w-full'
onClick={() => setShowClearConfirm(true)} onClick={() => setShowClearConfirm(true)}
> >
立即清空缓存 {t('clearCacheNow')}
</Button> </Button>
</div> </div>
{/* 右边:配置区 (3/5 cols) */} {/* 右边:配置区 (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'> <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'> <h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
策略配置 {t('policyConfig')}
</h4> </h4>
<form onSubmit={handleSaveConfig} className='space-y-4'> <form onSubmit={handleSaveConfig} className='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='maxSizeMB' className='text-xs font-medium'> <Label htmlFor='maxSizeMB' className='text-xs font-medium'>
最大容量限制 (MB) {t('maxSizeLimit')}
</Label> </Label>
<Input <Input
id='maxSizeMB' id='maxSizeMB'
@@ -247,17 +253,17 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
value={maxSizeMB} value={maxSizeMB}
onChange={(e) => setMaxSizeMB(e.target.value)} onChange={(e) => setMaxSizeMB(e.target.value)}
className='h-8 text-xs bg-background/50 border-border/40' className='h-8 text-xs bg-background/50 border-border/40'
placeholder='例如 100' placeholder={t('maxSizePlaceholder')}
required required
/> />
<p className='text-[9px] text-muted-foreground'> <p className='text-[9px] text-muted-foreground'>
当总大小超出该值时,自动触发淘汰算法 {t('maxSizeHint')}
</p> </p>
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='ttlMinutes' className='text-xs font-medium'> <Label htmlFor='ttlMinutes' className='text-xs font-medium'>
生存时间限制 (分钟) {t('ttlLimit')}
</Label> </Label>
<Input <Input
id='ttlMinutes' id='ttlMinutes'
@@ -266,11 +272,11 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
value={ttlMinutes} value={ttlMinutes}
onChange={(e) => setTtlMinutes(e.target.value)} onChange={(e) => setTtlMinutes(e.target.value)}
className='h-8 text-xs bg-background/50 border-border/40' className='h-8 text-xs bg-background/50 border-border/40'
placeholder='例如 60,0 表示永不过期' placeholder={t('ttlPlaceholder')}
required required
/> />
<p className='text-[9px] text-muted-foreground'> <p className='text-[9px] text-muted-foreground'>
缓存项的最长存活时间,超期后将失效被清理 {t('ttlHint')}
</p> </p>
</div> </div>
</div> </div>
@@ -281,10 +287,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
htmlFor='lruEnabled' htmlFor='lruEnabled'
className='text-xs font-semibold block cursor-pointer' className='text-xs font-semibold block cursor-pointer'
> >
启用 LRU 淘汰机制 {t('enableLru')}
</Label> </Label>
<span className='text-[10px] text-muted-foreground block'> <span className='text-[10px] text-muted-foreground block'>
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。 {t('enableLruDesc')}
</span> </span>
</div> </div>
<Switch <Switch
@@ -300,7 +306,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
disabled={savingConfig} disabled={savingConfig}
className='h-8 text-xs px-4' className='h-8 text-xs px-4'
> >
{savingConfig ? '正在保存...' : '保存设置'} {savingConfig ? t('saving') : t('saveSettings')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -309,7 +315,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
) : ( ) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'> <div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<HardDrive className='size-8 opacity-45 mb-2' /> <HardDrive className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未加载到缓存状态信息</span> <span className='text-xs'>{t('noCacheStatus')}</span>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -320,11 +326,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<DialogHeader> <DialogHeader>
<DialogTitle className='text-sm font-semibold flex items-center gap-2 text-destructive'> <DialogTitle className='text-sm font-semibold flex items-center gap-2 text-destructive'>
<Trash2 className='size-4' /> <Trash2 className='size-4' />
确认清空所有缓存? {t('confirmClearAllCache')}
</DialogTitle> </DialogTitle>
<DialogDescription className='text-xs text-muted-foreground pt-1'> <DialogDescription className='text-xs text-muted-foreground pt-1'>
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 {t('confirmClearDesc')}
0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className='flex gap-2 justify-end mt-4'> <DialogFooter className='flex gap-2 justify-end mt-4'>
@@ -335,7 +340,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={() => setShowClearConfirm(false)} onClick={() => setShowClearConfirm(false)}
disabled={clearingCache} disabled={clearingCache}
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -344,7 +349,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={handleClearCache} onClick={handleClearCache}
disabled={clearingCache} disabled={clearingCache}
> >
{clearingCache ? '清理中...' : '确认清空'} {clearingCache ? t('clearing') : t('confirmClear')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -6,6 +6,7 @@ import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror'; import CodeMirror from '@uiw/react-codemirror';
import { sql } from '@codemirror/lang-sql'; import { sql } from '@codemirror/lang-sql';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react'; import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -49,6 +50,7 @@ interface SQLConsoleProps {
} }
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) { export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
const t = useTranslations('admin.database.sql');
// SQL 控制台状态 // SQL 控制台状态
const [sqlQuery, setSqlQuery] = useState<string>(''); const [sqlQuery, setSqlQuery] = useState<string>('');
const [executingSQL, setExecutingSQL] = useState<boolean>(false); const [executingSQL, setExecutingSQL] = useState<boolean>(false);
@@ -97,10 +99,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
try { try {
const result = await DbManageService.executeSQL(sqlQuery); const result = await DbManageService.executeSQL(sqlQuery);
setSqlResult(result); setSqlResult(result);
toast.success('SQL 执行成功'); toast.success(t('sqlExecSuccess'));
} catch (err) { } catch (err) {
setSqlError(err instanceof Error ? err.message : '未知执行错误'); setSqlError(err instanceof Error ? err.message : t('unknownExecError'));
toast.error('SQL 执行失败'); toast.error(t('sqlExecFailed'));
} finally { } finally {
setExecutingSQL(false); setExecutingSQL(false);
} }
@@ -134,7 +136,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<Terminal className='size-5 text-primary' /> <Terminal className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'> <h1 className='text-2xl font-semibold tracking-tight'>
SQL 查询终端 {t('sqlQueryTerminal')}
</h1> </h1>
</div> </div>
</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 justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Terminal className='size-4 text-primary' /> <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>
<div className='flex items-center gap-3 flex-wrap'> <div className='flex items-center gap-3 flex-wrap'>
{/* 快速模板 */} {/* 快速模板 */}
<div className='flex items-center gap-1.5'> <div className='flex items-center gap-1.5'>
<span className='text-[11px] text-muted-foreground'> <span className='text-[11px] text-muted-foreground'>
快速模板: {t('quickTemplates')}
</span> </span>
<Select onValueChange={handlePresetSQLChange}> <Select onValueChange={handlePresetSQLChange}>
<SelectTrigger className='h-7 w-[180px] text-[11px] bg-background'> <SelectTrigger className='h-7 w-[180px] text-[11px] bg-background'>
<SelectValue placeholder='选择预设 SQL' /> <SelectValue placeholder={t('selectPresetSQL')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='SELECT * FROM users LIMIT 10;'> <SelectItem value='SELECT * FROM users LIMIT 10;'>
查询用户 (SELECT users) {t('queryUsers')}
</SelectItem> </SelectItem>
<SelectItem value='SELECT * FROM uploads LIMIT 10;'> <SelectItem value='SELECT * FROM uploads LIMIT 10;'>
查询文件 (SELECT uploads) {t('queryFiles')}
</SelectItem> </SelectItem>
<SelectItem value='SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;'> <SelectItem value='SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;'>
查询任务流水 (SELECT task_executions) {t('queryTaskExecutions')}
</SelectItem> </SelectItem>
<SelectItem value='SELECT sqlite_version();'> <SelectItem value='SELECT sqlite_version();'>
SQLite 版本 (SQLite only) {t('sqliteVersion')}
</SelectItem> </SelectItem>
<SelectItem value='SELECT version();'> <SelectItem value='SELECT version();'>
PostgreSQL 版本 (Postgres only) {t('postgresVersion')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -193,7 +195,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
size='icon' size='icon'
className='h-7 w-7 text-muted-foreground hover:text-foreground' className='h-7 w-7 text-muted-foreground hover:text-foreground'
onClick={() => setSqlQuery('')} onClick={() => setSqlQuery('')}
title='清空编辑器' title={t('clearEditor')}
> >
<Trash2 className='size-3.5' /> <Trash2 className='size-3.5' />
</Button> </Button>
@@ -204,7 +206,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
disabled={executingSQL || !sqlQuery.trim()} disabled={executingSQL || !sqlQuery.trim()}
> >
<Play className='size-3' /> <Play className='size-3' />
{executingSQL ? '运行中...' : '运行'} {executingSQL ? t('running') : t('run')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -236,7 +238,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div <div
onMouseDown={handleMouseDown} 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' 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 className='w-8 h-1 rounded bg-muted-foreground/30' />
</div> </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-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'> <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 && ( {sqlResult && (
<span> <span>
类型: {sqlResult.type.toUpperCase()} | 耗时:{' '} {t('type')}: {sqlResult.type.toUpperCase()} | {t('duration')}:{' '}
{sqlResult.execution_time_ms} ms {sqlResult.execution_time_ms} ms
</span> </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'> <div className='flex flex-col items-center justify-center h-full py-10 space-y-2'>
<RefreshCw className='size-5 text-primary animate-spin' /> <RefreshCw className='size-5 text-primary animate-spin' />
<span className='text-xs text-muted-foreground'> <span className='text-xs text-muted-foreground'>
正在在数据库执行查询,请稍候... {t('executingQuery')}
</span> </span>
</div> </div>
)} )}
{sqlError && ( {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]'> <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> <pre className='whitespace-pre-wrap'>{sqlError}</pre>
</div> </div>
)} )}
@@ -319,7 +321,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
colSpan={sqlResult.columns.length} colSpan={sqlResult.columns.length}
className='text-center py-10 text-muted-foreground' className='text-center py-10 text-muted-foreground'
> >
查询结果为空 {t('queryResultEmpty')}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -331,11 +333,13 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{sqlResult.type === 'exec' && ( {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'> <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'> <p className='text-foreground text-sm font-semibold'>
SQL 执行成功 {t('sqlExecSuccessTitle')}
</p> </p>
<p className='text-muted-foreground mt-2'> <p className='text-muted-foreground mt-2'>
受影响行数: {sqlResult.affected_rows} 行,耗时{' '} {t('affectedRows', {
{sqlResult.execution_time_ms} 毫秒。 rows: sqlResult.affected_rows,
ms: sqlResult.execution_time_ms,
})}
</p> </p>
</div> </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'> <div className='flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60'>
<Terminal className='size-8 mb-2' /> <Terminal className='size-8 mb-2' />
<span className='text-xs'> <span className='text-xs'>
编辑器就绪,请在上方编写 SQL 并运行查看结果 {t('editorReady')}
</span> </span>
</div> </div>
)} )}
@@ -2,6 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react'; import { Layers, RefreshCw } from 'lucide-react';
@@ -67,6 +68,7 @@ export function TableBrowser({
loadingTables, loadingTables,
refreshTrigger, refreshTrigger,
}: TableBrowserProps) { }: TableBrowserProps) {
const t = useTranslations('admin.database.tableBrowser');
const [selectedTable, setSelectedTable] = useState<string>(''); const [selectedTable, setSelectedTable] = useState<string>('');
const [tableData, setTableData] = useState<TableDataResponse | null>(null); const [tableData, setTableData] = useState<TableDataResponse | null>(null);
const [page, setPage] = useState<number>(1); const [page, setPage] = useState<number>(1);
@@ -86,14 +88,14 @@ export function TableBrowser({
}); });
setTableData(data); setTableData(data);
} catch (err) { } catch (err) {
toast.error(`获取数据表 ${tableName} 数据失败`, { toast.error(t('fetchTableFailed', { table: tableName }), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setLoadingData(false); setLoadingData(false);
} }
}, },
[], [t],
); );
// 默认选择第一张表 // 默认选择第一张表
@@ -125,9 +127,11 @@ export function TableBrowser({
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'> <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'> <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'> <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]'> <CardDescription className='text-[11px]'>
浏览数据库中的物理数据表详情及内容 {t('tableBrowserDesc')}
</CardDescription> </CardDescription>
</div> </div>
@@ -137,7 +141,7 @@ export function TableBrowser({
) : ( ) : (
<Select value={selectedTable} onValueChange={handleTableChange}> <Select value={selectedTable} onValueChange={handleTableChange}>
<SelectTrigger className='h-8 w-[200px] text-xs bg-background border-border/40'> <SelectTrigger className='h-8 w-[200px] text-xs bg-background border-border/40'>
<SelectValue placeholder='选择数据表' /> <SelectValue placeholder={t('selectTable')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className='max-h-[300px]'> <SelectContent className='max-h-[300px]'>
{tables.map((t) => ( {tables.map((t) => (
@@ -202,7 +206,7 @@ export function TableBrowser({
colSpan={tableData.columns.length} colSpan={tableData.columns.length}
className='text-center py-10 text-muted-foreground' className='text-center py-10 text-muted-foreground'
> >
表中无记录数据 {t('noRecordsInTable')}
</TableCell> </TableCell>
</TableRow> </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 className='flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed'>
<div> <div>
总行数:{' '} {t('totalRows')}:{' '}
<span className='font-mono text-foreground font-semibold'> <span className='font-mono text-foreground font-semibold'>
{formatNumber(tableData.total)} {formatNumber(tableData.total)}
</span>{' '} </span>{' '}
条记录 {t('records')}
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button <Button
@@ -227,11 +231,13 @@ export function TableBrowser({
onClick={() => handlePageChange(page - 1)} onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || loadingData} disabled={page <= 1 || loadingData}
> >
上一页 {t('prevPage')}
</Button> </Button>
<span className='text-xs px-2 font-mono'> <span className='text-xs px-2 font-mono'>
第 {page} /{' '} {t('pageIndicator', {
{Math.max(1, Math.ceil(tableData.total / pageSize))} 页 current: page,
total: Math.max(1, Math.ceil(tableData.total / pageSize)),
})}
</span> </span>
<Button <Button
variant='outline' variant='outline'
@@ -242,7 +248,7 @@ export function TableBrowser({
page >= Math.ceil(tableData.total / pageSize) || loadingData page >= Math.ceil(tableData.total / pageSize) || loadingData
} }
> >
下一页 {t('nextPage')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -250,7 +256,7 @@ export function TableBrowser({
) : ( ) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'> <div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<Layers className='size-8 opacity-45 mb-2' /> <Layers className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未选择数据表或表结构无法加载</span> <span className='text-xs'>{t('noTableSelected')}</span>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -3,6 +3,7 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import * as React from 'react'; import * as React from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Activity, Activity,
@@ -57,6 +58,7 @@ const formatNumber = (num: number | string) => {
}; };
export function DatabasePageClient() { export function DatabasePageClient() {
const t = useTranslations('admin.database');
// 核心状态 // 核心状态
const [overview, setOverview] = useState<DBOverview | null>(null); const [overview, setOverview] = useState<DBOverview | null>(null);
const [tables, setTables] = useState<string[]>([]); const [tables, setTables] = useState<string[]>([]);
@@ -75,13 +77,13 @@ export function DatabasePageClient() {
const data = await services.dbManage.getOverview(); const data = await services.dbManage.getOverview();
setOverview(data); setOverview(data);
} catch (err) { } catch (err) {
toast.error('获取数据库概览失败', { toast.error(t('fetchOverviewFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setLoadingOverview(false); setLoadingOverview(false);
} }
}, []); }, [t]);
// 2. 获取表列表 // 2. 获取表列表
const fetchTables = useCallback(async () => { const fetchTables = useCallback(async () => {
@@ -90,13 +92,13 @@ export function DatabasePageClient() {
const data = await services.dbManage.listTables(); const data = await services.dbManage.listTables();
setTables(data); setTables(data);
} catch (err) { } catch (err) {
toast.error('获取数据库数据表列表失败', { toast.error(t('fetchTablesFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setLoadingTables(false); setLoadingTables(false);
} }
}, []); }, [t]);
// 3. 协调刷新 // 3. 协调刷新
const handleRefreshAll = () => { const handleRefreshAll = () => {
@@ -118,12 +120,12 @@ export function DatabasePageClient() {
a.click(); a.click();
a.remove(); a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success('数据库导出成功', { toast.success(t('exportSuccess'), {
description: `已下载归档文件: ${filename}`, description: t('exportSuccessDesc', { filename }),
}); });
} catch (err) { } catch (err) {
toast.error('数据库导出失败', { toast.error(t('exportFailed'), {
description: err instanceof Error ? err.message : '导出异常', description: err instanceof Error ? err.message : t('exportException'),
}); });
} finally { } finally {
setExporting(false); setExporting(false);
@@ -170,7 +172,9 @@ export function DatabasePageClient() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Database className='size-5 text-primary' /> <Database className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>数据管理</h1> <h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div> </div>
</div> </div>
<Button <Button
@@ -183,7 +187,7 @@ export function DatabasePageClient() {
<RefreshCw <RefreshCw
className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`} className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`}
/> />
刷新数据 {t('refreshData')}
</Button> </Button>
</div> </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'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'> <CardDescription className='text-[10px] font-medium'>
数据库类型 {t('dbType')}
</CardDescription> </CardDescription>
<Server className='size-3.5 text-muted-foreground' /> <Server className='size-3.5 text-muted-foreground' />
</CardHeader> </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'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'> <CardDescription className='text-[10px] font-medium'>
版本信息 {t('versionInfo')}
</CardDescription> </CardDescription>
<Cpu className='size-3.5 text-muted-foreground' /> <Cpu className='size-3.5 text-muted-foreground' />
</CardHeader> </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'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'> <CardDescription className='text-[10px] font-medium'>
名称/路径 {t('nameOrPath')}
</CardDescription> </CardDescription>
<Database className='size-3.5 text-muted-foreground' /> <Database className='size-3.5 text-muted-foreground' />
</CardHeader> </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'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'> <CardDescription className='text-[10px] font-medium'>
数据库大小 {t('dbSize')}
</CardDescription> </CardDescription>
<HardDrive className='size-3.5 text-muted-foreground' /> <HardDrive className='size-3.5 text-muted-foreground' />
</CardHeader> </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'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'> <CardDescription className='text-[10px] font-medium'>
物理数据表 {t('physicalTables')}
</CardDescription> </CardDescription>
<Layers className='size-3.5 text-muted-foreground' /> <Layers className='size-3.5 text-muted-foreground' />
</CardHeader> </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'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'> <CardDescription className='text-[10px] font-medium'>
活跃连接数 {t('activeConnections')}
</CardDescription> </CardDescription>
<Activity className='size-3.5 text-muted-foreground' /> <Activity className='size-3.5 text-muted-foreground' />
</CardHeader> </CardHeader>
@@ -303,9 +307,11 @@ export function DatabasePageClient() {
{/* 4. 底部功能卡片区 */} {/* 4. 底部功能卡片区 */}
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'> <Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
<CardHeader className='pb-3 border-b border-dashed'> <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]'> <CardDescription className='text-[11px]'>
数据库导出及自定义高级 SQL 执行终端 {t('featureAreaDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='pt-4'> <CardContent className='pt-4'>
@@ -315,11 +321,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'> <div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'> <p className='text-xs font-semibold flex items-center gap-1.5'>
<Download className='size-4 text-primary' /> <Download className='size-4 text-primary' />
数据库备份导出 {t('dbBackupExport')}
</p> </p>
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL {t('dbBackupExportDesc')}
导出为打包的 .sql 文本文件)
</p> </p>
</div> </div>
<Button <Button
@@ -332,7 +337,7 @@ export function DatabasePageClient() {
<RefreshCw <RefreshCw
className={`size-3 ${exporting ? 'animate-spin' : ''}`} className={`size-3 ${exporting ? 'animate-spin' : ''}`}
/> />
{exporting ? '正在准备...' : '开始导出'} {exporting ? t('preparing') : t('startExport')}
</Button> </Button>
</div> </div>
@@ -341,11 +346,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'> <div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'> <p className='text-xs font-semibold flex items-center gap-1.5'>
<Terminal className='size-4 text-primary' /> <Terminal className='size-4 text-primary' />
SQL 查询控台 {t('sqlQueryConsole')}
</p> </p>
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
打开在线 SQL 控制终端,执行原生的 SQL {t('sqlQueryConsoleDesc')}
进行数据筛选、更新、调试或性能优化
</p> </p>
</div> </div>
<Button <Button
@@ -356,7 +360,7 @@ export function DatabasePageClient() {
}} }}
> >
<FileText className='size-3.5' /> <FileText className='size-3.5' />
进入控台 {t('enterConsole')}
</Button> </Button>
</div> </div>
</div> </div>
+31 -29
View File
@@ -4,6 +4,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react'; import { Code } from 'lucide-react';
import { import {
@@ -21,6 +22,7 @@ import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab'; import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() { export default function HeaderDemoPage() {
const t = useTranslations('admin.demo');
const [activeTab, setActiveTab] = React.useState('dashboard'); const [activeTab, setActiveTab] = React.useState('dashboard');
return ( return (
@@ -30,7 +32,7 @@ export default function HeaderDemoPage() {
<Code className='size-5 text-primary' /> <Code className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'> <h1 className='text-2xl font-semibold tracking-tight'>
开发规范示例 {t('pageTitle')}
</h1> </h1>
</div> </div>
</div> </div>
@@ -40,28 +42,28 @@ export default function HeaderDemoPage() {
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Code className='size-4 text-primary' /> <Code className='size-4 text-primary' />
<CardTitle className='text-base font-semibold'>规范说明</CardTitle> <CardTitle className='text-base font-semibold'>
{t('specTitle')}
</CardTitle>
</div> </div>
<CardDescription> <CardDescription>{t('specDescription')}</CardDescription>
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<div className='space-y-2'> <div className='space-y-2'>
<h3 className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'> <h3 className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
核心规范细节与编写原则: {t('coreSpecTitle')}
</h3> </h3>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'> <ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li> <li>
<strong>结构专注</strong> <strong>{t('rules.structureFocus.title')}</strong>
:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。 {t('rules.structureFocus.description')}
</li> </li>
<li> <li>
<strong>容器与上边距对齐</strong>:使用{' '} <strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2 flex items-center gap-2
</code>{' '} </code>{' '}
作为标题行基础容器(不含下边框),外层统一使用{' '} {t('rules.containerAlignment.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1 py-6 px-1
</code>{' '} </code>{' '}
@@ -69,53 +71,53 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 py-6
</code> </code>
) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。 ) {t('rules.containerAlignment.description2')}
</li> </li>
<li> <li>
<strong>图标呈现</strong>:直接将 Lucide <strong>{t('rules.iconPresentation.title')}</strong>:直接将
图标组件嵌套于标题容器中,应用{' '} Lucide 图标组件嵌套于标题容器中,应用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary size-5 text-primary
</code>{' '} </code>{' '}
样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。 {t('rules.iconPresentation.description')}
</li> </li>
<li> <li>
<strong>文本样式标准</strong>:文本使用且仅使用{' '} <strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'} {'h1 className="text-2xl font-semibold tracking-tight"'}
</code> </code>
,禁用字重加粗(如{' '} {t('rules.textStyle.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-bold font-bold
</code> </code>
)或任何渐变色,保证全站字体一致性。 {t('rules.textStyle.description2')}
</li> </li>
<li> <li>
<strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab <strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
页切换的页面,**禁止**将所有 Tab Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的 的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '} Content 必须单独拆分为独立组件文件(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx tabs/events-tab.tsx
</code>{' '} </code>{' '}
或就近放在{' '} {t('rules.tabsModular.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/ components/
</code>{' '} </code>{' '}
下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。 {t('rules.tabsModular.description2')}
</li> </li>
<li> <li>
<strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs <strong>{t('rules.complexityDriven.title')}</strong>:不仅是
切换,当一个页面代码行数超过 600 Tabs 切换,当一个页面代码行数超过 600
行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如{' '} {t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/ app/(main)/admin/database/components/
</code> </code>
),只有真正跨页面复用的组件才进入全局{' '} {t('rules.complexityDriven.description2')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/common/ components/common/
</code> </code>
。典型标杆案例参考“数据管理” ( {t('rules.complexityDriven.description3')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database /admin/database
</code> </code>
@@ -145,19 +147,19 @@ export default function HeaderDemoPage() {
value='dashboard' value='dashboard'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
仪表盘与指标卡片 {t('tabs.dashboard')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='table' value='table'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
数据表格规范 {t('tabs.table')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='controls' value='controls'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
输入控件与表单 {t('tabs.controls')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -4,6 +4,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react'; import { Code } from 'lucide-react';
import { import {
Card, Card,
@@ -25,6 +26,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export function ControlsTab() { export function ControlsTab() {
const t = useTranslations('admin.demo.controls');
const [formData, setFormData] = React.useState({ const [formData, setFormData] = React.useState({
name: '', name: '',
category: 'database', category: 'database',
@@ -38,11 +40,9 @@ export function ControlsTab() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold'> <CardTitle className='text-sm font-semibold'>
表单与输入框交互演示 {t('formDemoTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('formDemoDesc')}</CardDescription>
包含按钮变体、下拉选择、多选框及基础输入框的标准样式
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{/* 输入框 */} {/* 输入框 */}
@@ -51,7 +51,7 @@ export function ControlsTab() {
htmlFor='task-name' htmlFor='task-name'
className='text-xs font-medium text-foreground' className='text-xs font-medium text-foreground'
> >
任务名称 {t('taskName')}
</Label> </Label>
<Input <Input
id='task-name' id='task-name'
@@ -59,18 +59,18 @@ export function ControlsTab() {
onChange={(e) => onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value })) setFormData((prev) => ({ ...prev, name: e.target.value }))
} }
placeholder='输入要创建的任务描述...' placeholder={t('taskNamePlaceholder')}
className='h-8 text-xs shadow-none bg-background focus-visible:ring-1' className='h-8 text-xs shadow-none bg-background focus-visible:ring-1'
/> />
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
任务名称将用于后台执行记录的可读性显示 {t('taskNameHint')}
</p> </p>
</div> </div>
{/* 下拉框 */} {/* 下拉框 */}
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-medium text-foreground'> <Label className='text-xs font-medium text-foreground'>
任务分类 {t('taskCategory')}
</Label> </Label>
<Select <Select
value={formData.category} value={formData.category}
@@ -82,9 +82,11 @@ export function ControlsTab() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='database'>数据库运维</SelectItem> <SelectItem value='database'>
<SelectItem value='sync'>数据批量同步</SelectItem> {t('categoryDatabase')}
<SelectItem value='cleanup'>过期磁盘清理</SelectItem> </SelectItem>
<SelectItem value='sync'>{t('categorySync')}</SelectItem>
<SelectItem value='cleanup'>{t('categoryCleanup')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -107,10 +109,10 @@ export function ControlsTab() {
htmlFor='allow-notify' htmlFor='allow-notify'
className='text-xs font-medium text-foreground cursor-pointer select-none leading-none' className='text-xs font-medium text-foreground cursor-pointer select-none leading-none'
> >
允许触发异步通知推送 {t('allowNotify')}
</label> </label>
<p className='text-[10px] text-muted-foreground leading-normal'> <p className='text-[10px] text-muted-foreground leading-normal'>
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。 {t('allowNotifyDesc')}
</p> </p>
</div> </div>
</div> </div>
@@ -118,32 +120,32 @@ export function ControlsTab() {
{/* 按钮变体 */} {/* 按钮变体 */}
<div className='pt-2'> <div className='pt-2'>
<div className='text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2'> <div className='text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2'>
按钮样式对齐: {t('buttonStyles')}
</div> </div>
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<Button size='sm' className='h-8 text-xs shadow-none'> <Button size='sm' className='h-8 text-xs shadow-none'>
主要操作 (Primary) {t('primaryButton')}
</Button> </Button>
<Button <Button
variant='secondary' variant='secondary'
size='sm' size='sm'
className='h-8 text-xs shadow-none' className='h-8 text-xs shadow-none'
> >
次要操作 (Secondary) {t('secondaryButton')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
className='h-8 border-dashed text-xs shadow-none' className='h-8 border-dashed text-xs shadow-none'
> >
辅助线框 (Outline) {t('outlineButton')}
</Button> </Button>
<Button <Button
variant='ghost' variant='ghost'
size='sm' size='sm'
className='h-8 text-xs hover:bg-muted' className='h-8 text-xs hover:bg-muted'
> >
幽灵按钮 (Ghost) {t('ghostButton')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -155,13 +157,13 @@ export function ControlsTab() {
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'> <CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' /> <Code className='size-4 text-primary' />
表单设计规范 {t('formDesignSpec')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-4 text-xs text-muted-foreground'> <CardContent className='space-y-4 text-xs text-muted-foreground'>
<div className='space-y-2 leading-relaxed'> <div className='space-y-2 leading-relaxed'>
<p className='font-semibold text-foreground text-[11px]'> <p className='font-semibold text-foreground text-[11px]'>
1. 统一控件高度与投影: {t('spec.unifiedHeight')}
</p> </p>
<p> <p>
输入框 ( 输入框 (
@@ -188,7 +190,7 @@ export function ControlsTab() {
</p> </p>
<p className='font-semibold text-foreground text-[11px] pt-1'> <p className='font-semibold text-foreground text-[11px] pt-1'>
2. Checkbox 与文本的对齐: {t('spec.checkboxAlignment')}
</p> </p>
<p> <p>
Checkbox 应在左侧垂直偏上对齐,使用容器{' '} Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
@@ -203,7 +205,7 @@ export function ControlsTab() {
</p> </p>
<p className='font-semibold text-foreground text-[11px] pt-1'> <p className='font-semibold text-foreground text-[11px] pt-1'>
3. 按钮使用策略: {t('spec.buttonStrategy')}
</p> </p>
<p> <p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '} 普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
@@ -4,11 +4,13 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Cpu, Database, HardDrive } from 'lucide-react'; import { Code, Cpu, Database, HardDrive } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
export function DashboardTab() { export function DashboardTab() {
const t = useTranslations('admin.demo.dashboard');
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'> <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'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'> <CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'> <span className='text-xs font-medium text-muted-foreground'>
容器 CPU 使用率 {t('cpuUsage')}
</span> </span>
<Cpu className='size-4 text-primary' /> <Cpu className='size-4 text-primary' />
</CardHeader> </CardHeader>
@@ -25,7 +27,7 @@ export function DashboardTab() {
<Progress value={42.8} className='h-1.5' /> <Progress value={42.8} className='h-1.5' />
<p className='text-[10px] text-muted-foreground flex items-center gap-1'> <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' /> <span className='size-1.5 rounded-full bg-emerald-500 inline-block animate-pulse' />
容器运行正常,负载平稳 {t('cpuStatus')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -34,7 +36,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'> <CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'> <span className='text-xs font-medium text-muted-foreground'>
存储已用容量 {t('storageUsage')}
</span> </span>
<HardDrive className='size-4 text-primary' /> <HardDrive className='size-4 text-primary' />
</CardHeader> </CardHeader>
@@ -45,7 +47,7 @@ export function DashboardTab() {
</div> </div>
<Progress value={72.4} className='h-1.5' /> <Progress value={72.4} className='h-1.5' />
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
数据已同步远端 S3 存储桶,支持自动过期 {t('storageStatus')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -54,7 +56,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'> <CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'> <span className='text-xs font-medium text-muted-foreground'>
物理数据库 {t('database')}
</span> </span>
<Database className='size-4 text-primary' /> <Database className='size-4 text-primary' />
</CardHeader> </CardHeader>
@@ -67,16 +69,16 @@ export function DashboardTab() {
</div> </div>
<div className='flex items-center gap-3 text-xs text-muted-foreground'> <div className='flex items-center gap-3 text-xs text-muted-foreground'>
<div> <div>
活跃连接:{' '} {t('activeConnections')}{' '}
<span className='font-semibold text-foreground'>12</span> <span className='font-semibold text-foreground'>12</span>
</div> </div>
<div> <div>
总表数量:{' '} {t('totalTables')}{' '}
<span className='font-semibold text-foreground'>34</span> <span className='font-semibold text-foreground'>34</span>
</div> </div>
</div> </div>
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
链接池自动维护中,最大连接限制为 100 {t('dbStatus')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -87,45 +89,45 @@ export function DashboardTab() {
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'> <CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' /> <Code className='size-4 text-primary' />
指标卡片 & 仪表盘设计规范 {t('dashboardDesignSpec')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'> <ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li> <li>
指标卡片统一采用{' '} {t('spec.dashedBorder')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none border-dashed shadow-none
</code>{' '} </code>{' '}
作为边框和投影。 {t('spec.dashedBorder2')}
</li> </li>
<li> <li>
头部使用{' '} {t('spec.headerLayout')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex flex-row items-center justify-between pb-2 flex flex-row items-center justify-between pb-2
</code>{' '} </code>{' '}
布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。 {t('spec.headerLayout2')}
</li> </li>
<li> <li>
数值强调使用且仅使用{' '} {t('spec.valueStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-2xl font-semibold tracking-tight text-2xl font-semibold tracking-tight
</code>{' '} </code>{' '}
展示。 {t('spec.valueStyle2')}
</li> </li>
<li> <li>
进度条使用{' '} {t('spec.progressBar')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-1.5 h-1.5
</code>{' '} </code>{' '}
细条高度,不喧宾夺主。 {t('spec.progressBar2')}
</li> </li>
<li> <li>
底部的次要说明信息必须统一使用{' '} {t('spec.secondaryText')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-[10px] text-muted-foreground text-[10px] text-muted-foreground
</code>{' '} </code>{' '}
等辅助类。 {t('spec.secondaryText2')}
</li> </li>
</ul> </ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'> <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'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react'; import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; 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'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() { export function TableTab() {
const t = useTranslations('admin.demo.table');
const mockTableData = [ const mockTableData = [
{ {
id: 'TX-1002', id: 'TX-1002',
name: '用户信息批量同步导出', name: t('mockData.syncExport'),
type: '数据同步', type: t('mockType.dataSync'),
status: 'success', status: 'success',
duration: '1.2s', duration: '1.2s',
time: '2026-06-15 14:22', time: '2026-06-15 14:22',
}, },
{ {
id: 'TX-1003', id: 'TX-1003',
name: '系统数据库每周自动备份归档', name: t('mockData.dbBackup'),
type: '数据库运维', type: t('mockType.dbOps'),
status: 'running', status: 'running',
duration: '在执行中', duration: t('mockDuration.running'),
time: '2026-06-15 15:00', time: '2026-06-15 15:00',
}, },
{ {
id: 'TX-1004', id: 'TX-1004',
name: '远端S3过期缓存对象自动清理任务', name: t('mockData.s3Cleanup'),
type: '磁盘清理', type: t('mockType.diskCleanup'),
status: 'failed', status: 'failed',
duration: '0.4s', duration: '0.4s',
time: '2026-06-15 10:05', time: '2026-06-15 10:05',
@@ -54,7 +56,7 @@ export function TableTab() {
<div className='relative w-64'> <div className='relative w-64'>
<Search className='absolute left-2.5 top-2.5 size-3 text-muted-foreground' /> <Search className='absolute left-2.5 top-2.5 size-3 text-muted-foreground' />
<Input <Input
placeholder='输入任务名称搜索...' placeholder={t('searchPlaceholder')}
className='h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background' className='h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background'
/> />
</div> </div>
@@ -64,12 +66,12 @@ export function TableTab() {
className='h-8 border-dashed text-xs shadow-none' className='h-8 border-dashed text-xs shadow-none'
> >
<ListFilter className='size-3 mr-1' /> <ListFilter className='size-3 mr-1' />
过滤 {t('filter')}
</Button> </Button>
</div> </div>
<Button size='sm' className='h-8 text-xs shadow-none'> <Button size='sm' className='h-8 text-xs shadow-none'>
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
新建任务 {t('newTask')}
</Button> </Button>
</div> </div>
@@ -79,25 +81,25 @@ export function TableTab() {
<TableHeader className='bg-muted/40'> <TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'> <TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='w-[100px] text-xs font-semibold'> <TableHead className='w-[100px] text-xs font-semibold'>
任务编号 {t('columns.taskId')}
</TableHead> </TableHead>
<TableHead className='text-xs font-semibold'> <TableHead className='text-xs font-semibold'>
执行任务名称 {t('columns.taskName')}
</TableHead> </TableHead>
<TableHead className='w-[120px] text-xs font-semibold'> <TableHead className='w-[120px] text-xs font-semibold'>
任务类型 {t('columns.taskType')}
</TableHead> </TableHead>
<TableHead className='w-[100px] text-xs font-semibold'> <TableHead className='w-[100px] text-xs font-semibold'>
状态 {t('columns.status')}
</TableHead> </TableHead>
<TableHead className='w-[100px] text-xs font-semibold'> <TableHead className='w-[100px] text-xs font-semibold'>
执行耗时 {t('columns.duration')}
</TableHead> </TableHead>
<TableHead className='w-[150px] text-xs font-semibold'> <TableHead className='w-[150px] text-xs font-semibold'>
触发时间 {t('columns.triggerTime')}
</TableHead> </TableHead>
<TableHead className='w-[80px] text-xs font-semibold text-right'> <TableHead className='w-[80px] text-xs font-semibold text-right'>
操作 {t('columns.actions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </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' 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' /> <span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
成功 {t('status.success')}
</Badge> </Badge>
)} )}
{row.status === 'running' && ( {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' 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' /> <span className='size-1 bg-blue-500 rounded-full mr-1.5 shrink-0 animate-pulse' />
运行中 {t('status.running')}
</Badge> </Badge>
)} )}
{row.status === 'failed' && ( {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' 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' /> <span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败 {t('status.failed')}
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
@@ -180,13 +182,13 @@ export function TableTab() {
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'> <CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' /> <Code className='size-4 text-primary' />
表格设计规范与代码模板 {t('tableDesignSpec')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'> <ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li> <li>
表格容器必须统一使用带有虚线边框的容器,即类名: {t('spec.container')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
border border-dashed shadow-none rounded-lg overflow-hidden border border-dashed shadow-none rounded-lg overflow-hidden
bg-background bg-background
@@ -194,40 +196,40 @@ export function TableTab() {
。 。
</li> </li>
<li> <li>
表头背景设为{' '} {t('spec.headerBg')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
bg-muted/40 bg-muted/40
</code> </code>
。表头表项字号统一为小字粗体{' '} {t('spec.headerStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold text-xs font-semibold
</code> </code>
。 。
</li> </li>
<li> <li>
单元格行与列之间使用虚线分割( {t('spec.cellDivider')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed border-dashed
</code> </code>
)。表格行悬浮交互采用温和过渡色{' '} {t('spec.rowHover')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors hover:bg-muted/10 transition-colors
</code> </code>
。 。
</li> </li>
<li> <li>
编码、主键或唯一 ID 类字段一律采用等宽字体呈现: {t('spec.monoFont')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs font-mono text-xs
</code> </code>
。 。
</li> </li>
<li> <li>
状态标签(Badge)尽量圆角化( {t('spec.badgeStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'> <code className='bg-muted px-1 py-0.5 rounded font-mono'>
rounded-full py-0 px-2 rounded-full py-0 px-2
</code> </code>
),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。 {t('spec.badgeStyle2')}
</li> </li>
</ul> </ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'> <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'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { import {
@@ -81,6 +82,7 @@ function formatDate(dateStr: string) {
} }
export function FileList() { export function FileList() {
const t = useTranslations('admin.files');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState(''); const [keyword, setKeyword] = React.useState('');
const [debouncedKeyword, setDebouncedKeyword] = React.useState(''); const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
@@ -157,10 +159,10 @@ export function FileList() {
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] }); void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] }); void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
toast.success('文件已删除'); toast.success(t('list.deleteSuccess'));
setDeleteTarget(null); setDeleteTarget(null);
}, },
onError: (err: Error) => toast.error(err.message || '删除失败'), onError: (err: Error) => toast.error(err.message || t('list.deleteFailed')),
}); });
// 批量 ZIP 下载 // 批量 ZIP 下载
@@ -173,9 +175,9 @@ export function FileList() {
a.download = 'batch_download.zip'; a.download = 'batch_download.zip';
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success('批量下载已开始'); toast.success(t('list.batchDownloadSuccess'));
}, },
onError: () => toast.error('批量下载失败'), onError: () => toast.error(t('list.batchDownloadFailed')),
}); });
const toggleSelect = (id: string) => { const toggleSelect = (id: string) => {
@@ -209,7 +211,7 @@ export function FileList() {
<div className='relative flex-1 max-w-sm'> <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' /> <Search className='absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground' />
<Input <Input
placeholder='搜索文件名...' placeholder={t('list.searchPlaceholder')}
className='pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0' className='pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0'
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} onChange={(e) => setKeyword(e.target.value)}
@@ -232,7 +234,9 @@ export function FileList() {
selectedIds.size === files.length ? clearSelection : selectAll selectedIds.size === files.length ? clearSelection : selectAll
} }
> >
{selectedIds.size === files.length ? '取消全选' : '全选本页'} {selectedIds.size === files.length
? t('list.deselectAll')
: t('list.selectAll')}
</Button> </Button>
)} )}
@@ -247,7 +251,7 @@ export function FileList() {
className='flex items-center gap-2' className='flex items-center gap-2'
> >
<Badge variant='secondary' className='text-xs px-2.5'> <Badge variant='secondary' className='text-xs px-2.5'>
已选 {selectedIds.size} 个 {t('list.selectedCount', { count: selectedIds.size })}
</Badge> </Badge>
<Button <Button
size='sm' size='sm'
@@ -261,7 +265,7 @@ export function FileList() {
) : ( ) : (
<FileArchive className='size-3.5 mr-1' /> <FileArchive className='size-3.5 mr-1' />
)} )}
打包下载 {t('list.batchDownload')}
</Button> </Button>
<Button <Button
size='sm' size='sm'
@@ -278,7 +282,7 @@ export function FileList() {
{total > 0 && ( {total > 0 && (
<span className='text-xs text-muted-foreground shrink-0'> <span className='text-xs text-muted-foreground shrink-0'>
共 {total} 个文件 {t('list.totalFiles', { count: total })}
</span> </span>
)} )}
</div> </div>
@@ -292,7 +296,7 @@ export function FileList() {
<div className='flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground'> <div className='flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground'>
<Upload className='size-12 text-muted-foreground/30' /> <Upload className='size-12 text-muted-foreground/30' />
<p className='text-sm'> <p className='text-sm'>
{debouncedKeyword ? '没有匹配的文件' : '您还没有上传任何文件'} {debouncedKeyword ? t('list.noMatch') : t('list.noFiles')}
</p> </p>
</div> </div>
) : ( ) : (
@@ -309,18 +313,28 @@ export function FileList() {
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
/> />
</TableHead> </TableHead>
<TableHead className='w-[80px] py-3'>预览</TableHead> <TableHead className='w-[80px] py-3'>
<TableHead className='w-[180px] py-3'>ID</TableHead> {t('list.colPreview')}
<TableHead className='py-3'>文件名</TableHead> </TableHead>
<TableHead className='max-w-[200px] truncate py-3'> <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>
<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'> <TableHead className='w-[120px] text-right pr-4 py-3'>
操作 {t('list.colActions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -396,7 +410,7 @@ export function FileList() {
size='icon' size='icon'
variant='ghost' variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground' className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
title='查看详情' title={t('list.viewDetail')}
onClick={() => setDetailTarget(file)} onClick={() => setDetailTarget(file)}
> >
<Eye className='size-3.5' /> <Eye className='size-3.5' />
@@ -405,7 +419,7 @@ export function FileList() {
size='icon' size='icon'
variant='ghost' variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted' className='h-7 w-7 rounded-md hover:bg-muted'
title='下载' title={t('list.download')}
onClick={() => handleDownload(file)} onClick={() => handleDownload(file)}
> >
<Download className='size-3.5' /> <Download className='size-3.5' />
@@ -414,7 +428,7 @@ export function FileList() {
size='icon' size='icon'
variant='ghost' variant='ghost'
className='h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive' 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)} onClick={() => setDeleteTarget(file)}
> >
<Trash2 className='size-3.5' /> <Trash2 className='size-3.5' />
@@ -439,7 +453,7 @@ export function FileList() {
disabled={page <= 1} disabled={page <= 1}
onClick={() => setPage((p) => p - 1)} onClick={() => setPage((p) => p - 1)}
> >
上一页 {t('list.prevPage')}
</Button> </Button>
<span className='text-xs text-muted-foreground'> <span className='text-xs text-muted-foreground'>
{page} / {totalPages} {page} / {totalPages}
@@ -451,7 +465,7 @@ export function FileList() {
disabled={page >= totalPages} disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)} onClick={() => setPage((p) => p + 1)}
> >
下一页 {t('list.nextPage')}
</Button> </Button>
</div> </div>
)} )}
@@ -463,9 +477,9 @@ export function FileList() {
> >
<SheetContent className='w-full sm:max-w-md overflow-y-auto p-6'> <SheetContent className='w-full sm:max-w-md overflow-y-auto p-6'>
<SheetHeader className='border-b pb-4 px-0'> <SheetHeader className='border-b pb-4 px-0'>
<SheetTitle>文件详情</SheetTitle> <SheetTitle>{t('list.detailTitle')}</SheetTitle>
<SheetDescription> <SheetDescription>
查看文件的完整元数据与配置属性。 {t('list.detailDesc')}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -485,7 +499,9 @@ export function FileList() {
<div className='flex flex-col items-center gap-3'> <div className='flex flex-col items-center gap-3'>
{getFileIcon(detailTarget.mime_type, 'size-14')} {getFileIcon(detailTarget.mime_type, 'size-14')}
<span className='text-xs font-medium text-muted-foreground uppercase'> <span className='text-xs font-medium text-muted-foreground uppercase'>
{detailTarget.extension} 文件 {t('list.extensionFile', {
ext: detailTarget.extension,
})}
</span> </span>
</div> </div>
)} )}
@@ -495,7 +511,7 @@ export function FileList() {
<div className='space-y-4 text-xs'> <div className='space-y-4 text-xs'>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
文件 ID {t('list.detailId')}
</span> </span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'> <span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.id} {detailTarget.id}
@@ -504,7 +520,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
文件名 {t('list.detailName')}
</span> </span>
<span className='col-span-2 break-all font-medium text-foreground/90'> <span className='col-span-2 break-all font-medium text-foreground/90'>
{detailTarget.file_name} {detailTarget.file_name}
@@ -513,7 +529,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
存储路径 {t('list.detailPath')}
</span> </span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'> <span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.file_path} {detailTarget.file_path}
@@ -522,17 +538,18 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
大小 {t('list.detailSize')}
</span> </span>
<span className='col-span-2 text-foreground/90'> <span className='col-span-2 text-foreground/90'>
{formatFileSize(detailTarget.file_size)} ( {formatFileSize(detailTarget.file_size)} (
{detailTarget.file_size.toLocaleString()} 字节) {detailTarget.file_size.toLocaleString()} {t('list.bytes')}
)
</span> </span>
</div> </div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
MIME 类型 {t('list.detailMime')}
</span> </span>
<span className='col-span-2 font-mono text-foreground/90'> <span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.mime_type} {detailTarget.mime_type}
@@ -541,7 +558,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
文件后缀 {t('list.detailExtension')}
</span> </span>
<span className='col-span-2 font-mono text-foreground/90'> <span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.extension} {detailTarget.extension}
@@ -559,7 +576,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
业务类型 {t('list.detailBizType')}
</span> </span>
<span className='col-span-2'> <span className='col-span-2'>
<Badge <Badge
@@ -573,7 +590,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
存储驱动 {t('list.detailDriver')}
</span> </span>
<span className='col-span-2 font-mono text-foreground/90'> <span className='col-span-2 font-mono text-foreground/90'>
{storageDriverQuery.data ?? 'local'} {storageDriverQuery.data ?? 'local'}
@@ -582,7 +599,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
上传者 ID {t('list.detailUploaderId')}
</span> </span>
<span className='col-span-2 font-mono text-foreground/90'> <span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.user_id} {detailTarget.user_id}
@@ -591,7 +608,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'> <div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'> <span className='text-muted-foreground font-medium'>
上传时间 {t('list.detailUploadedAt')}
</span> </span>
<span className='col-span-2 text-foreground/90'> <span className='col-span-2 text-foreground/90'>
{formatDate(detailTarget.created_at)} {formatDate(detailTarget.created_at)}
@@ -602,7 +619,7 @@ export function FileList() {
Object.keys(detailTarget.metadata).length > 0 && ( Object.keys(detailTarget.metadata).length > 0 && (
<div className='space-y-1.5 pt-2'> <div className='space-y-1.5 pt-2'>
<span className='text-muted-foreground font-medium block'> <span className='text-muted-foreground font-medium block'>
额外元数据 {t('list.detailMetadata')}
</span> </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'> <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)} {JSON.stringify(detailTarget.metadata, null, 2)}
@@ -622,18 +639,16 @@ export function FileList() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle> <AlertDialogTitle>{t('list.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确定要删除文件{' '} {t('list.deleteConfirm', {
<span className='font-semibold text-foreground'> name: deleteTarget?.file_name ?? '',
「{deleteTarget?.file_name}」 })}
</span>{' '}
吗?此操作不可撤销。
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}> <AlertDialogCancel disabled={deleteMutation.isPending}>
取消 {t('list.cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => onClick={() =>
@@ -645,7 +660,7 @@ export function FileList() {
{deleteMutation.isPending && ( {deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' /> <Loader2 className='mr-1.5 size-3.5 animate-spin' />
)} )}
确认删除 {t('list.confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
Database, Database,
@@ -49,42 +50,47 @@ const categoryMap: Record<string, string> = {
其他: 'others', 其他: 'others',
}; };
const statsChartConfig = { function useStatsChartConfig() {
const t = useTranslations('admin.files');
return {
images: { images: {
label: '图片', label: t('stats.catImages'),
color: 'hsl(var(--chart-1))', color: 'hsl(var(--chart-1))',
}, },
videos: { videos: {
label: '视频', label: t('stats.catVideos'),
color: 'hsl(var(--chart-2))', color: 'hsl(var(--chart-2))',
}, },
audio: { audio: {
label: '音频', label: t('stats.catAudio'),
color: 'hsl(var(--chart-3))', color: 'hsl(var(--chart-3))',
}, },
documents: { documents: {
label: '文档', label: t('stats.catDocuments'),
color: 'hsl(var(--chart-4))', color: 'hsl(var(--chart-4))',
}, },
archives: { archives: {
label: '压缩包', label: t('stats.catArchives'),
color: 'hsl(var(--chart-5))', color: 'hsl(var(--chart-5))',
}, },
others: { others: {
label: '其他', label: t('stats.catOthers'),
color: 'hsl(var(--chart-6))', color: 'hsl(var(--chart-6))',
}, },
count: { count: {
label: '新增文件数', label: t('stats.chartLabelCount'),
color: 'hsl(var(--primary))', color: 'hsl(var(--primary))',
}, },
size: { size: {
label: '新增大小', label: t('stats.chartLabelSize'),
color: 'hsl(var(--chart-2))', color: 'hsl(var(--chart-2))',
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
}
export function FileStats() { export function FileStats() {
const t = useTranslations('admin.files');
const statsChartConfig = useStatsChartConfig();
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>( const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
'count', 'count',
); );
@@ -137,11 +143,16 @@ export function FileStats() {
const maxStats = React.useMemo(() => { const maxStats = React.useMemo(() => {
if (!stats?.categories || stats.categories.length === 0) { 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 maxCount = 0;
let maxSizeName = '无'; let maxSizeName = t('stats.none');
let maxSize = 0; let maxSize = 0;
stats.categories.forEach((cat) => { stats.categories.forEach((cat) => {
@@ -156,7 +167,7 @@ export function FileStats() {
}); });
return { maxCountName, maxCount, maxSizeName, maxSize }; return { maxCountName, maxCount, maxSizeName, maxSize };
}, [stats?.categories]); }, [stats?.categories, t]);
const trendSummary = React.useMemo(() => { const trendSummary = React.useMemo(() => {
if (!stats?.trend) return { count: 0, size: 0 }; 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'> <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' /> <Upload className='size-14 text-muted-foreground/25' />
<div className='text-center space-y-1'> <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'> <p className='text-xs text-muted-foreground'>
上传文件后即可在此查看容量与文件分布分析。 {t('stats.noDataDesc')}
</p> </p>
</div> </div>
</div> </div>
@@ -198,7 +209,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'> <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'> <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'> <CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
总文件数 {t('stats.totalFiles')}
</CardTitle> </CardTitle>
<Files className='size-4 text-sky-500' /> <Files className='size-4 text-sky-500' />
</CardHeader> </CardHeader>
@@ -206,11 +217,11 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-sky-500'> <div className='text-2xl font-bold font-mono text-sky-500'>
{stats.total_count.toLocaleString()}{' '} {stats.total_count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'> <span className='text-xs font-normal text-muted-foreground'>
个 {t('stats.countUnit')}
</span> </span>
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1'> <p className='text-[10px] text-muted-foreground mt-1'>
您上传的可用文件数量汇总 {t('stats.totalFilesDesc')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -218,7 +229,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'> <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'> <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'> <CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
占用存储容量 {t('stats.totalSize')}
</CardTitle> </CardTitle>
<Database className='size-4 text-emerald-500' /> <Database className='size-4 text-emerald-500' />
</CardHeader> </CardHeader>
@@ -227,7 +238,7 @@ export function FileStats() {
{formatFileSize(stats.total_size)} {formatFileSize(stats.total_size)}
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1'> <p className='text-[10px] text-muted-foreground mt-1'>
文件实际在磁盘/S3中占用的总空间 {t('stats.totalSizeDesc')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -235,7 +246,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'> <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'> <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'> <CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
近 7 天新增文件 {t('stats.weeklyNewFiles')}
</CardTitle> </CardTitle>
<TrendingUp className='size-4 text-indigo-500' /> <TrendingUp className='size-4 text-indigo-500' />
</CardHeader> </CardHeader>
@@ -243,11 +254,11 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-indigo-500'> <div className='text-2xl font-bold font-mono text-indigo-500'>
+{trendSummary.count.toLocaleString()}{' '} +{trendSummary.count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'> <span className='text-xs font-normal text-muted-foreground'>
个 {t('stats.countUnit')}
</span> </span>
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1'> <p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传成功的文件总数 {t('stats.weeklyNewFilesDesc')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -255,7 +266,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'> <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'> <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'> <CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
近 7 天新增大小 {t('stats.weeklyNewSize')}
</CardTitle> </CardTitle>
<HardDrive className='size-4 text-amber-500' /> <HardDrive className='size-4 text-amber-500' />
</CardHeader> </CardHeader>
@@ -264,7 +275,7 @@ export function FileStats() {
+{formatFileSize(trendSummary.size)} +{formatFileSize(trendSummary.size)}
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1'> <p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传所消耗的存储带宽 {t('stats.weeklyNewSizeDesc')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -275,10 +286,10 @@ export function FileStats() {
<CardHeader className='flex flex-row items-center justify-between'> <CardHeader className='flex flex-row items-center justify-between'>
<div> <div>
<CardTitle className='text-sm font-bold'> <CardTitle className='text-sm font-bold'>
最近 7 天新增趋势 {t('stats.trendTitle')}
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
按日统计近一周期内的上传波动 {t('stats.trendDesc')}
</CardDescription> </CardDescription>
</div> </div>
<div className='flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30'> <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' className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('count')} onClick={() => setTrendMetric('count')}
> >
新增数量 (个) {t('stats.trendCount')}
</Button> </Button>
<Button <Button
size='sm' size='sm'
@@ -296,7 +307,7 @@ export function FileStats() {
className='h-7 text-xs px-2.5 rounded-md' className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('size')} onClick={() => setTrendMetric('size')}
> >
新增大小 (MB) {t('stats.trendSize')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -361,11 +372,13 @@ export function FileStats() {
hideLabel hideLabel
formatter={(value) => { formatter={(value) => {
const label = const label =
trendMetric === 'count' ? '新增文件数' : '新增大小'; trendMetric === 'count'
? t('stats.chartLabelCount')
: t('stats.chartLabelSize');
const formattedValue = const formattedValue =
trendMetric === 'size' trendMetric === 'size'
? formatFileSize(Number(value)) ? formatFileSize(Number(value))
: `${value} 个`; : `${value} ${t('stats.countUnit')}`;
return ( return (
<> <>
<div className='flex items-center gap-1.5'> <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'> <Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-bold'> <CardTitle className='text-sm font-bold'>
文件格式数量分布 {t('stats.countDistTitle')}
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
各种格式类型的文件个数占比 {t('stats.countDistDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='flex-1 flex flex-col justify-between gap-4'> <CardContent className='flex-1 flex flex-col justify-between gap-4'>
{categoryCountData.length === 0 ? ( {categoryCountData.length === 0 ? (
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'> <div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无分类数据 {t('stats.noCategoryData')}
</div> </div>
) : ( ) : (
<div className='h-[240px] w-full'> <div className='h-[240px] w-full'>
@@ -475,7 +488,7 @@ export function FileStats() {
</span> </span>
</div> </div>
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'> <span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{value} 个 {value} {t('stats.countUnit')}
</span> </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'> <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' /> <Info className='size-4 shrink-0 mt-0.5' />
<p className='leading-normal'> <p className='leading-normal'>
文件类型中数量最多的是{' '} {t('stats.maxCountHint', {
<strong className='font-bold'> name: maxStats.maxCountName,
「{maxStats.maxCountName}」 count: maxStats.maxCount,
</strong> })}
,累计上传了{' '}
<strong className='font-mono font-bold'>
{maxStats.maxCount}
</strong>{' '}
个文件。
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -516,16 +524,16 @@ export function FileStats() {
<Card className='bg-card/20 border-border/40 flex flex-col'> <Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-bold'> <CardTitle className='text-sm font-bold'>
文件占用容量分布 {t('stats.sizeDistTitle')}
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
各种格式类型的文件大小占比 {t('stats.sizeDistDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='flex-1 flex flex-col justify-between gap-4'> <CardContent className='flex-1 flex flex-col justify-between gap-4'>
{categorySizeData.length === 0 ? ( {categorySizeData.length === 0 ? (
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'> <div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无容量数据 {t('stats.noSizeData')}
</div> </div>
) : ( ) : (
<div className='h-[240px] w-full'> <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'> <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' /> <Info className='size-4 shrink-0 mt-0.5' />
<p className='leading-normal'> <p className='leading-normal'>
文件类型中占用存储最大的是{' '} {t('stats.maxSizeHint', {
<strong className='font-bold'> name: maxStats.maxSizeName,
「{maxStats.maxSizeName}」 size: formatFileSize(maxStats.maxSize),
</strong> })}
,共消耗了{' '}
<strong className='font-mono font-bold'>
{formatFileSize(maxStats.maxSize)}
</strong>{' '}
存储容量。
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Loader2, Play, Save } from 'lucide-react'; import { Database, Loader2, Play, Save } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -44,13 +45,13 @@ import type {
const storageConfigKey = 'storage_config'; const storageConfigKey = 'storage_config';
const storageMigrationTaskType = 'storage_migration'; const storageMigrationTaskType = 'storage_migration';
const driverLabels: Record<StorageDriver, string> = { const driverLabelKeys: Record<StorageDriver, string> = {
local: '本地文件系统', local: 'driverLocal',
s3: 'AWS S3', s3: 'driverS3',
r2: 'Cloudflare R2', r2: 'driverR2',
minio: 'MinIO', minio: 'driverMinio',
oss: '阿里云 OSS', oss: 'driverOss',
webdav: 'WebDAV', webdav: 'driverWebdav',
}; };
const emptyObjectConfig: ObjectStorageConfig = { const emptyObjectConfig: ObjectStorageConfig = {
@@ -112,8 +113,8 @@ function parseMigrationPayload(payload: string): StorageConfig | undefined {
} }
} }
function driverLabel(driver?: StorageDriver): string { function driverLabelKey(driver?: StorageDriver): string {
return driver ? driverLabels[driver] : '未知'; return driver ? driverLabelKeys[driver] : 'driverUnknown';
} }
function latestMigration( function latestMigration(
@@ -136,6 +137,7 @@ function latestMigration(
} }
export function StorageConfigTab() { export function StorageConfigTab() {
const t = useTranslations('admin.files');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [config, setConfig] = React.useState<StorageConfig | null>(null); const [config, setConfig] = React.useState<StorageConfig | null>(null);
const query = useQuery({ const query = useQuery({
@@ -186,13 +188,13 @@ export function StorageConfigTab() {
}); });
}, },
onSuccess: () => { onSuccess: () => {
toast.success('存储配置已保存'); toast.success(t('storage.saveSuccess'));
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'], queryKey: ['admin', 'storage-config'],
}); });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || '保存存储配置失败'); toast.error(error.message || t('storage.saveFailed'));
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'], queryKey: ['admin', 'storage-config'],
}); });
@@ -208,13 +210,13 @@ export function StorageConfigTab() {
}); });
}, },
onSuccess: () => { onSuccess: () => {
toast.success('存储迁移任务已下发'); toast.success(t('storage.migrateSuccess'));
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'], queryKey: ['admin', 'storage-config'],
}); });
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(error.message || '下发存储迁移任务失败'); toast.error(error.message || t('storage.migrateFailed'));
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'], queryKey: ['admin', 'storage-config'],
}); });
@@ -224,12 +226,13 @@ export function StorageConfigTab() {
mutationFn: (executionID: string) => mutationFn: (executionID: string) =>
services.adminTask.retryTaskExecution(executionID), services.adminTask.retryTaskExecution(executionID),
onSuccess: () => { onSuccess: () => {
toast.success('存储迁移任务已重新下发'); toast.success(t('storage.retrySuccess'));
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'], 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) { if (query.isPending || !config) {
@@ -269,7 +272,7 @@ export function StorageConfigTab() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className='flex items-center gap-2'> <CardTitle className='flex items-center gap-2'>
存储维护模式 {t('storage.maintenanceMode')}
<Badge <Badge
variant={ variant={
migration.state === 'failed' ? 'destructive' : 'secondary' migration.state === 'failed' ? 'destructive' : 'secondary'
@@ -279,9 +282,10 @@ export function StorageConfigTab() {
</Badge> </Badge>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{driverLabel(migration.source_driver)} →{' '} {t('storage.maintenanceDesc', {
{driverLabel(migration.target_driver)} source: t(`storage.${driverLabelKey(migration.source_driver)}`),
。迁移期间文件只允许读取,禁止上传、删除和清理。 target: t(`storage.${driverLabelKey(migration.target_driver)}`),
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='flex flex-col gap-3'> <CardContent className='flex flex-col gap-3'>
@@ -289,7 +293,9 @@ export function StorageConfigTab() {
value={migration.state === 'succeeded' ? 100 : undefined} value={migration.state === 'succeeded' ? 100 : undefined}
/> />
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'} {t('storage.migrationProgress', {
taskId: migration.task_id || t('storage.notDispatched'),
})}
</p> </p>
{migration.error && ( {migration.error && (
<p className='text-sm text-destructive'>{migration.error}</p> <p className='text-sm text-destructive'>{migration.error}</p>
@@ -310,7 +316,7 @@ export function StorageConfigTab() {
) : ( ) : (
<Play data-icon='inline-start' /> <Play data-icon='inline-start' />
)} )}
重试迁移 {t('storage.retryMigration')}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
@@ -324,10 +330,10 @@ export function StorageConfigTab() {
</div> </div>
<div> <div>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
文件存储 {t('storage.fileStorage')}
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。 {t('storage.fileStorageDesc')}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -337,7 +343,7 @@ export function StorageConfigTab() {
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'> <div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<Field className='flex flex-col gap-1.5 md:col-span-2'> <Field className='flex flex-col gap-1.5 md:col-span-2'>
<FieldLabel className='text-xs font-semibold'> <FieldLabel className='text-xs font-semibold'>
存储类型 {t('storage.storageType')}
</FieldLabel> </FieldLabel>
<Select <Select
value={config.driver} value={config.driver}
@@ -351,10 +357,10 @@ export function StorageConfigTab() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{(Object.keys(driverLabels) as StorageDriver[]).map( {(Object.keys(driverLabelKeys) as StorageDriver[]).map(
(driver) => ( (driver) => (
<SelectItem key={driver} value={driver}> <SelectItem key={driver} value={driver}>
{driverLabels[driver]} {t(`storage.${driverLabelKeys[driver]}`)}
</SelectItem> </SelectItem>
), ),
)} )}
@@ -366,7 +372,7 @@ export function StorageConfigTab() {
{config.driver === 'local' && ( {config.driver === 'local' && (
<div className='md:col-span-2'> <div className='md:col-span-2'>
<TextField <TextField
label='根目录' label={t('storage.rootDir')}
value={config.local.root} value={config.local.root}
placeholder='.' placeholder='.'
onChange={(root) => onChange={(root) =>
@@ -396,7 +402,7 @@ export function StorageConfigTab() {
<> <>
<div className='md:col-span-2'> <div className='md:col-span-2'>
<TextField <TextField
label='服务地址' label={t('storage.endpoint')}
value={config.webdav.endpoint} value={config.webdav.endpoint}
placeholder='https://dav.example.com' placeholder='https://dav.example.com'
onChange={(endpoint) => onChange={(endpoint) =>
@@ -408,7 +414,7 @@ export function StorageConfigTab() {
/> />
</div> </div>
<TextField <TextField
label='用户名' label={t('storage.username')}
value={config.webdav.username} value={config.webdav.username}
onChange={(username) => onChange={(username) =>
setConfig({ setConfig({
@@ -418,7 +424,7 @@ export function StorageConfigTab() {
} }
/> />
<TextField <TextField
label='密码' label={t('storage.password')}
type='password' type='password'
value={config.webdav.password} value={config.webdav.password}
onChange={(password) => onChange={(password) =>
@@ -430,7 +436,7 @@ export function StorageConfigTab() {
/> />
<div className='md:col-span-2'> <div className='md:col-span-2'>
<TextField <TextField
label='基础路径' label={t('storage.basePath')}
value={config.webdav.base_path} value={config.webdav.base_path}
placeholder='openflare' placeholder='openflare'
onChange={(base_path) => 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'> <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 && ( {config.driver !== query.data?.config.driver && (
<span className='text-xs text-amber-500 mr-auto text-left max-w-md'> <span className='text-xs text-amber-500 mr-auto text-left max-w-md'>
⚠️ {t('storage.switchWarning')}
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
</span> </span>
)} )}
<div className='flex gap-2'> <div className='flex gap-2'>
@@ -465,7 +470,7 @@ export function StorageConfigTab() {
) : ( ) : (
<Save data-icon='inline-start' /> <Save data-icon='inline-start' />
)} )}
保存配置 {t('storage.saveConfig')}
</Button> </Button>
{config.driver !== query.data?.config.driver && ( {config.driver !== query.data?.config.driver && (
<Button <Button
@@ -477,7 +482,7 @@ export function StorageConfigTab() {
) : ( ) : (
<Play data-icon='inline-start' /> <Play data-icon='inline-start' />
)} )}
开始迁移 {t('storage.startMigration')}
</Button> </Button>
)} )}
</div> </div>
@@ -496,6 +501,7 @@ function ObjectFields({
value: ObjectStorageConfig; value: ObjectStorageConfig;
onChange: (patch: Partial<ObjectStorageConfig>) => void; onChange: (patch: Partial<ObjectStorageConfig>) => void;
}) { }) {
const t = useTranslations('admin.files');
return ( return (
<> <>
{driver === 'r2' && ( {driver === 'r2' && (
@@ -539,13 +545,13 @@ function ObjectFields({
onChange={(secret_access_key) => onChange({ secret_access_key })} onChange={(secret_access_key) => onChange({ secret_access_key })}
/> />
<TextField <TextField
label='对象前缀' label={t('storage.keyPrefix')}
value={value.key_prefix} value={value.key_prefix}
placeholder='uploads' placeholder='uploads'
onChange={(key_prefix) => onChange({ key_prefix })} onChange={(key_prefix) => onChange({ key_prefix })}
/> />
<TextField <TextField
label='CDN 地址' label={t('storage.cdnUrl')}
value={value.cdn_url} value={value.cdn_url}
placeholder='https://cdn.example.com' placeholder='https://cdn.example.com'
onChange={(cdn_url) => onChange({ cdn_url })} onChange={(cdn_url) => onChange({ cdn_url })}
@@ -561,7 +567,7 @@ function ObjectFields({
Path Style Path Style
</FieldLabel> </FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'> <FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
MinIO 等自托管 S3 通常需要开启。 {t('storage.pathStyleDesc')}
</FieldDescription> </FieldDescription>
</div> </div>
<Switch <Switch
+8 -4
View File
@@ -3,6 +3,7 @@
import * as React from 'react'; import * as React from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { FolderOpen } from 'lucide-react'; import { FolderOpen } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FileStats } from './components/file-stats'; import { FileStats } from './components/file-stats';
@@ -10,6 +11,7 @@ import { FileList } from './components/file-list';
import { StorageConfigTab } from './components/storage-config-tab'; import { StorageConfigTab } from './components/storage-config-tab';
export default function FilesPage() { export default function FilesPage() {
const t = useTranslations('admin.files');
const [activeTab, setActiveTab] = React.useState('stats'); const [activeTab, setActiveTab] = React.useState('stats');
return ( return (
@@ -23,7 +25,9 @@ export default function FilesPage() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<FolderOpen className='size-5 text-primary' /> <FolderOpen className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>存储管理</h1> <h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div> </div>
</div> </div>
@@ -33,16 +37,16 @@ export default function FilesPage() {
value='stats' value='stats'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
文件存储信息 {t('tabStats')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value='list' className='px-0 pb-2 text-xs font-semibold'> <TabsTrigger value='list' className='px-0 pb-2 text-xs font-semibold'>
文件列表 {t('tabList')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='storage' value='storage'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
存储配置 {t('tabStorage')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -28,6 +28,7 @@ import {
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import services from '@/lib/services'; import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error'; import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading'; import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -65,12 +66,15 @@ interface TopUserData {
count: number; count: number;
} }
const chartConfig = { function useAnalyticsChartConfig() {
const t = useTranslations('admin.logs.analytics');
return {
count: { count: {
label: '请求量', label: t('requestVolume'),
color: 'hsl(var(--primary))', color: 'hsl(var(--primary))',
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
}
// Format YYYY-MM-DD to MM/DD // Format YYYY-MM-DD to MM/DD
function formatDateLabel(dateStr: string) { function formatDateLabel(dateStr: string) {
@@ -83,6 +87,8 @@ function formatDateLabel(dateStr: string) {
} }
export function AccessAnalytics() { export function AccessAnalytics() {
const t = useTranslations('admin.logs.analytics');
const chartConfig = useAnalyticsChartConfig();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [clickhouseDisabled, setClickhouseDisabled] = useState(false); const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
@@ -109,7 +115,7 @@ export function AccessAnalytics() {
setClickhouseDisabled(false); setClickhouseDisabled(false);
} catch (err) { } catch (err) {
const errorInstance = const errorInstance =
err instanceof Error ? err : new Error('获取数据统计失败'); err instanceof Error ? err : new Error(t('fetchFailed'));
const errMsg = errorInstance.message || ''; const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) { if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
setClickhouseDisabled(true); setClickhouseDisabled(true);
@@ -119,7 +125,7 @@ export function AccessAnalytics() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
fetchAnalytics(); fetchAnalytics();
@@ -135,11 +141,9 @@ export function AccessAnalytics() {
return ( 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]'> <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' /> <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'> <p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse {t('clickhouseDisabledDesc')}
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
</p> </p>
</div> </div>
); );
@@ -153,7 +157,7 @@ export function AccessAnalytics() {
return ( return (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={BarChart3} icon={BarChart3}
description='加载访问统计指标中...' description={t('loadingAnalytics')}
/> />
); );
} }
@@ -165,7 +169,7 @@ export function AccessAnalytics() {
<Card className='bg-card/25 border-border/40'> <Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'> <CardTitle className='text-sm font-semibold tracking-tight'>
近 7 天总请求数 {t('totalRequests7d')}
</CardTitle> </CardTitle>
<TrendingUp className='size-4 text-muted-foreground' /> <TrendingUp className='size-4 text-muted-foreground' />
</CardHeader> </CardHeader>
@@ -174,14 +178,14 @@ export function AccessAnalytics() {
{totalTrendRequests.toLocaleString()} {totalTrendRequests.toLocaleString()}
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1'> <p className='text-[10px] text-muted-foreground mt-1'>
系统记录的所有成功认证的访问总频次 {t('totalRequests7dDesc')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className='bg-card/25 border-border/40'> <Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'> <CardTitle className='text-sm font-semibold tracking-tight'>
活跃终端分类数 {t('activeBrowserTypes')}
</CardTitle> </CardTitle>
<Globe className='size-4 text-muted-foreground' /> <Globe className='size-4 text-muted-foreground' />
</CardHeader> </CardHeader>
@@ -190,14 +194,14 @@ export function AccessAnalytics() {
{browsers.length} {browsers.length}
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1'> <p className='text-[10px] text-muted-foreground mt-1'>
在一周内发起请求的浏览器代理大类统计 {t('activeBrowserTypesDesc')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className='bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'> <CardTitle className='text-sm font-semibold tracking-tight'>
活跃独立用户数 {t('activeUsers')}
</CardTitle> </CardTitle>
<Users className='size-4 text-muted-foreground' /> <Users className='size-4 text-muted-foreground' />
</CardHeader> </CardHeader>
@@ -206,7 +210,7 @@ export function AccessAnalytics() {
{topUsers.length} {topUsers.length}
</div> </div>
<p className='text-[10px] text-muted-foreground mt-1'> <p className='text-[10px] text-muted-foreground mt-1'>
一周内累计发起高频请求的注册账户总量 {t('activeUsersDesc')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -217,10 +221,10 @@ export function AccessAnalytics() {
<CardHeader className='flex flex-row items-center justify-between'> <CardHeader className='flex flex-row items-center justify-between'>
<div> <div>
<CardTitle className='text-base font-bold'> <CardTitle className='text-base font-bold'>
一周访问量趋势 {t('weeklyAccessTrend')}
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
展现系统最近 7 天内的每日 API 请求曲线 {t('weeklyAccessTrendDesc')}
</CardDescription> </CardDescription>
</div> </div>
<Button <Button
@@ -241,7 +245,7 @@ export function AccessAnalytics() {
{trend.length === 0 ? ( {trend.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={TrendingUp} icon={TrendingUp}
description='暂无趋势数据' description={t('noTrendData')}
/> />
) : ( ) : (
<div className='h-[280px] w-full'> <div className='h-[280px] w-full'>
@@ -289,7 +293,7 @@ export function AccessAnalytics() {
strokeWidth={2} strokeWidth={2}
fillOpacity={1} fillOpacity={1}
fill='url(#colorCount)' fill='url(#colorCount)'
name='请求次数' name={t('requestCount')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -305,17 +309,17 @@ export function AccessAnalytics() {
<CardHeader> <CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'> <CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Globe className='size-4.5 text-muted-foreground' /> <Globe className='size-4.5 text-muted-foreground' />
使用的浏览器排行 {t('browserRanking')}
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
基于请求头 User-Agent 智能分类的一周占比统计 {t('browserRankingDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='flex-1'> <CardContent className='flex-1'>
{browsers.length === 0 ? ( {browsers.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Globe} icon={Globe}
description='暂无浏览器排行数据' description={t('noBrowserData')}
/> />
) : ( ) : (
<div className='space-y-4'> <div className='space-y-4'>
@@ -337,8 +341,10 @@ export function AccessAnalytics() {
<span className='font-semibold'>{item.browser}</span> <span className='font-semibold'>{item.browser}</span>
</span> </span>
<span className='text-muted-foreground font-mono text-xs'> <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> </span>
</div> </div>
<div className='h-2 w-full rounded-full bg-muted overflow-hidden'> <div className='h-2 w-full rounded-full bg-muted overflow-hidden'>
@@ -360,17 +366,17 @@ export function AccessAnalytics() {
<CardHeader> <CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'> <CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Users className='size-4.5 text-muted-foreground' /> <Users className='size-4.5 text-muted-foreground' />
最活跃的用户排行 (Top 10) {t('topActiveUsers')}
</CardTitle> </CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>
统计最近一周发起接口访问请求数量最多的账户 {t('topActiveUsersDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='flex-1'> <CardContent className='flex-1'>
{topUsers.length === 0 ? ( {topUsers.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Users} icon={Users}
description='暂无活跃用户数据' description={t('noActiveUsersData')}
/> />
) : ( ) : (
<div className='divide-y divide-border/40'> <div className='divide-y divide-border/40'>
@@ -385,10 +391,13 @@ export function AccessAnalytics() {
</div> </div>
<div className='truncate max-w-[180px] sm:max-w-xs'> <div className='truncate max-w-[180px] sm:max-w-xs'>
<div className='text-xs font-bold leading-tight truncate'> <div className='text-xs font-bold leading-tight truncate'>
{user.username || '未知'} {user.username || t('unknown')}
</div> </div>
<div className='text-[10px] text-muted-foreground leading-normal truncate'> <div className='text-[10px] text-muted-foreground leading-normal truncate'>
{user.nickname ? `(${user.nickname})` : '(无昵称)'} | {user.nickname
? `(${user.nickname})`
: t('noNickname')}{' '}
|
ID: {user.user_id} ID: {user.user_id}
</div> </div>
</div> </div>
@@ -398,7 +407,7 @@ export function AccessAnalytics() {
{user.count.toLocaleString()} {user.count.toLocaleString()}
</div> </div>
<div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'> <div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'>
次请求 {t('requests')}
</div> </div>
</div> </div>
</div> </div>
@@ -32,6 +32,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import services from '@/lib/services'; import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error'; import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading'; import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -97,6 +98,7 @@ function isClickhouseDisabledError(error: Error) {
} }
export function AccessLogs() { export function AccessLogs() {
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// 搜索过滤条件 // 搜索过滤条件
@@ -184,12 +186,12 @@ export function AccessLogs() {
const copyToClipboard = (text: string, subject: string) => { const copyToClipboard = (text: string, subject: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
toast.success(`${subject}已复制到剪贴板`); toast.success(t('copiedToClipboard', { subject }));
}; };
// Prettify Headers JSON string // Prettify Headers JSON string
const getPrettyHeaders = (headersRaw?: string) => { const getPrettyHeaders = (headersRaw?: string) => {
if (!headersRaw) return '暂无头部数据'; if (!headersRaw) return t('noHeaderData');
try { try {
const parsed = JSON.parse(headersRaw); const parsed = JSON.parse(headersRaw);
return JSON.stringify(parsed, null, 2); return JSON.stringify(parsed, null, 2);
@@ -202,11 +204,9 @@ export function AccessLogs() {
return ( 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]'> <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' /> <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'> <p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse {t('clickhouseDisabledDesc')}
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
</p> </p>
</div> </div>
); );
@@ -227,11 +227,11 @@ export function AccessLogs() {
> >
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label htmlFor='search-user' className='text-xs'> <Label htmlFor='search-user' className='text-xs'>
用户名 {t('username')}
</Label> </Label>
<Input <Input
id='search-user' id='search-user'
placeholder='输入用户名搜索...' placeholder={t('usernamePlaceholder')}
value={usernameFilter} value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)} onChange={(e) => setUsernameFilter(e.target.value)}
className='h-8 text-xs' className='h-8 text-xs'
@@ -239,11 +239,11 @@ export function AccessLogs() {
</div> </div>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label htmlFor='search-path' className='text-xs'> <Label htmlFor='search-path' className='text-xs'>
接口路径 {t('apiPath')}
</Label> </Label>
<Input <Input
id='search-path' id='search-path'
placeholder='模糊匹配路径...' placeholder={t('pathPlaceholder')}
value={pathFilter} value={pathFilter}
onChange={(e) => setPathFilter(e.target.value)} onChange={(e) => setPathFilter(e.target.value)}
className='h-8 text-xs' className='h-8 text-xs'
@@ -251,7 +251,7 @@ export function AccessLogs() {
</div> </div>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label htmlFor='search-start' className='text-xs'> <Label htmlFor='search-start' className='text-xs'>
起始时间 {t('startTime')}
</Label> </Label>
<Input <Input
id='search-start' id='search-start'
@@ -263,7 +263,7 @@ export function AccessLogs() {
</div> </div>
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label htmlFor='search-end' className='text-xs'> <Label htmlFor='search-end' className='text-xs'>
结束时间 {t('endTime')}
</Label> </Label>
<Input <Input
id='search-end' id='search-end'
@@ -276,7 +276,7 @@ export function AccessLogs() {
<div className='flex gap-2'> <div className='flex gap-2'>
<Button type='submit' size='sm' className='h-8 flex-1'> <Button type='submit' size='sm' className='h-8 flex-1'>
<Search className='size-3.5 mr-1' /> <Search className='size-3.5 mr-1' />
搜索 {t('search')}
</Button> </Button>
<Button <Button
type='button' type='button'
@@ -286,7 +286,7 @@ export function AccessLogs() {
className='h-8 flex-1' className='h-8 flex-1'
> >
<RotateCcw className='size-3.5 mr-1' /> <RotateCcw className='size-3.5 mr-1' />
重置 {t('reset')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -295,26 +295,27 @@ export function AccessLogs() {
{loading && logs.length === 0 ? ( {loading && logs.length === 0 ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Activity} icon={Activity}
description='加载访问日志中...' description={t('loadingAccessLogs')}
/> />
) : logs.length === 0 ? ( ) : logs.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder icon={Activity} description={t('noAccessLogs')} />
icon={Activity}
description='未查询到任何访问日志数据'
/>
) : ( ) : (
<div className='rounded-lg border bg-card overflow-hidden'> <div className='rounded-lg border bg-card overflow-hidden'>
<Table className='min-w-[900px]'> <Table className='min-w-[900px]'>
<TableHeader> <TableHeader>
<TableRow className='hover:bg-transparent'> <TableRow className='hover:bg-transparent'>
<TableHead className='w-[100px]'>请求方法</TableHead> <TableHead className='w-[100px]'>{t('colMethod')}</TableHead>
<TableHead className='min-w-[200px]'>路径</TableHead> <TableHead className='min-w-[200px]'>{t('colPath')}</TableHead>
<TableHead className='w-[150px]'>用户</TableHead> <TableHead className='w-[150px]'>{t('colUser')}</TableHead>
<TableHead className='w-[120px]'>IP</TableHead> <TableHead className='w-[120px]'>{t('colIp')}</TableHead>
<TableHead className='w-[90px]'>状态</TableHead> <TableHead className='w-[90px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead> <TableHead className='w-[100px]'>{t('colLatency')}</TableHead>
<TableHead className='w-[170px]'>请求时间</TableHead> <TableHead className='w-[170px]'>
<TableHead className='w-[80px] text-center'>详情</TableHead> {t('colRequestTime')}
</TableHead>
<TableHead className='w-[80px] text-center'>
{t('colDetail')}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -334,7 +335,7 @@ export function AccessLogs() {
<TableCell> <TableCell>
<div className='flex flex-col'> <div className='flex flex-col'>
<span className='text-xs font-medium'> <span className='text-xs font-medium'>
{log.username || '未知用户'} {log.username || t('unknownUser')}
</span> </span>
{log.nickname && ( {log.nickname && (
<span className='text-[10px] text-muted-foreground'> <span className='text-[10px] text-muted-foreground'>
@@ -381,7 +382,7 @@ export function AccessLogs() {
{logs.length > 0 && ( {logs.length > 0 && (
<div className='flex items-center justify-between py-1'> <div className='flex items-center justify-between py-1'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
共 {total} 条记录,当前第 {page}/{totalPages} 页 {t('paginationInfo', { total, page, totalPages })}
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button <Button
@@ -392,7 +393,7 @@ export function AccessLogs() {
className='h-8 text-xs' className='h-8 text-xs'
> >
<ChevronLeft className='size-3.5 mr-1' /> <ChevronLeft className='size-3.5 mr-1' />
上一页 {t('prevPage')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -401,7 +402,7 @@ export function AccessLogs() {
disabled={page >= totalPages || loading} disabled={page >= totalPages || loading}
className='h-8 text-xs' className='h-8 text-xs'
> >
下一页 {t('nextPage')}
<ChevronRight className='size-3.5 ml-1' /> <ChevronRight className='size-3.5 ml-1' />
</Button> </Button>
</div> </div>
@@ -412,9 +413,11 @@ export function AccessLogs() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}> <Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l'> <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'> <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'> <SheetDescription className='text-xs'>
访问 ID: {selectedLog?.id} {t('accessId', { id: selectedLog?.id ?? '' })}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -424,7 +427,7 @@ export function AccessLogs() {
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3'> <div className='grid grid-cols-2 gap-3 sm:grid-cols-3'>
<div className='rounded-lg border p-3 bg-muted/10'> <div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'> <div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
请求方法 {t('method')}
</div> </div>
<div className='mt-1 font-mono text-sm font-bold uppercase'> <div className='mt-1 font-mono text-sm font-bold uppercase'>
{selectedLog.method} {selectedLog.method}
@@ -432,7 +435,7 @@ export function AccessLogs() {
</div> </div>
<div className='rounded-lg border p-3 bg-muted/10'> <div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'> <div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
响应状态 {t('responseStatus')}
</div> </div>
<div className='mt-1'> <div className='mt-1'>
<Badge variant={statusVariant(selectedLog.status)}> <Badge variant={statusVariant(selectedLog.status)}>
@@ -442,7 +445,7 @@ export function AccessLogs() {
</div> </div>
<div className='rounded-lg border p-3 bg-muted/10'> <div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'> <div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
耗时 {t('latency')}
</div> </div>
<div className='mt-1 font-mono text-sm'> <div className='mt-1 font-mono text-sm'>
{formatLatency(selectedLog.latency)} {formatLatency(selectedLog.latency)}
@@ -450,18 +453,18 @@ export function AccessLogs() {
</div> </div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1'> <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'> <div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
IP 地址 {t('ipAddress')}
</div> </div>
<div className='mt-1 font-mono text-sm'>{selectedLog.ip}</div> <div className='mt-1 font-mono text-sm'>{selectedLog.ip}</div>
</div> </div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2'> <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'> <div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
用户 {t('user')}
</div> </div>
<div className='mt-1 text-sm font-medium'> <div className='mt-1 text-sm font-medium'>
{selectedLog.username {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'> <span className='block font-mono text-[10px] text-muted-foreground mt-0.5'>
ID: {selectedLog.user_id} ID: {selectedLog.user_id}
</span> </span>
@@ -472,7 +475,7 @@ export function AccessLogs() {
{/* Path */} {/* Path */}
<div className='grid gap-2'> <div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'> <Label className='text-xs font-bold text-muted-foreground'>
请求路径 {t('requestPath')}
</Label> </Label>
<div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'> <div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'>
{selectedLog.path} {selectedLog.path}
@@ -482,7 +485,7 @@ export function AccessLogs() {
{/* Request Time */} {/* Request Time */}
<div className='grid gap-2'> <div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'> <Label className='text-xs font-bold text-muted-foreground'>
请求时间 {t('requestTime')}
</Label> </Label>
<div className='font-mono text-xs'> <div className='font-mono text-xs'>
{formatDateTime(selectedLog.created_at)} {formatDateTime(selectedLog.created_at)}
@@ -502,7 +505,7 @@ export function AccessLogs() {
onClick={() => onClick={() =>
copyToClipboard(selectedLog.user_agent, 'User-Agent') copyToClipboard(selectedLog.user_agent, 'User-Agent')
} }
title='复制 User-Agent' title={t('copyUserAgent')}
> >
<Copy className='size-3' /> <Copy className='size-3' />
</Button> </Button>
@@ -528,7 +531,7 @@ export function AccessLogs() {
'Headers', 'Headers',
) )
} }
title='复制 Headers' title={t('copyHeaders')}
> >
<Copy className='size-3' /> <Copy className='size-3' />
</Button> </Button>
@@ -542,7 +545,7 @@ export function AccessLogs() {
<div className='flex-grow flex items-center justify-center'> <div className='flex-grow flex items-center justify-center'>
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Activity} icon={Activity}
description='未选中日志明细' description={t('noLogSelected')}
/> />
</div> </div>
)} )}
@@ -22,6 +22,7 @@ import { toast } from 'sonner';
import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react'; import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react';
import services from '@/lib/services'; import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error'; import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading'; import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -92,6 +93,7 @@ function parseLogLevel(
} }
export function AppLogs() { export function AppLogs() {
const t = useTranslations('admin.logs.appLogs');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
@@ -176,9 +178,9 @@ export function AppLogs() {
try { try {
return await services.adminLog.getLogs(cursor); return await services.adminLog.getLogs(cursor);
} catch (err) { } catch (err) {
throw err instanceof Error ? err : new Error('获取日志失败'); throw err instanceof Error ? err : new Error(t('fetchFailed'));
} }
}, []); }, [t]);
const loadHistory = useCallback( const loadHistory = useCallback(
async (cursor: number = 0) => { async (cursor: number = 0) => {
@@ -209,16 +211,16 @@ export function AppLogs() {
setNextCursor(data.next_cursor); setNextCursor(data.next_cursor);
} catch (err) { } catch (err) {
if (isInitial) { if (isInitial) {
setError(err instanceof Error ? err : new Error('获取日志失败')); setError(err instanceof Error ? err : new Error(t('fetchFailed')));
} else { } else {
toast.error('加载更早日志失败'); toast.error(t('loadOlderFailed'));
} }
} finally { } finally {
if (isInitial) setLoading(false); if (isInitial) setLoading(false);
else setLoadingMore(false); else setLoadingMore(false);
} }
}, },
[fetchLogs], [fetchLogs, t],
); );
// ---- WebSocket ------------------------------------------------------- // ---- WebSocket -------------------------------------------------------
@@ -300,12 +302,12 @@ export function AppLogs() {
{loadingMore ? ( {loadingMore ? (
<> <>
<Loader2 className='size-3 mr-1.5 animate-spin' /> <Loader2 className='size-3 mr-1.5 animate-spin' />
加载中... {t('loading')}
</> </>
) : ( ) : (
<> <>
<ChevronUp className='size-3 mr-1.5' /> <ChevronUp className='size-3 mr-1.5' />
加载更早日志 {t('loadOlderLogs')}
</> </>
)} )}
</Button> </Button>
@@ -313,7 +315,9 @@ export function AppLogs() {
)} )}
{logs.length === 0 ? ( {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 <div
className='relative px-3 py-3' 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' className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
> >
<ArrowDown className='size-3.5 mr-1.5' /> <ArrowDown className='size-3.5 mr-1.5' />
回到最新 {t('backToLatest')}
</Button> </Button>
</div> </div>
)} )}
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Activity, BarChart3, Terminal } from 'lucide-react'; import { Activity, BarChart3, Terminal } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -26,13 +27,16 @@ const AppLogs = dynamic(
); );
export function LogsPageClient() { export function LogsPageClient() {
const t = useTranslations('admin.logs');
return ( return (
<div className='flex flex-col h-full space-y-6 py-6'> <div className='flex flex-col h-full space-y-6 py-6'>
{/* Header */} {/* Header */}
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Terminal className='size-5 text-primary' /> <Terminal className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>系统日志</h1> <h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div> </div>
</div> </div>
{/* Tabs Layout */} {/* Tabs Layout */}
@@ -43,21 +47,21 @@ export function LogsPageClient() {
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5' className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
> >
<BarChart3 className='size-3.5' /> <BarChart3 className='size-3.5' />
访问分析 {t('accessAnalytics')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='access' value='access'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5' className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
> >
<Activity className='size-3.5' /> <Activity className='size-3.5' />
用户访问日志 {t('userAccessLogs')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='app' value='app'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5' className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
> >
<Terminal className='size-3.5' /> <Terminal className='size-3.5' />
应用运行日志 {t('appRuntimeLogs')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -4,6 +4,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -80,6 +81,7 @@ import type {
import { PushService } from '@/lib/services/push'; import { PushService } from '@/lib/services/push';
export function EventsTab() { export function EventsTab() {
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>( const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
null, null,
@@ -120,12 +122,12 @@ export function EventsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) =>
PushService.updateEvent(id, data), PushService.updateEvent(id, data),
onSuccess: () => { onSuccess: () => {
toast.success('事件更新成功'); toast.success(t('eventUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setEditEventOpen(false); setEditEventOpen(false);
}, },
onError: (err: unknown) => { 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'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
toast.error('操作失败: ' + (err as Error).message); toast.error(t('operationFailed') + ': ' + (err as Error).message);
}, },
}); });
const createEventMutation = useMutation({ const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data), mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => { onSuccess: () => {
toast.success('事件创建成功'); toast.success(t('eventCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setCreateEventOpen(false); setCreateEventOpen(false);
setNewEventKey(''); setNewEventKey('');
@@ -152,7 +154,7 @@ export function EventsTab() {
setNewEventEnabled(true); setNewEventEnabled(true);
}, },
onError: (err: unknown) => { 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), mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => { onSuccess: () => {
setDeleteTarget(null); setDeleteTarget(null);
toast.success('配置删除成功'); toast.success(t('configDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
}, },
onError: (err: unknown) => { 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 { try {
JSON.parse(eventTemplate); JSON.parse(eventTemplate);
} catch { } catch {
toast.error('消息模板不是合法的 JSON 格式'); toast.error(t('templateInvalidJson'));
return; return;
} }
@@ -260,8 +262,8 @@ export function EventsTab() {
); );
if (taskMeta) { if (taskMeta) {
const defaultTemplate = { const defaultTemplate = {
title: `任务完成: ${taskMeta.name}`, title: t('taskCompleteTitle', { name: taskMeta.name }),
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`, content: t('taskCompleteContent'),
level: 'INFO', level: 'INFO',
}; };
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2)); setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2));
@@ -272,11 +274,11 @@ export function EventsTab() {
const handleCreateEvent = () => { const handleCreateEvent = () => {
if (newEventType === 'builtin' && !newEventKey) { if (newEventType === 'builtin' && !newEventKey) {
toast.error('请选择系统事件'); toast.error(t('selectSystemEvent'));
return; return;
} }
if (newEventType === 'task' && !newEventTaskType) { if (newEventType === 'task' && !newEventTaskType) {
toast.error('请选择异步任务'); toast.error(t('selectAsyncTask'));
return; return;
} }
@@ -284,7 +286,7 @@ export function EventsTab() {
try { try {
JSON.parse(newEventTemplate); JSON.parse(newEventTemplate);
} catch { } catch {
toast.error('内容渲染模板不是合法的 JSON 格式'); toast.error(t('templateInvalidJson'));
return; return;
} }
} }
@@ -309,11 +311,11 @@ export function EventsTab() {
<div className='flex justify-end'> <div className='flex justify-end'>
<Button size='sm' onClick={handleCreateEventClick} className='text-xs'> <Button size='sm' onClick={handleCreateEventClick} className='text-xs'>
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
新增通知事件 {t('addEvent')}
</Button> </Button>
</div> </div>
{eventsQuery.isLoading ? ( {eventsQuery.isLoading ? (
<LoadingStateWithBorder icon={Layers} description='加载通知事件中...' /> <LoadingStateWithBorder icon={Layers} description={t('loadingEvents')} />
) : eventsQuery.isError ? ( ) : eventsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'> <div className='p-8 border border-dashed rounded-xl bg-card'>
<ErrorInline <ErrorInline
@@ -325,7 +327,7 @@ export function EventsTab() {
) : (eventsQuery.data ?? []).length === 0 ? ( ) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Layers} icon={Layers}
description='暂无通知配置事件,请点击右上角新增' description={t('noEvents')}
/> />
) : ( ) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'> <div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
@@ -336,19 +338,19 @@ export function EventsTab() {
ID ID
</TableHead> </TableHead>
<TableHead className='w-[180px] whitespace-nowrap py-2 h-8'> <TableHead className='w-[180px] whitespace-nowrap py-2 h-8'>
通知事件 {t('colEvent')}
</TableHead> </TableHead>
<TableHead className='w-[200px] whitespace-nowrap py-2 h-8'> <TableHead className='w-[200px] whitespace-nowrap py-2 h-8'>
关联渠道 {t('colChannels')}
</TableHead> </TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'> <TableHead className='whitespace-nowrap py-2 h-8'>
推送目标 {t('colTargets')}
</TableHead> </TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'> <TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态 {t('colStatus')}
</TableHead> </TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'> <TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作 {t('colActions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -376,7 +378,7 @@ export function EventsTab() {
variant='outline' variant='outline'
className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200' className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200'
> >
任务 {t('taskBadge')}
</Badge> </Badge>
)} )}
</div> </div>
@@ -389,7 +391,7 @@ export function EventsTab() {
<div className='flex flex-wrap gap-1'> <div className='flex flex-wrap gap-1'>
{(event.channels ?? []).length === 0 ? ( {(event.channels ?? []).length === 0 ? (
<span className='text-xs text-muted-foreground italic'> <span className='text-xs text-muted-foreground italic'>
未指定渠道 {t('noChannelSpecified')}
</span> </span>
) : ( ) : (
event.channels.map((ch) => ( event.channels.map((ch) => (
@@ -398,7 +400,7 @@ export function EventsTab() {
variant='secondary' variant='secondary'
className='text-[10px] py-0 px-1.5 h-4.5' className='text-[10px] py-0 px-1.5 h-4.5'
> >
{ch === 'email' ? '邮件' : ch} {ch === 'email' ? t('emailChannel') : ch}
</Badge> </Badge>
)) ))
)} )}
@@ -453,7 +455,7 @@ export function EventsTab() {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='text-xs'> <TooltipContent side='top' className='text-xs'>
配置 {t('configure')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -472,7 +474,7 @@ export function EventsTab() {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='text-xs'> <TooltipContent side='top' className='text-xs'>
删除 {t('delete')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -489,15 +491,15 @@ export function EventsTab() {
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}> <Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'> <DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader> <DialogHeader>
<DialogTitle>新增通知事件</DialogTitle> <DialogTitle>{t('addEventDialogTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效 {t('addEventDialogDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='space-y-4 py-4'> <div className='space-y-4 py-4'>
<div className='space-y-1.5'> <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'> <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'> <label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input <input
@@ -511,7 +513,7 @@ export function EventsTab() {
}} }}
className='scale-90' className='scale-90'
/> />
<span>系统内置事件</span> <span>{t('builtinEventType')}</span>
</label> </label>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'> <label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input <input
@@ -525,22 +527,22 @@ export function EventsTab() {
}} }}
className='scale-90' className='scale-90'
/> />
<span>任务完成事件</span> <span>{t('taskEventType')}</span>
</label> </label>
</div> </div>
</div> </div>
{newEventType === 'builtin' ? ( {newEventType === 'builtin' ? (
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统事件</Label> <Label className='text-xs font-semibold'>{t('systemEvent')}</Label>
{builtInEventsQuery.isLoading ? ( {builtInEventsQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'> <div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' /> <Loader2 className='size-3.5 animate-spin' />
<span>加载系统事件中...</span> <span>{t('loadingSystemEvents')}</span>
</div> </div>
) : availableBuiltInEvents.length === 0 ? ( ) : availableBuiltInEvents.length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'> <div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
所有内置事件都已配置,没有可新增的事件。 {t('allBuiltinEventsConfigured')}
</div> </div>
) : ( ) : (
<Select <Select
@@ -548,7 +550,9 @@ export function EventsTab() {
onValueChange={handleNewEventKeyChange} onValueChange={handleNewEventKeyChange}
> >
<SelectTrigger className='text-xs h-9'> <SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择系统事件' /> <SelectValue
placeholder={t('selectSystemEventPlaceholder')}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableBuiltInEvents.map((ev) => ( {availableBuiltInEvents.map((ev) => (
@@ -566,15 +570,17 @@ export function EventsTab() {
</div> </div>
) : ( ) : (
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统异步任务</Label> <Label className='text-xs font-semibold'>
{t('systemAsyncTask')}
</Label>
{taskTypesQuery.isLoading ? ( {taskTypesQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'> <div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' /> <Loader2 className='size-3.5 animate-spin' />
<span>加载异步任务中...</span> <span>{t('loadingAsyncTasks')}</span>
</div> </div>
) : (taskTypesQuery.data ?? []).length === 0 ? ( ) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'> <div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
暂无可用的系统任务。 {t('noAvailableTasks')}
</div> </div>
) : ( ) : (
<Select <Select
@@ -582,7 +588,9 @@ export function EventsTab() {
onValueChange={handleNewEventTaskTypeChange} onValueChange={handleNewEventTaskTypeChange}
> >
<SelectTrigger className='text-xs h-9'> <SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择异步任务' /> <SelectValue
placeholder={t('selectAsyncTaskPlaceholder')}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(taskTypesQuery.data ?? []).map((taskMeta) => ( {(taskTypesQuery.data ?? []).map((taskMeta) => (
@@ -603,11 +611,11 @@ export function EventsTab() {
{newEventType === 'builtin' && newEventKey && ( {newEventType === 'builtin' && newEventKey && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'> <div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'> <span className='font-semibold text-foreground'>
事件说明: {t('eventDescription')}
</span> </span>
<span> <span>
{availableBuiltInEvents.find((e) => e.key === newEventKey) {availableBuiltInEvents.find((e) => e.key === newEventKey)
?.description || '无描述信息'} ?.description || t('noDescription')}
</span> </span>
</div> </div>
)} )}
@@ -615,18 +623,20 @@ export function EventsTab() {
{newEventType === 'task' && newEventTaskType && ( {newEventType === 'task' && newEventTaskType && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'> <div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'> <span className='font-semibold text-foreground'>
任务说明: {t('taskDescription')}
</span> </span>
<span> <span>
{(taskTypesQuery.data ?? []).find( {(taskTypesQuery.data ?? []).find(
(t) => t.asynq_task === newEventTaskType, (t) => t.asynq_task === newEventTaskType,
)?.description || '无描述信息'} )?.description || t('noDescription')}
</span> </span>
</div> </div>
)} )}
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'>推送渠道 (可多选)</Label> <Label className='text-xs font-semibold'>
{t('pushChannelsMulti')}
</Label>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -636,11 +646,11 @@ export function EventsTab() {
{newEventChannels.length > 0 {newEventChannels.length > 0
? newEventChannels ? newEventChannels
.map((ch) => { .map((ch) => {
if (ch === 'email') return '邮件'; if (ch === 'email') return t('emailChannel');
return ch; return ch;
}) })
.join(', ') .join(', ')
: '选择已配置推送渠道'} : t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' /> <ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -666,12 +676,12 @@ export function EventsTab() {
} }
}} }}
/> />
<span>{ch === 'email' ? '邮件推送' : ch}</span> <span>{ch === 'email' ? t('emailPush') : ch}</span>
</label> </label>
))} ))}
{availableChannels.length === 0 && ( {availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'> <div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建或启用。 {t('noAvailableChannels')}
</div> </div>
)} )}
</div> </div>
@@ -681,11 +691,11 @@ export function EventsTab() {
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'> <Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要) {t('pushTargets')}
</Label> </Label>
<Input <Input
type='text' type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com' placeholder={t('pushTargetsPlaceholder')}
value={newEventTargets} value={newEventTargets}
onChange={(e) => setNewEventTargets(e.target.value)} onChange={(e) => setNewEventTargets(e.target.value)}
className='text-xs h-9' className='text-xs h-9'
@@ -695,12 +705,12 @@ export function EventsTab() {
<div className='space-y-1.5'> <div className='space-y-1.5'>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'> <Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式) {t('contentTemplate')}
</Label> </Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'> <span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{newEventType === 'task' {newEventType === 'task'
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}' ? t('taskTemplateVars')
: '支持变量:{{user.username}}, {{ip}}, {{time}}'} : t('eventTemplateVars')}
</span> </span>
</div> </div>
<Textarea <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='flex items-center justify-between p-3 border rounded-lg bg-muted/10'>
<div className='space-y-0.5'> <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'> <div className='text-[10px] text-muted-foreground'>
创建后是否立即开始接收此事件的通知 {t('enableStatusDesc')}
</div> </div>
</div> </div>
<Switch <Switch
@@ -733,7 +743,7 @@ export function EventsTab() {
onClick={() => setCreateEventOpen(false)} onClick={() => setCreateEventOpen(false)}
className='h-9 text-xs' className='h-9 text-xs'
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
variant='default' variant='default'
@@ -749,7 +759,7 @@ export function EventsTab() {
{createEventMutation.isPending && ( {createEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' /> <Loader2 className='size-3 animate-spin mr-1' />
)} )}
启用保存 {t('enableSave')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -759,9 +769,9 @@ export function EventsTab() {
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}> <Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'> <DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader> <DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle> <DialogTitle>{t('editEventDialogTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置 {t('editEventDialogDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -770,7 +780,7 @@ export function EventsTab() {
<div className='grid grid-cols-2 gap-4'> <div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'> <Label className='text-xs font-semibold text-muted-foreground'>
事件名称 {t('eventName')}
</Label> </Label>
<Input <Input
value={selectedEvent.name} value={selectedEvent.name}
@@ -780,7 +790,7 @@ export function EventsTab() {
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'> <Label className='text-xs font-semibold text-muted-foreground'>
事件键 (Key) {t('eventKey')}
</Label> </Label>
<Input <Input
value={selectedEvent.event_key} value={selectedEvent.event_key}
@@ -793,7 +803,7 @@ export function EventsTab() {
{selectedEvent.task_type && ( {selectedEvent.task_type && (
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'> <Label className='text-xs font-semibold text-muted-foreground'>
关联异步任务 {t('relatedAsyncTask')}
</Label> </Label>
<Input <Input
value={`${(taskTypesQuery.data ?? []).find((t) => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`} 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'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'> <Label className='text-xs font-semibold'>
推送渠道 (可多选) {t('pushChannelsMulti')}
</Label> </Label>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@@ -816,11 +826,11 @@ export function EventsTab() {
{eventChannels.length > 0 {eventChannels.length > 0
? eventChannels ? eventChannels
.map((ch) => { .map((ch) => {
if (ch === 'email') return '邮件'; if (ch === 'email') return t('emailChannel');
return ch; return ch;
}) })
.join(', ') .join(', ')
: '选择已配置推送渠道'} : t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' /> <ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -846,12 +856,12 @@ export function EventsTab() {
} }
}} }}
/> />
<span>{ch === 'email' ? '邮件推送' : ch}</span> <span>{ch === 'email' ? t('emailPush') : ch}</span>
</label> </label>
))} ))}
{availableChannels.length === 0 && ( {availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'> <div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建并启用。 {t('noAvailableChannelsEdit')}
</div> </div>
)} )}
</div> </div>
@@ -861,11 +871,11 @@ export function EventsTab() {
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'> <Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要) {t('pushTargets')}
</Label> </Label>
<Input <Input
type='text' type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com' placeholder={t('pushTargetsPlaceholder')}
value={eventTargets} value={eventTargets}
onChange={(e) => setEventTargets(e.target.value)} onChange={(e) => setEventTargets(e.target.value)}
className='text-xs h-9' className='text-xs h-9'
@@ -875,12 +885,12 @@ export function EventsTab() {
<div className='space-y-1.5'> <div className='space-y-1.5'>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'> <Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式) {t('contentTemplate')}
</Label> </Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'> <span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{selectedEvent.task_type {selectedEvent.task_type
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}' ? t('taskTemplateVars')
: '支持变量:{{user.username}}, {{ip}}, {{time}}'} : t('eventTemplateVars')}
</span> </span>
</div> </div>
<Textarea <Textarea
@@ -901,7 +911,7 @@ export function EventsTab() {
onClick={() => setEditEventOpen(false)} onClick={() => setEditEventOpen(false)}
className='h-9 text-xs' className='h-9 text-xs'
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
variant='default' variant='default'
@@ -913,7 +923,7 @@ export function EventsTab() {
{updateEventMutation.isPending && ( {updateEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' /> <Loader2 className='size-3 animate-spin mr-1' />
)} )}
保存修改 {t('saveChanges')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -925,14 +935,14 @@ export function EventsTab() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除通知事件</AlertDialogTitle> <AlertDialogTitle>{t('deleteEventTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确定要删除该通知事件配置吗?删除后无法恢复。 {t('deleteEventConfirm')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteEventMutation.isPending}> <AlertDialogCancel disabled={deleteEventMutation.isPending}>
取消 {t('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
disabled={deleteEventMutation.isPending} disabled={deleteEventMutation.isPending}
@@ -940,7 +950,9 @@ export function EventsTab() {
deleteTarget && deleteEventMutation.mutate(deleteTarget.id) deleteTarget && deleteEventMutation.mutate(deleteTarget.id)
} }
> >
{deleteEventMutation.isPending ? '删除中...' : '确认删除'} {deleteEventMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -4,6 +4,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
ChevronLeft, ChevronLeft,
@@ -62,6 +63,7 @@ function getLevelBadgeVariant(
} }
export function HistoriesTab() { export function HistoriesTab() {
const t = useTranslations('admin.push.histories');
const [historyPage, setHistoryPage] = React.useState(1); const [historyPage, setHistoryPage] = React.useState(1);
const [historySearch, setHistorySearch] = React.useState(''); const [historySearch, setHistorySearch] = React.useState('');
const [historyStatus, setHistoryStatus] = React.useState('all'); 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' /> <Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
<Input <Input
type='text' type='text'
placeholder='输入事件键过滤...' placeholder={t('searchPlaceholder')}
className='pl-8 text-xs h-9' className='pl-8 text-xs h-9'
value={historySearch} value={historySearch}
onChange={(e) => { onChange={(e) => {
@@ -117,17 +119,17 @@ export function HistoriesTab() {
}} }}
> >
<SelectTrigger className='text-xs h-9'> <SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='发送状态' /> <SelectValue placeholder={t('sendStatus')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='all' className='text-xs'> <SelectItem value='all' className='text-xs'>
全部状态 {t('allStatus')}
</SelectItem> </SelectItem>
<SelectItem value='success' className='text-xs'> <SelectItem value='success' className='text-xs'>
发送成功 {t('sendSuccess')}
</SelectItem> </SelectItem>
<SelectItem value='failed' className='text-xs'> <SelectItem value='failed' className='text-xs'>
发送失败 {t('sendFailed')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -151,13 +153,27 @@ export function HistoriesTab() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className='bg-muted/30'> <TableRow className='bg-muted/30'>
<TableHead className='text-xs font-semibold'>事件</TableHead> <TableHead className='text-xs font-semibold'>
<TableHead className='text-xs font-semibold'>渠道</TableHead> {t('colEvent')}
<TableHead className='text-xs font-semibold'>目标</TableHead> </TableHead>
<TableHead className='text-xs font-semibold'>标题</TableHead> <TableHead className='text-xs font-semibold'>
<TableHead className='text-xs font-semibold'>等级</TableHead> {t('colChannel')}
<TableHead className='text-xs font-semibold'>状态</TableHead> </TableHead>
<TableHead className='text-xs font-semibold'>时间</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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -166,7 +182,7 @@ export function HistoriesTab() {
<TableCell colSpan={7} className='h-32'> <TableCell colSpan={7} className='h-32'>
<LoadingStateWithBorder <LoadingStateWithBorder
icon={History} icon={History}
description='加载推送历史记录中...' description={t('loadingHistories')}
className='border-0 shadow-none' className='border-0 shadow-none'
/> />
</TableCell> </TableCell>
@@ -187,7 +203,7 @@ export function HistoriesTab() {
colSpan={7} colSpan={7}
className='h-32 text-center text-xs text-muted-foreground' className='h-32 text-center text-xs text-muted-foreground'
> >
无推送历史数据 {t('noHistoryData')}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -201,7 +217,9 @@ export function HistoriesTab() {
{hist.event_key} {hist.event_key}
</TableCell> </TableCell>
<TableCell className='text-xs uppercase font-semibold text-muted-foreground'> <TableCell className='text-xs uppercase font-semibold text-muted-foreground'>
{hist.channel === 'email' ? '邮件' : hist.channel} {hist.channel === 'email'
? t('emailChannel')
: hist.channel}
</TableCell> </TableCell>
<TableCell <TableCell
className='text-xs font-mono text-muted-foreground max-w-[120px] truncate' className='text-xs font-mono text-muted-foreground max-w-[120px] truncate'
@@ -230,7 +248,7 @@ export function HistoriesTab() {
} }
className='text-[10px] font-semibold' className='text-[10px] font-semibold'
> >
{hist.status === 'success' ? '成功' : '失败'} {hist.status === 'success' ? t('success') : t('failed')}
</Badge> </Badge>
{hist.status !== 'success' && hist.error_msg && ( {hist.status !== 'success' && hist.error_msg && (
<div <div
@@ -253,7 +271,9 @@ export function HistoriesTab() {
{historiesQuery.data && historiesQuery.data.total > 0 && ( {historiesQuery.data && historiesQuery.data.total > 0 && (
<div className='flex justify-between items-center text-xs text-muted-foreground'> <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'> <div className='flex items-center gap-1.5'>
<Button <Button
variant='outline' variant='outline'
@@ -284,10 +304,10 @@ export function HistoriesTab() {
<DialogHeader> <DialogHeader>
<DialogTitle className='flex items-center gap-2'> <DialogTitle className='flex items-center gap-2'>
<History className='size-5 text-primary' /> <History className='size-5 text-primary' />
推送通知详情 {t('pushNotificationDetail')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
查看该条通知发送的详细审计信息与内容 {t('pushNotificationDetailDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -296,7 +316,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'> <div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
事件标识 {t('eventIdentifier')}
</span> </span>
<div className='font-mono bg-muted/40 p-2 rounded border'> <div className='font-mono bg-muted/40 p-2 rounded border'>
{selectedHistory.event_key} {selectedHistory.event_key}
@@ -304,11 +324,11 @@ export function HistoriesTab() {
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
发送渠道 {t('sendChannel')}
</span> </span>
<div className='bg-muted/40 p-2 rounded border uppercase font-medium'> <div className='bg-muted/40 p-2 rounded border uppercase font-medium'>
{selectedHistory.channel === 'email' {selectedHistory.channel === 'email'
? '邮件推送' ? t('emailPush')
: selectedHistory.channel} : selectedHistory.channel}
</div> </div>
</div> </div>
@@ -317,7 +337,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'> <div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
推送目标 {t('pushTarget')}
</span> </span>
<div <div
className='font-mono bg-muted/40 p-2 rounded border truncate' className='font-mono bg-muted/40 p-2 rounded border truncate'
@@ -328,7 +348,7 @@ export function HistoriesTab() {
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
发送时间 {t('sendTime')}
</span> </span>
<div className='bg-muted/40 p-2 rounded border'> <div className='bg-muted/40 p-2 rounded border'>
{new Date(selectedHistory.created_at).toLocaleString()} {new Date(selectedHistory.created_at).toLocaleString()}
@@ -339,7 +359,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'> <div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
通知等级 {t('notificationLevel')}
</span> </span>
<div> <div>
<Badge <Badge
@@ -352,7 +372,7 @@ export function HistoriesTab() {
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
发送状态 {t('sendStatus')}
</span> </span>
<div> <div>
<Badge <Badge
@@ -363,7 +383,9 @@ export function HistoriesTab() {
} }
className='text-[10px] font-semibold py-0.5 px-2' className='text-[10px] font-semibold py-0.5 px-2'
> >
{selectedHistory.status === 'success' ? '成功' : '失败'} {selectedHistory.status === 'success'
? t('success')
: t('failed')}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -373,7 +395,7 @@ export function HistoriesTab() {
selectedHistory.error_msg && ( selectedHistory.error_msg && (
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-destructive block'> <span className='font-semibold text-destructive block'>
失败原因 {t('failureReason')}
</span> </span>
<div className='font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all'> <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} {selectedHistory.error_msg}
@@ -383,7 +405,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
通知标题 {t('notificationTitle')}
</span> </span>
<div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'> <div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'>
{selectedHistory.title} {selectedHistory.title}
@@ -392,7 +414,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'> <div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'> <span className='font-semibold text-muted-foreground block'>
通知内容 {t('notificationContent')}
</span> </span>
<div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'> <div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'>
{selectedHistory.content} {selectedHistory.content}
@@ -408,7 +430,7 @@ export function HistoriesTab() {
onClick={() => setDetailOpen(false)} onClick={() => setDetailOpen(false)}
className='h-9 text-xs' className='h-9 text-xs'
> >
关闭 {t('close')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -4,6 +4,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -61,6 +62,7 @@ import type {
import { PushService } from '@/lib/services/push'; import { PushService } from '@/lib/services/push';
export function SettingsTab() { export function SettingsTab() {
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>( const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
null, null,
@@ -82,12 +84,12 @@ export function SettingsTab() {
const createChannelMutation = useMutation({ const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data), mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
onSuccess: () => { onSuccess: () => {
toast.success('通道创建成功'); toast.success(t('channelCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false); setChannelDialogOpen(false);
}, },
onError: (err: unknown) => { 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 }) => mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
PushService.updateChannel(id, data), PushService.updateChannel(id, data),
onSuccess: () => { onSuccess: () => {
toast.success('通道更新成功'); toast.success(t('channelUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false); setChannelDialogOpen(false);
}, },
onError: (err: unknown) => { 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), mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => { onSuccess: () => {
setDeleteTarget(null); setDeleteTarget(null);
toast.success('通道删除成功'); toast.success(t('channelDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
}, },
onError: (err: unknown) => { 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 = () => { const handleSaveChannel = () => {
if (!channelName && !editingChannel) { if (!channelName && !editingChannel) {
toast.error('通道名称不能为空'); toast.error(t('channelNameRequired'));
return; return;
} }
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) { if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
toast.error('通道名称只能使用英文字母、数字和下划线'); toast.error(t('channelNameFormat'));
return; return;
} }
if (!activeDef) { if (!activeDef) {
toast.error('无效的通道类型'); toast.error(t('invalidChannelType'));
return; return;
} }
@@ -217,7 +219,7 @@ export function SettingsTab() {
? channelToken ? channelToken
: channelOther; : channelOther;
if (field.required && !value.trim()) { if (field.required && !value.trim()) {
toast.error(`${field.label}不能为空`); toast.error(t('fieldRequired', { label: field.label }));
return; return;
} }
} }
@@ -225,7 +227,7 @@ export function SettingsTab() {
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议) // 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
if (channelType !== 'email') { if (channelType !== 'email') {
if (channelUrl && !channelUrl.startsWith('https://')) { if (channelUrl && !channelUrl.startsWith('https://')) {
toast.error('地址必须以 https:// 开头以确保安全性'); toast.error(t('httpsRequired'));
return; return;
} }
} }
@@ -235,14 +237,14 @@ export function SettingsTab() {
try { try {
JSON.parse(channelOther); JSON.parse(channelOther);
} catch { } catch {
toast.error('请求体必须是合法的 JSON 格式'); toast.error(t('jsonFormatRequired'));
return; return;
} }
} else if (channelType === 'lark' && channelOther) { } else if (channelType === 'lark' && channelOther) {
try { try {
JSON.parse(channelOther); JSON.parse(channelOther);
} catch { } catch {
toast.error('自定义卡片模版必须是合法的 JSON 格式'); toast.error(t('larkTemplateFormat'));
return; return;
} }
} }
@@ -281,15 +283,15 @@ export function SettingsTab() {
const handleSendChannelTest = async () => { const handleSendChannelTest = async () => {
try { try {
setIsTestingChannel(true); setIsTestingChannel(true);
toast.info('正在发送测试推送...'); toast.info(t('sendingTestPush'));
await PushService.testChannel({ await PushService.testChannel({
name: testChannelName, name: testChannelName,
target: testChannelTarget || undefined, target: testChannelTarget || undefined,
}); });
toast.success('测试推送发送成功,请前往对应平台确认。'); toast.success(t('testPushSuccess'));
setTestChannelOpen(false); setTestChannelOpen(false);
} catch (err: unknown) { } catch (err: unknown) {
toast.error('连通性测试失败: ' + (err as Error).message); toast.error(t('connectivityTestFailed') + ': ' + (err as Error).message);
} finally { } finally {
setIsTestingChannel(false); setIsTestingChannel(false);
} }
@@ -299,9 +301,9 @@ export function SettingsTab() {
<div className='pt-4 space-y-4'> <div className='pt-4 space-y-4'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div> <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'> <p className='text-[11px] text-muted-foreground mt-0.5'>
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道 {t('customPushChannelsDesc')}
</p> </p>
</div> </div>
<Button <Button
@@ -310,14 +312,14 @@ export function SettingsTab() {
className='text-xs' className='text-xs'
> >
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
新建消息通道 {t('createChannel')}
</Button> </Button>
</div> </div>
{channelsQuery.isLoading ? ( {channelsQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Settings} icon={Settings}
description='加载消息通道中...' description={t('loadingChannels')}
/> />
) : channelsQuery.isError ? ( ) : channelsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'> <div className='p-8 border border-dashed rounded-xl bg-card'>
@@ -334,7 +336,7 @@ export function SettingsTab() {
style={{ animationDuration: '3s' }} style={{ animationDuration: '3s' }}
/> />
<span className='text-xs font-medium'> <span className='text-xs font-medium'>
暂无自定义通道配置,请点击右上角新建 {t('noCustomChannels')}
</span> </span>
</div> </div>
) : ( ) : (
@@ -343,19 +345,19 @@ export function SettingsTab() {
<TableHeader className='sticky top-0 z-20 bg-background'> <TableHeader className='sticky top-0 z-20 bg-background'>
<TableRow className='border-b border-dashed hover:bg-transparent'> <TableRow className='border-b border-dashed hover:bg-transparent'>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'> <TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
名称 {t('colName')}
</TableHead> </TableHead>
<TableHead className='w-[100px] whitespace-nowrap py-2 h-8'> <TableHead className='w-[100px] whitespace-nowrap py-2 h-8'>
类型 {t('colType')}
</TableHead> </TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'> <TableHead className='whitespace-nowrap py-2 h-8'>
备注 {t('colRemark')}
</TableHead> </TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'> <TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态 {t('colStatus')}
</TableHead> </TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8'> <TableHead className='sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8'>
操作 {t('colActions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -380,7 +382,9 @@ export function SettingsTab() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className='text-xs text-muted-foreground max-w-[200px] truncate py-1'> <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>
<TableCell <TableCell
className='text-center py-1' 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' className='h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10'
> >
<Play className='size-2.5 mr-1' /> <Play className='size-2.5 mr-1' />
测试 {t('test')}
</Button> </Button>
<Button <Button
variant='ghost' variant='ghost'
@@ -425,7 +429,7 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground' className='h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground'
> >
<Edit2 className='size-2.5 mr-1' /> <Edit2 className='size-2.5 mr-1' />
编辑 {t('edit')}
</Button> </Button>
<Button <Button
variant='ghost' 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' className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
> >
<Trash2 className='size-2.5 mr-1' /> <Trash2 className='size-2.5 mr-1' />
删除 {t('delete')}
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -451,35 +455,34 @@ export function SettingsTab() {
<DialogContent className='sm:max-w-[600px] max-h-[85vh] overflow-y-auto'> <DialogContent className='sm:max-w-[600px] max-h-[85vh] overflow-y-auto'>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editingChannel ? '编辑消息通道' : '新建消息通道'} {editingChannel
? t('editChannelDialogTitle')
: t('createChannelDialogTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{t('channelDialogDesc')}</DialogDescription>
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook
或推送服务投递数据
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className='space-y-4 py-4'> <div className='space-y-4 py-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'>名称</Label> <Label className='text-xs font-semibold'>{t('channelName')}</Label>
<Input <Input
type='text' type='text'
placeholder='请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一' placeholder={t('channelNamePlaceholder')}
value={channelName} value={channelName}
onChange={(e) => setChannelName(e.target.value)} onChange={(e) => setChannelName(e.target.value)}
disabled={!!editingChannel} disabled={!!editingChannel}
className='text-xs h-9 font-mono' className='text-xs h-9 font-mono'
/> />
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
通道唯一标识,创建后不可修改 {t('channelNameHint')}
</p> </p>
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'>备注</Label> <Label className='text-xs font-semibold'>{t('channelRemark')}</Label>
<Input <Input
type='text' type='text'
placeholder='请输入备注信息' placeholder={t('channelRemarkPlaceholder')}
value={channelDescription} value={channelDescription}
onChange={(e) => setChannelDescription(e.target.value)} onChange={(e) => setChannelDescription(e.target.value)}
className='text-xs h-9' className='text-xs h-9'
@@ -487,13 +490,13 @@ export function SettingsTab() {
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs font-semibold'>通道类型</Label> <Label className='text-xs font-semibold'>{t('channelType')}</Label>
<Select <Select
value={channelType} value={channelType}
onValueChange={handleChannelTypeChange} onValueChange={handleChannelTypeChange}
> >
<SelectTrigger className='text-xs h-9'> <SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='选择通道类型' /> <SelectValue placeholder={t('selectChannelType')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(definitionsQuery.data ?? []).map((d) => ( {(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='p-3.5 border rounded-lg bg-muted/20 space-y-1.5'>
<div className='text-xs font-semibold'> <div className='text-xs font-semibold'>
{activeDef.name}配置说明 {t('configHelp', { name: activeDef.name })}
</div> </div>
<p className='text-[11px] text-muted-foreground leading-relaxed'> <p className='text-[11px] text-muted-foreground leading-relaxed'>
{activeDef.description} {activeDef.description}
@@ -572,7 +575,7 @@ export function SettingsTab() {
{channelType === 'custom' && ( {channelType === 'custom' && (
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-2.5'> <div className='p-3.5 border rounded-lg bg-muted/20 space-y-2.5'>
<Label className='text-[11px] font-semibold'> <Label className='text-[11px] font-semibold'>
快捷加载常用模版实例: {t('quickLoadTemplates')}
</Label> </Label>
<div className='flex flex-wrap gap-1.5'> <div className='flex flex-wrap gap-1.5'>
<Button <Button
@@ -596,7 +599,7 @@ export function SettingsTab() {
2, 2,
), ),
); );
toast.success('已加载飞书 Webhook 模版'); toast.success(t('loadedFeishuTemplate'));
}} }}
> >
飞书 Webhook 飞书 Webhook
@@ -623,7 +626,7 @@ export function SettingsTab() {
2, 2,
), ),
); );
toast.success('已加载钉钉机器人模版'); toast.success(t('loadedDingTalkTemplate'));
}} }}
> >
钉钉群机器人 钉钉群机器人
@@ -650,7 +653,7 @@ export function SettingsTab() {
2, 2,
), ),
); );
toast.success('已加载企业微信机器人模版'); toast.success(t('loadedWeChatTemplate'));
}} }}
> >
企业微信群机器人 企业微信群机器人
@@ -674,7 +677,7 @@ export function SettingsTab() {
2, 2,
), ),
); );
toast.success('已加载 Bark App 模版'); toast.success(t('loadedBarkTemplate'));
}} }}
> >
Bark App Bark App
@@ -693,7 +696,7 @@ export function SettingsTab() {
onClick={() => setChannelDialogOpen(false)} onClick={() => setChannelDialogOpen(false)}
className='h-9 text-xs' className='h-9 text-xs'
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
variant='default' variant='default'
@@ -709,7 +712,7 @@ export function SettingsTab() {
updateChannelMutation.isPending) && ( updateChannelMutation.isPending) && (
<Loader2 className='size-3 animate-spin mr-1' /> <Loader2 className='size-3 animate-spin mr-1' />
)} )}
确定 {t('confirm')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -719,15 +722,15 @@ export function SettingsTab() {
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}> <Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
<DialogContent className='sm:max-w-[450px]'> <DialogContent className='sm:max-w-[450px]'>
<DialogHeader> <DialogHeader>
<DialogTitle>发送测试通知</DialogTitle> <DialogTitle>{t('sendTestNotification')}</DialogTitle>
<DialogDescription> <DialogDescription>
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试 {t('sendTestNotificationDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='space-y-4 py-3'> <div className='space-y-4 py-3'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs'>推送通道名称</Label> <Label className='text-xs'>{t('pushChannelName')}</Label>
<Input <Input
type='text' type='text'
value={testChannelName} value={testChannelName}
@@ -736,10 +739,10 @@ export function SettingsTab() {
/> />
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label className='text-xs'>测试推送目标 (对应模板变量 $to)</Label> <Label className='text-xs'>{t('testPushTarget')}</Label>
<Input <Input
type='text' type='text'
placeholder='请输入测试推送接收人/目标标识,如 Bark Token、邮箱等' placeholder={t('testPushTargetPlaceholder')}
value={testChannelTarget} value={testChannelTarget}
onChange={(e) => setTestChannelTarget(e.target.value)} onChange={(e) => setTestChannelTarget(e.target.value)}
className='text-xs h-9' className='text-xs h-9'
@@ -754,7 +757,7 @@ export function SettingsTab() {
onClick={() => setTestChannelOpen(false)} onClick={() => setTestChannelOpen(false)}
className='h-9 text-xs' className='h-9 text-xs'
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
variant='default' variant='default'
@@ -766,7 +769,7 @@ export function SettingsTab() {
{isTestingChannel && ( {isTestingChannel && (
<Loader2 className='size-3 animate-spin mr-1' /> <Loader2 className='size-3 animate-spin mr-1' />
)} )}
发送测试 {t('sendTest')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -778,14 +781,14 @@ export function SettingsTab() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除通道</AlertDialogTitle> <AlertDialogTitle>{t('deleteChannelTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确定要删除通道「{deleteTarget?.name}」吗?删除后无法恢复。 {t('deleteChannelConfirm', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteChannelMutation.isPending}> <AlertDialogCancel disabled={deleteChannelMutation.isPending}>
取消 {t('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
disabled={deleteChannelMutation.isPending} disabled={deleteChannelMutation.isPending}
@@ -793,7 +796,9 @@ export function SettingsTab() {
deleteTarget && deleteChannelMutation.mutate(deleteTarget.id) deleteTarget && deleteChannelMutation.mutate(deleteTarget.id)
} }
> >
{deleteChannelMutation.isPending ? '删除中...' : '确认删除'} {deleteChannelMutation.isPending
? t('deleting')
: t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+6 -4
View File
@@ -6,6 +6,7 @@
import * as React from 'react'; import * as React from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Bell, History, Layers, Settings } from 'lucide-react'; import { Bell, History, Layers, Settings } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EventsTab } from './components/events-tab'; import { EventsTab } from './components/events-tab';
@@ -13,6 +14,7 @@ import { HistoriesTab } from './components/histories-tab';
import { SettingsTab } from './components/settings-tab'; import { SettingsTab } from './components/settings-tab';
export default function PushAdminPage() { export default function PushAdminPage() {
const t = useTranslations('admin.push');
const [activeTab, setActiveTab] = React.useState('events'); const [activeTab, setActiveTab] = React.useState('events');
return ( return (
@@ -26,7 +28,7 @@ export default function PushAdminPage() {
<Bell className='size-5 text-primary' /> <Bell className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'> <h1 className='text-2xl font-semibold tracking-tight'>
通知推送管理 {t('pageTitle')}
</h1> </h1>
</div> </div>
</div> </div>
@@ -38,21 +40,21 @@ export default function PushAdminPage() {
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
<Layers className='size-3.5 mr-1' /> <Layers className='size-3.5 mr-1' />
事件管理 {t('eventsTab')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='histories' value='histories'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
<History className='size-3.5 mr-1' /> <History className='size-3.5 mr-1' />
通知历史 {t('historiesTab')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='settings' value='settings'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
<Settings className='size-3.5 mr-1' /> <Settings className='size-3.5 mr-1' />
通道管理与设置 {t('settingsTab')}
</TabsTrigger> </TabsTrigger>
</TabsList> </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); const milliseconds = Number.parseInt(value, 10);
if (Number.isNaN(milliseconds)) return value; if (Number.isNaN(milliseconds)) return value;
if (milliseconds >= 60000) return `${milliseconds / 60000} 分钟`; if (milliseconds >= 60000) {
return `${milliseconds / 1000} 秒`; return t('duration.minutes', { count: milliseconds / 60000 });
}
return t('duration.seconds', { count: milliseconds / 1000 });
} }
export function normalizeServerUrl(value: string) { export function normalizeServerUrl(value: string) {
@@ -116,21 +123,24 @@ export function buildDiscoveryCommand(
].join('\n'); ].join('\n');
} }
export function validateAgentFields(fields: OpenFlareOpsFields) { export function validateAgentFields(fields: OpenFlareOpsFields, t: OpsMessage) {
const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10); const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10);
const offline = Number.parseInt(fields.node_offline_threshold, 10); const offline = Number.parseInt(fields.node_offline_threshold, 10);
if (Number.isNaN(heartbeat) || heartbeat < 1000) { if (Number.isNaN(heartbeat) || heartbeat < 1000) {
throw new Error('心跳间隔不能小于 1000 毫秒。'); throw new Error(t('errors.heartbeatMin'));
} }
if (Number.isNaN(offline) || offline < 10000) { if (Number.isNaN(offline) || offline < 10000) {
throw new Error('离线阈值不能小于 10000 毫秒。'); throw new Error(t('errors.offlineMin'));
} }
if (offline < heartbeat * 3) { 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 syncInt = Number.parseInt(fields.uptime_kuma_sync_interval, 10);
const interval = Number.parseInt(fields.uptime_kuma_interval, 10); const interval = Number.parseInt(fields.uptime_kuma_interval, 10);
const retry = Number.parseInt(fields.uptime_kuma_retry, 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); const timeout = Number.parseInt(fields.uptime_kuma_timeout, 10);
if (fields.uptime_kuma_enabled) { if (fields.uptime_kuma_enabled) {
if (!fields.uptime_kuma_url.trim()) if (!fields.uptime_kuma_url.trim()) throw new Error(t('errors.kumaUrl'));
throw new Error('请输入 Uptime Kuma 地址。');
if (!fields.uptime_kuma_username.trim()) if (!fields.uptime_kuma_username.trim())
throw new Error('请输入 Uptime Kuma 用户名。'); throw new Error(t('errors.kumaUser'));
} }
if (Number.isNaN(syncInt) || syncInt <= 0) if (Number.isNaN(syncInt) || syncInt <= 0)
throw new Error('同步间隔必须为正整数。'); throw new Error(t('errors.syncInterval'));
if (Number.isNaN(interval) || interval <= 0) if (Number.isNaN(interval) || interval <= 0)
throw new Error('心跳间隔必须为正整数。'); throw new Error(t('errors.interval'));
if (Number.isNaN(retry) || retry < 0) if (Number.isNaN(retry) || retry < 0) throw new Error(t('errors.retry'));
throw new Error('重试次数必须为非负整数。');
if (Number.isNaN(retryInt) || retryInt <= 0) if (Number.isNaN(retryInt) || retryInt <= 0)
throw new Error('心跳重试间隔必须为正整数。'); throw new Error(t('errors.retryInterval'));
if (Number.isNaN(timeout) || timeout <= 0) if (Number.isNaN(timeout) || timeout <= 0)
throw new Error('请求超时必须为正整数。'); throw new Error(t('errors.timeout'));
} }
export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] { export function agentOptionEntries(
validateAgentFields(fields); fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validateAgentFields(fields, t);
return [ return [
{ key: 'agent_heartbeat_interval', value: fields.agent_heartbeat_interval }, { key: 'agent_heartbeat_interval', value: fields.agent_heartbeat_interval },
{ {
@@ -171,8 +182,9 @@ export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
export function uptimeKumaOptionEntries( export function uptimeKumaOptionEntries(
fields: OpenFlareOpsFields, fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] { ): OptionItem[] {
validateUptimeKumaFields(fields); validateUptimeKumaFields(fields, t);
return [ return [
{ key: 'uptime_kuma_enabled', value: String(fields.uptime_kuma_enabled) }, { key: 'uptime_kuma_enabled', value: String(fields.uptime_kuma_enabled) },
{ key: 'uptime_kuma_url', value: fields.uptime_kuma_url.trim() }, { 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 packageSize = Number.parseInt(fields.pages_max_package_size_mb, 10);
const historyCount = Number.parseInt(fields.pages_max_history_count, 10); const historyCount = Number.parseInt(fields.pages_max_history_count, 10);
if (Number.isNaN(packageSize) || packageSize < 1 || packageSize > 2048) { 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) { if (Number.isNaN(historyCount) || historyCount < 0) {
throw new Error( throw new Error(t('errors.pagesHistory'));
'Pages 历史保留数必须为大于等于 0 的整数(0 表示不限制)。',
);
} }
} }
export function pagesOptionEntries(fields: OpenFlareOpsFields): OptionItem[] { export function pagesOptionEntries(
validatePagesFields(fields); fields: OpenFlareOpsFields,
t: OpsMessage,
): OptionItem[] {
validatePagesFields(fields, t);
return [ return [
{ {
key: 'pages_max_package_size_mb', key: 'pages_max_package_size_mb',
@@ -1,6 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
@@ -63,6 +64,7 @@ async function copyText(value: string) {
} }
export function OpenFlareOpsSettings() { export function OpenFlareOpsSettings() {
const t = useTranslations('openflareOps');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [fields, setFields] = useState<OpenFlareOpsFields>( const [fields, setFields] = useState<OpenFlareOpsFields>(
defaultOpenFlareOpsFields, defaultOpenFlareOpsFields,
@@ -113,20 +115,20 @@ export function OpenFlareOpsSettings() {
await OptionService.updateBatch(entries); await OptionService.updateBatch(entries);
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success('OpenFlare 运维设置已保存'); toast.success(t('saved'));
await queryClient.invalidateQueries({ queryKey: optionsQueryKey }); await queryClient.invalidateQueries({ queryKey: optionsQueryKey });
setSavingSection(null); setSavingSection(null);
}, },
onError: (error) => { onError: (error) => {
setSavingSection(null); setSavingSection(null);
toast.error(error instanceof Error ? error.message : '保存失败'); toast.error(error instanceof Error ? error.message : t('saveFailed'));
}, },
}); });
const rotateTokenMutation = useMutation({ const rotateTokenMutation = useMutation({
mutationFn: () => NodeService.rotateBootstrapToken(), mutationFn: () => NodeService.rotateBootstrapToken(),
onSuccess: async (data) => { onSuccess: async (data) => {
toast.success('Discovery Token 已重新生成'); toast.success(t('tokenRotated'));
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['openflare', 'bootstrap-token'], queryKey: ['openflare', 'bootstrap-token'],
}); });
@@ -139,15 +141,17 @@ export function OpenFlareOpsSettings() {
} }
}, },
onError: (error) => { onError: (error) => {
toast.error(error instanceof Error ? error.message : 'Token 轮换失败'); toast.error(
error instanceof Error ? error.message : t('tokenRotateFailed'),
);
}, },
}); });
const syncUptimeKumaMutation = useMutation({ const syncUptimeKumaMutation = useMutation({
mutationFn: () => UptimeKumaService.sync(), mutationFn: () => UptimeKumaService.sync(),
onSuccess: () => toast.success('Uptime Kuma 同步任务已执行'), onSuccess: () => toast.success(t('kumaSynced')),
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : '同步失败'), toast.error(error instanceof Error ? error.message : t('syncFailed')),
}); });
const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
@@ -167,10 +171,10 @@ export function OpenFlareOpsSettings() {
try { try {
saveMutation.mutate({ saveMutation.mutate({
section: 'agent', section: 'agent',
entries: agentOptionEntries(fields), entries: agentOptionEntries(fields, t),
}); });
} catch (error) { } 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 { try {
saveMutation.mutate({ saveMutation.mutate({
section: 'uptimekuma', section: 'uptimekuma',
entries: uptimeKumaOptionEntries(fields), entries: uptimeKumaOptionEntries(fields, t),
}); });
} catch (error) { } 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 { try {
saveMutation.mutate({ saveMutation.mutate({
section: 'pages', section: 'pages',
entries: pagesOptionEntries(fields), entries: pagesOptionEntries(fields, t),
}); });
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : '参数校验失败'); toast.error(error instanceof Error ? error.message : t('invalidParams'));
} }
}; };
if (optionsQuery.isLoading) { if (optionsQuery.isLoading) {
return ( return (
<LoadingStateWithBorder <LoadingStateWithBorder icon={Server} description={t('loading')} />
icon={Server}
description='加载 OpenFlare 运维设置...'
/>
); );
} }
@@ -211,7 +212,7 @@ export function OpenFlareOpsSettings() {
message={ message={
optionsQuery.error instanceof Error optionsQuery.error instanceof Error
? optionsQuery.error.message ? optionsQuery.error.message
: '加载失败' : t('loadFailed')
} }
onRetry={() => void optionsQuery.refetch()} onRetry={() => void optionsQuery.refetch()}
/> />
@@ -224,10 +225,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'> <CardHeader className='flex flex-row items-center justify-between gap-4'>
<div> <div>
<CardTitle className='text-base'>Agent 运行参数</CardTitle> <CardTitle className='text-base'>{t('agent.title')}</CardTitle>
<CardDescription> <CardDescription>{t('agent.description')}</CardDescription>
心跳间隔与离线阈值会在下个心跳周期同步到节点。
</CardDescription>
</div> </div>
<Button <Button
size='sm' size='sm'
@@ -239,13 +238,18 @@ export function OpenFlareOpsSettings() {
) : ( ) : (
<Save className='size-3.5 mr-1' /> <Save className='size-3.5 mr-1' />
)} )}
保存 {t('save')}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<FieldInput <FieldInput
label={`心跳间隔 (${formatDurationLabel(fields.agent_heartbeat_interval)})`} label={t('agent.heartbeat', {
duration: formatDurationLabel(
fields.agent_heartbeat_interval,
t,
),
})}
value={fields.agent_heartbeat_interval} value={fields.agent_heartbeat_interval}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -253,7 +257,12 @@ export function OpenFlareOpsSettings() {
} }
/> />
<FieldInput <FieldInput
label={`离线阈值 (${formatDurationLabel(fields.node_offline_threshold)})`} label={t('agent.offline', {
duration: formatDurationLabel(
fields.node_offline_threshold,
t,
),
})}
value={fields.node_offline_threshold} value={fields.node_offline_threshold}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -262,15 +271,15 @@ export function OpenFlareOpsSettings() {
/> />
</div> </div>
<ToggleRow <ToggleRow
label='开启 WS 连接升级' label={t('agent.wsUpgrade')}
description='HTTP 心跳成功后尝试升级为 WebSocket,配置发布可即时通知。' description={t('agent.wsUpgradeDesc')}
checked={fields.agent_websocket_upgrade_enabled} checked={fields.agent_websocket_upgrade_enabled}
onChange={(value) => onChange={(value) =>
updateField('agent_websocket_upgrade_enabled', value) updateField('agent_websocket_upgrade_enabled', value)
} }
/> />
<FieldInput <FieldInput
label='Agent 更新仓库' label={t('agent.updateRepo')}
value={fields.agent_update_repo} value={fields.agent_update_repo}
placeholder='Rain-kl/OpenFlare' placeholder='Rain-kl/OpenFlare'
onChange={(value) => updateField('agent_update_repo', value)} onChange={(value) => updateField('agent_update_repo', value)}
@@ -281,11 +290,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'> <CardHeader className='flex flex-row items-center justify-between gap-4'>
<div> <div>
<CardTitle className='text-base'>IP 归属解析</CardTitle> <CardTitle className='text-base'>{t('geo.title')}</CardTitle>
<CardDescription> <CardDescription>{t('geo.description')}</CardDescription>
控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用
MaxMind mmdb。
</CardDescription>
</div> </div>
<Button <Button
size='sm' size='sm'
@@ -297,12 +303,12 @@ export function OpenFlareOpsSettings() {
) : ( ) : (
<Save className='size-3.5 mr-1' /> <Save className='size-3.5 mr-1' />
)} )}
保存 {t('save')}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label>归属方式</Label> <Label>{t('geo.mode')}</Label>
<Select <Select
value={fields.geoip_provider} value={fields.geoip_provider}
onValueChange={(value) => updateField('geoip_provider', value)} onValueChange={(value) => updateField('geoip_provider', value)}
@@ -311,7 +317,7 @@ export function OpenFlareOpsSettings() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='disabled'>关闭</SelectItem> <SelectItem value='disabled'>{t('geo.disabled')}</SelectItem>
<SelectItem value='mmdb'>MaxMind mmdb</SelectItem> <SelectItem value='mmdb'>MaxMind mmdb</SelectItem>
<SelectItem value='ip-api'>ip-api.com</SelectItem> <SelectItem value='ip-api'>ip-api.com</SelectItem>
<SelectItem value='geojs'>geojs.io</SelectItem> <SelectItem value='geojs'>geojs.io</SelectItem>
@@ -321,7 +327,7 @@ export function OpenFlareOpsSettings() {
</div> </div>
<div className='flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-end'> <div className='flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-end'>
<FieldInput <FieldInput
label='测试 IP' label={t('geo.testIp')}
value={geoIPTestIP} value={geoIPTestIP}
onChange={setGeoIPTestIP} onChange={setGeoIPTestIP}
placeholder='8.8.8.8' placeholder='8.8.8.8'
@@ -332,13 +338,13 @@ export function OpenFlareOpsSettings() {
disabled={geoIPMutation.isPending} disabled={geoIPMutation.isPending}
onClick={() => geoIPMutation.mutate()} onClick={() => geoIPMutation.mutate()}
> >
{geoIPMutation.isPending ? '查询中...' : '查询归属'} {geoIPMutation.isPending ? t('geo.querying') : t('geo.query')}
</Button> </Button>
</div> </div>
{geoIPMutation.data ? ( {geoIPMutation.data ? (
<div className='grid gap-2 text-sm sm:grid-cols-2'> <div className='grid gap-2 text-sm sm:grid-cols-2'>
<InfoCell <InfoCell
label='国家/地区' label={t('geo.country')}
value={geoIPMutation.data.name || '—'} value={geoIPMutation.data.name || '—'}
/> />
<InfoCell <InfoCell
@@ -354,10 +360,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'> <CardHeader className='flex flex-row items-center justify-between gap-4'>
<div> <div>
<CardTitle className='text-base'>Pages 静态托管</CardTitle> <CardTitle className='text-base'>{t('pages.title')}</CardTitle>
<CardDescription> <CardDescription>{t('pages.description')}</CardDescription>
配置部署包上传体积上限与每个项目的历史部署保留数量。
</CardDescription>
</div> </div>
<Button <Button
size='sm' size='sm'
@@ -369,13 +373,13 @@ export function OpenFlareOpsSettings() {
) : ( ) : (
<Save className='size-3.5 mr-1' /> <Save className='size-3.5 mr-1' />
)} )}
保存 {t('save')}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<FieldInput <FieldInput
label='部署包大小上限 (MiB)' label={t('pages.maxSize')}
value={fields.pages_max_package_size_mb} value={fields.pages_max_package_size_mb}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -384,7 +388,7 @@ export function OpenFlareOpsSettings() {
placeholder='100' placeholder='100'
/> />
<FieldInput <FieldInput
label='历史部署保留数(0 不限制)' label={t('pages.history')}
value={fields.pages_max_history_count} value={fields.pages_max_history_count}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -394,9 +398,7 @@ export function OpenFlareOpsSettings() {
/> />
</div> </div>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
每个项目最多保留 N {t('pages.historyHint')}
条部署:激活部署始终保留,其余按从新到旧填充;超出的非激活部署会在上传成功后自动清理。支持
zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式。
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -404,17 +406,16 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'> <CardHeader className='flex flex-row items-center justify-between gap-4'>
<div> <div>
<CardTitle className='text-base'>Discovery Token 与部署</CardTitle> <CardTitle className='text-base'>
<CardDescription> {t('discovery.title')}
新节点首次接入使用 Discovery </CardTitle>
Token;轮换请前往节点管理或使用下方操作。 <CardDescription>{t('discovery.description')}</CardDescription>
</CardDescription>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild> <Button variant='outline' size='sm' asChild>
<Link href='/nodes'> <Link href='/nodes'>
<ExternalLink className='size-3.5 mr-1' /> <ExternalLink className='size-3.5 mr-1' />
节点管理 {t('discovery.manageNodes')}
</Link> </Link>
</Button> </Button>
<Button <Button
@@ -424,7 +425,7 @@ export function OpenFlareOpsSettings() {
onClick={() => rotateTokenMutation.mutate()} onClick={() => rotateTokenMutation.mutate()}
> >
<RotateCw className='size-3.5 mr-1' /> <RotateCw className='size-3.5 mr-1' />
轮换 Token {t('discovery.rotate')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -437,21 +438,19 @@ export function OpenFlareOpsSettings() {
/> />
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
Discovery Token(只读) {t('discovery.tokenReadonly')}
</p> </p>
<p className='mt-2 break-all text-sm font-mono'> <p className='mt-2 break-all text-sm font-mono'>
{bootstrapQuery.isLoading {bootstrapQuery.isLoading
? '加载中...' ? t('discovery.loading')
: discoveryToken || '未生成'} : discoveryToken || t('discovery.notGenerated')}
</p> </p>
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label>一键部署命令</Label> <Label>{t('discovery.command')}</Label>
<Textarea <Textarea
readOnly readOnly
value={ value={discoveryCommand || t('discovery.commandHint')}
discoveryCommand || '请先填写可访问的 Server URL 并获取 Token。'
}
className='min-h-24 font-mono text-xs' className='min-h-24 font-mono text-xs'
/> />
{discoveryCommand ? ( {discoveryCommand ? (
@@ -461,12 +460,12 @@ export function OpenFlareOpsSettings() {
size='sm' size='sm'
onClick={() => onClick={() =>
void copyText(discoveryCommand).then(() => void copyText(discoveryCommand).then(() =>
toast.success('命令已复制'), toast.success(t('discovery.copied')),
) )
} }
> >
<Copy className='size-3.5 mr-1' /> <Copy className='size-3.5 mr-1' />
复制命令 {t('discovery.copy')}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -476,10 +475,8 @@ export function OpenFlareOpsSettings() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'> <CardHeader className='flex flex-row items-center justify-between gap-4'>
<div> <div>
<CardTitle className='text-base'>Uptime Kuma 集成</CardTitle> <CardTitle className='text-base'>{t('kuma.title')}</CardTitle>
<CardDescription> <CardDescription>{t('kuma.description')}</CardDescription>
将反代站点差分同步至 Uptime Kuma 监控实例。
</CardDescription>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Button <Button
@@ -491,20 +488,20 @@ export function OpenFlareOpsSettings() {
onClick={() => syncUptimeKumaMutation.mutate()} onClick={() => syncUptimeKumaMutation.mutate()}
> >
<RefreshCw className='size-3.5 mr-1' /> <RefreshCw className='size-3.5 mr-1' />
立即同步 {t('kuma.syncNow')}
</Button> </Button>
<Button <Button
size='sm' size='sm'
disabled={savingSection === 'uptimekuma'} disabled={savingSection === 'uptimekuma'}
onClick={saveUptimeKumaSettings} onClick={saveUptimeKumaSettings}
> >
保存 {t('save')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<ToggleRow <ToggleRow
label='开启 Uptime Kuma' label={t('kuma.enable')}
checked={fields.uptime_kuma_enabled} checked={fields.uptime_kuma_enabled}
onChange={(value) => updateField('uptime_kuma_enabled', value)} onChange={(value) => updateField('uptime_kuma_enabled', value)}
/> />
@@ -512,29 +509,29 @@ export function OpenFlareOpsSettings() {
<> <>
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<FieldInput <FieldInput
label='Uptime Kuma 地址' label={t('kuma.url')}
value={fields.uptime_kuma_url} value={fields.uptime_kuma_url}
onChange={(value) => updateField('uptime_kuma_url', value)} onChange={(value) => updateField('uptime_kuma_url', value)}
placeholder='http://localhost:3001' placeholder='http://localhost:3001'
/> />
<FieldInput <FieldInput
label='用户名' label={t('kuma.username')}
value={fields.uptime_kuma_username} value={fields.uptime_kuma_username}
onChange={(value) => onChange={(value) =>
updateField('uptime_kuma_username', value) updateField('uptime_kuma_username', value)
} }
/> />
<FieldInput <FieldInput
label='密码' label={t('kuma.password')}
value={fields.uptime_kuma_password} value={fields.uptime_kuma_password}
type='password' type='password'
onChange={(value) => onChange={(value) =>
updateField('uptime_kuma_password', value) updateField('uptime_kuma_password', value)
} }
placeholder='留空表示不更新' placeholder={t('kuma.passwordKeep')}
/> />
<FieldInput <FieldInput
label='同步间隔 (分钟)' label={t('kuma.syncInterval')}
value={fields.uptime_kuma_sync_interval} value={fields.uptime_kuma_sync_interval}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -542,7 +539,7 @@ export function OpenFlareOpsSettings() {
} }
/> />
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label>监控范围</Label> <Label>{t('kuma.scope')}</Label>
<Select <Select
value={fields.uptime_kuma_monitor_scope} value={fields.uptime_kuma_monitor_scope}
onValueChange={(value) => onValueChange={(value) =>
@@ -553,8 +550,10 @@ export function OpenFlareOpsSettings() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='all'>全部站点</SelectItem> <SelectItem value='all'>{t('kuma.allSites')}</SelectItem>
<SelectItem value='selected'>选择站点</SelectItem> <SelectItem value='selected'>
{t('kuma.selectedSites')}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -562,26 +561,28 @@ export function OpenFlareOpsSettings() {
{fields.uptime_kuma_monitor_scope === 'selected' ? ( {fields.uptime_kuma_monitor_scope === 'selected' ? (
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<div className='flex items-center justify-between gap-2'> <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 <Button
type='button' type='button'
variant='outline' variant='outline'
size='sm' size='sm'
onClick={() => setUptimeKumaModalOpen(true)} onClick={() => setUptimeKumaModalOpen(true)}
> >
选择监控站点 {t('kuma.pickSites')}
</Button> </Button>
</div> </div>
<p className='mt-2 break-all text-xs text-muted-foreground'> <p className='mt-2 break-all text-xs text-muted-foreground'>
{fields.uptime_kuma_selected_sites {fields.uptime_kuma_selected_sites
? fields.uptime_kuma_selected_sites.split(',').join(', ') ? fields.uptime_kuma_selected_sites.split(',').join(', ')
: '未选择任何站点'} : t('kuma.noneSelected')}
</p> </p>
</div> </div>
) : null} ) : null}
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<FieldInput <FieldInput
label='检测频率 (秒)' label={t('kuma.interval')}
value={fields.uptime_kuma_interval} value={fields.uptime_kuma_interval}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -589,13 +590,13 @@ export function OpenFlareOpsSettings() {
} }
/> />
<FieldInput <FieldInput
label='重试次数' label={t('kuma.retry')}
value={fields.uptime_kuma_retry} value={fields.uptime_kuma_retry}
type='number' type='number'
onChange={(value) => updateField('uptime_kuma_retry', value)} onChange={(value) => updateField('uptime_kuma_retry', value)}
/> />
<FieldInput <FieldInput
label='重试间隔 (秒)' label={t('kuma.retryInterval')}
value={fields.uptime_kuma_retry_interval} value={fields.uptime_kuma_retry_interval}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -603,7 +604,7 @@ export function OpenFlareOpsSettings() {
} }
/> />
<FieldInput <FieldInput
label='请求超时 (秒)' label={t('kuma.timeout')}
value={fields.uptime_kuma_timeout} value={fields.uptime_kuma_timeout}
type='number' type='number'
onChange={(value) => onChange={(value) =>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -45,6 +46,7 @@ const formatNumber = (num: number | string) => {
* 运行时系统状态展示与管理组件 * 运行时系统状态展示与管理组件
*/ */
export function SystemStatusManager() { export function SystemStatusManager() {
const t = useTranslations('openflareOps.status');
const [status, setStatus] = useState<SystemStatus | null>(null); const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false); const [wavelet, setWavelet] = useState(false);
@@ -82,14 +84,14 @@ export function SystemStatusManager() {
setStatus(data); setStatus(data);
prevStatusRef.current = data; prevStatusRef.current = data;
} catch (err) { } catch (err) {
toast.error('获取系统状态失败', { toast.error(t('loadFailed'), {
description: err instanceof Error ? err.message : '请求时发生未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setLoading(false); setLoading(false);
setWavelet(false); setWavelet(false);
} }
}, []); }, [t]);
// 轮询逻辑 // 轮询逻辑
useEffect(() => { useEffect(() => {
@@ -196,7 +198,9 @@ export function SystemStatusManager() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Activity className='size-5 text-primary' /> <Activity className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>系统状态</h1> <h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
</div> </div>
</div> </div>
@@ -211,7 +215,7 @@ export function SystemStatusManager() {
htmlFor='auto-refresh' htmlFor='auto-refresh'
className='text-xs font-medium cursor-pointer select-none' className='text-xs font-medium cursor-pointer select-none'
> >
自动刷新 {t('autoRefresh')}
</label> </label>
</div> </div>
@@ -237,7 +241,7 @@ export function SystemStatusManager() {
disabled={wavelet} disabled={wavelet}
> >
<RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} /> <RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} />
刷新 {t('refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -251,10 +255,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'> <CardTitle className='text-sm font-semibold'>
服务概览 {t('overview')}
</CardTitle> </CardTitle>
<CardDescription className='text-[10px]'> <CardDescription className='text-[10px]'>
服务的基础生命指标 {t('overviewDesc')}
</CardDescription> </CardDescription>
</div> </div>
<Badge <Badge
@@ -266,17 +270,17 @@ export function SystemStatusManager() {
</CardHeader> </CardHeader>
<CardContent className='space-y-1'> <CardContent className='space-y-1'>
<RenderMetricRow <RenderMetricRow
label='服务运行时间' label={t('uptime')}
value={status.uptime} value={status.uptime}
field='uptime' field='uptime'
/> />
<RenderMetricRow <RenderMetricRow
label='当前 Goroutines 数量' label={t('goroutines')}
value={status.num_goroutine} value={status.num_goroutine}
field='num_goroutine' field='num_goroutine'
/> />
<RenderMetricRow <RenderMetricRow
label='Heap 对象数量' label={t('heapObjects')}
value={status.heap_objects} value={status.heap_objects}
field='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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'> <CardTitle className='text-sm font-semibold'>
内存统计 {t('memory')}
</CardTitle> </CardTitle>
<CardDescription className='text-[10px]'> <CardDescription className='text-[10px]'>
主内存消耗概览 {t('memoryDesc')}
</CardDescription> </CardDescription>
</div> </div>
<Badge <Badge
@@ -304,22 +308,22 @@ export function SystemStatusManager() {
<CardContent className='space-y-3'> <CardContent className='space-y-3'>
<div className='space-y-1'> <div className='space-y-1'>
<RenderMetricRow <RenderMetricRow
label='当前内存使用量' label={t('alloc')}
value={status.alloc} value={status.alloc}
field='alloc' field='alloc'
/> />
<RenderMetricRow <RenderMetricRow
label='所有已分配的内存' label={t('totalAlloc')}
value={status.total_alloc} value={status.total_alloc}
field='total_alloc' field='total_alloc'
/> />
<RenderMetricRow <RenderMetricRow
label='内存占用量' label={t('sys')}
value={status.sys} value={status.sys}
field='sys' field='sys'
/> />
<RenderMetricRow <RenderMetricRow
label='下次 GC 内存回收量' label={t('nextGc')}
value={status.next_gc} value={status.next_gc}
field='next_gc' field='next_gc'
/> />
@@ -327,7 +331,7 @@ export function SystemStatusManager() {
{/* 物理内存水位比例 */} {/* 物理内存水位比例 */}
<div className='space-y-1 px-1 pt-1'> <div className='space-y-1 px-1 pt-1'>
<div className='flex justify-between text-[10px] text-muted-foreground'> <div className='flex justify-between text-[10px] text-muted-foreground'>
<span>当前使用率 (Alloc / Sys)</span> <span>{t('usageRatio')}</span>
<span className='font-mono'> <span className='font-mono'>
{getMemoryUsagePercent()}% {getMemoryUsagePercent()}%
</span> </span>
@@ -342,10 +346,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'> <CardTitle className='text-sm font-semibold'>
堆/栈详情 {t('heapStack')}
</CardTitle> </CardTitle>
<CardDescription className='text-[10px]'> <CardDescription className='text-[10px]'>
运行时堆栈内存空间细节 {t('heapStackDesc')}
</CardDescription> </CardDescription>
</div> </div>
<Badge <Badge
@@ -357,37 +361,37 @@ export function SystemStatusManager() {
</CardHeader> </CardHeader>
<CardContent className='space-y-1'> <CardContent className='space-y-1'>
<RenderMetricRow <RenderMetricRow
label='当前 Heap 内存使用量' label={t('heapAlloc')}
value={status.heap_alloc} value={status.heap_alloc}
field='heap_alloc' field='heap_alloc'
/> />
<RenderMetricRow <RenderMetricRow
label='Heap 内存占用量' label={t('heapSys')}
value={status.heap_sys} value={status.heap_sys}
field='heap_sys' field='heap_sys'
/> />
<RenderMetricRow <RenderMetricRow
label='Heap 内存空闲量' label={t('heapIdle')}
value={status.heap_idle} value={status.heap_idle}
field='heap_idle' field='heap_idle'
/> />
<RenderMetricRow <RenderMetricRow
label='正在使用的 Heap 内存' label={t('heapInuse')}
value={status.heap_inuse} value={status.heap_inuse}
field='heap_inuse' field='heap_inuse'
/> />
<RenderMetricRow <RenderMetricRow
label='已释放的 Heap 内存' label={t('heapReleased')}
value={status.heap_released} value={status.heap_released}
field='heap_released' field='heap_released'
/> />
<RenderMetricRow <RenderMetricRow
label='启动 Stack 使用量' label={t('stackInuse')}
value={status.stack_inuse} value={status.stack_inuse}
field='stack_inuse' field='stack_inuse'
/> />
<RenderMetricRow <RenderMetricRow
label='已分配的 Stack 内存' label={t('stackSys')}
value={status.stack_sys} value={status.stack_sys}
field='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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'> <CardTitle className='text-sm font-semibold'>
底层及结构体内存 {t('structs')}
</CardTitle> </CardTitle>
<CardDescription className='text-[10px]'> <CardDescription className='text-[10px]'>
运行时底层管理结构体开销 {t('structsDesc')}
</CardDescription> </CardDescription>
</div> </div>
<Badge <Badge
@@ -414,37 +418,37 @@ export function SystemStatusManager() {
</CardHeader> </CardHeader>
<CardContent className='space-y-1'> <CardContent className='space-y-1'>
<RenderMetricRow <RenderMetricRow
label='MSpan 结构内存使用量' label={t('mspanInuse')}
value={status.mspan_inuse} value={status.mspan_inuse}
field='mspan_inuse' field='mspan_inuse'
/> />
<RenderMetricRow <RenderMetricRow
label='已分配的 MSpan 结构内存' label={t('mspanSys')}
value={status.mspan_sys} value={status.mspan_sys}
field='mspan_sys' field='mspan_sys'
/> />
<RenderMetricRow <RenderMetricRow
label='MCache 结构内存使用量' label={t('mcacheInuse')}
value={status.mcache_inuse} value={status.mcache_inuse}
field='mcache_inuse' field='mcache_inuse'
/> />
<RenderMetricRow <RenderMetricRow
label='已分配的 MCache 结构内存' label={t('mcacheSys')}
value={status.mcache_sys} value={status.mcache_sys}
field='mcache_sys' field='mcache_sys'
/> />
<RenderMetricRow <RenderMetricRow
label='已分配的剖析哈希表内存' label={t('buckHashSys')}
value={status.buck_hash_sys} value={status.buck_hash_sys}
field='buck_hash_sys' field='buck_hash_sys'
/> />
<RenderMetricRow <RenderMetricRow
label='已分配的 GC 元数据内存' label={t('gcSys')}
value={status.gc_sys} value={status.gc_sys}
field='gc_sys' field='gc_sys'
/> />
<RenderMetricRow <RenderMetricRow
label='其它已分配的系统内存' label={t('otherSys')}
value={status.other_sys} value={status.other_sys}
field='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'> <CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'> <div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'> <CardTitle className='text-sm font-semibold'>
垃圾回收与分配计数 {t('gc')}
</CardTitle> </CardTitle>
<CardDescription className='text-[10px]'> <CardDescription className='text-[10px]'>
GC 历史数据与分配频次 {t('gcDesc')}
</CardDescription> </CardDescription>
</div> </div>
<Badge <Badge
@@ -473,39 +477,39 @@ export function SystemStatusManager() {
<div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'> <div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'>
<div> <div>
<RenderMetricRow <RenderMetricRow
label='GC 执行次数' label={t('numGc')}
value={status.num_gc} value={status.num_gc}
field='num_gc' field='num_gc'
/> />
<RenderMetricRow <RenderMetricRow
label='距离上次 GC 时间' label={t('lastGc')}
value={status.last_gc_time} value={status.last_gc_time}
field='last_gc_time' field='last_gc_time'
/> />
<RenderMetricRow <RenderMetricRow
label='GC 暂停时间总量' label={t('pauseTotal')}
value={status.pause_total_ns} value={status.pause_total_ns}
field='pause_total_ns' field='pause_total_ns'
/> />
<RenderMetricRow <RenderMetricRow
label='上次 GC 暂停时间' label={t('lastPause')}
value={status.last_pause} value={status.last_pause}
field='last_pause' field='last_pause'
/> />
</div> </div>
<div> <div>
<RenderMetricRow <RenderMetricRow
label='内存分配次数' label={t('mallocs')}
value={status.mallocs} value={status.mallocs}
field='mallocs' field='mallocs'
/> />
<RenderMetricRow <RenderMetricRow
label='内存释放次数' label={t('frees')}
value={status.frees} value={status.frees}
field='frees' field='frees'
/> />
<RenderMetricRow <RenderMetricRow
label='指针查找次数' label={t('lookups')}
value={status.lookups} value={status.lookups}
field='lookups' field='lookups'
/> />
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -29,6 +30,8 @@ export function UptimeKumaSiteSelectModal({
onOpenChange, onOpenChange,
onSave, onSave,
}: UptimeKumaSiteSelectModalProps) { }: UptimeKumaSiteSelectModalProps) {
const t = useTranslations('openflareOps.siteModal');
const tCommon = useTranslations('common');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set()); const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
@@ -90,19 +93,17 @@ export function UptimeKumaSiteSelectModal({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'> <DialogContent className='max-w-2xl'>
<DialogHeader> <DialogHeader>
<DialogTitle>选择监控站点</DialogTitle> <DialogTitle>{t('title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('description')}</DialogDescription>
选择要同步到 Uptime Kuma 的反代站点,支持按站点名称和域名搜索。
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className='space-y-4'> <div className='space-y-4'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label>搜索站点</Label> <Label>{t('search')}</Label>
<Input <Input
value={searchTerm} value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)} onChange={(event) => setSearchTerm(event.target.value)}
placeholder='按名称或域名搜索...' placeholder={t('searchPlaceholder')}
/> />
</div> </div>
@@ -113,7 +114,7 @@ export function UptimeKumaSiteSelectModal({
size='sm' size='sm'
onClick={handleSelectAll} onClick={handleSelectAll}
> >
全选过滤项 {t('selectFiltered')}
</Button> </Button>
<Button <Button
type='button' type='button'
@@ -121,30 +122,32 @@ export function UptimeKumaSiteSelectModal({
size='sm' size='sm'
onClick={handleDeselectAll} onClick={handleDeselectAll}
> >
清空过滤项 {t('clearFiltered')}
</Button> </Button>
</div> </div>
<div className='max-h-72 overflow-y-auto rounded-lg border'> <div className='max-h-72 overflow-y-auto rounded-lg border'>
{routesQuery.isLoading ? ( {routesQuery.isLoading ? (
<p className='p-4 text-sm text-muted-foreground'> <p className='p-4 text-sm text-muted-foreground'>
加载站点列表... {t('loading')}
</p> </p>
) : routesQuery.isError ? ( ) : routesQuery.isError ? (
<p className='p-4 text-sm text-destructive'> <p className='p-4 text-sm text-destructive'>
{routesQuery.error instanceof Error {routesQuery.error instanceof Error
? routesQuery.error.message ? routesQuery.error.message
: '加载站点失败'} : t('loadFailed')}
</p> </p>
) : filteredRoutes.length === 0 ? ( ) : 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'> <table className='w-full text-left text-sm'>
<thead className='sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground'> <thead className='sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground'>
<tr> <tr>
<th className='w-12 px-3 py-2'>选择</th> <th className='w-12 px-3 py-2'>{t('select')}</th>
<th className='px-3 py-2'>站点名称</th> <th className='px-3 py-2'>{t('siteName')}</th>
<th className='px-3 py-2'>主域名</th> <th className='px-3 py-2'>{t('primaryDomain')}</th>
</tr> </tr>
</thead> </thead>
<tbody className='divide-y'> <tbody className='divide-y'>
@@ -184,7 +187,7 @@ export function UptimeKumaSiteSelectModal({
</div> </div>
<p className='text-right text-xs text-muted-foreground'> <p className='text-right text-xs text-muted-foreground'>
已选择 {tempSelected.size} 个监控站点 {t('selectedCount', { count: tempSelected.size })}
</p> </p>
</div> </div>
@@ -194,7 +197,7 @@ export function UptimeKumaSiteSelectModal({
variant='outline' variant='outline'
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
取消 {tCommon('cancel')}
</Button> </Button>
<Button <Button
type='button' type='button'
@@ -203,7 +206,7 @@ export function UptimeKumaSiteSelectModal({
onOpenChange(false); onOpenChange(false);
}} }}
> >
保存选择 {t('save')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -5,6 +5,7 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Loader2, Settings } from 'lucide-react'; import { Loader2, Settings } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
@@ -79,6 +80,7 @@ function systemConfigMap(configs: SystemConfig[]) {
} }
export function AdminSettingsPageClient() { export function AdminSettingsPageClient() {
const t = useTranslations('admin.settings');
const { user, loading } = useAuth(); const { user, loading } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -136,7 +138,9 @@ export function AdminSettingsPageClient() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Settings className='size-5 text-primary' /> <Settings className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>系统设置</h1> <h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div> </div>
</div> </div>
<Tabs <Tabs
@@ -149,40 +153,40 @@ export function AdminSettingsPageClient() {
value='openflare-ops' value='openflare-ops'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
OpenFlare {t('tabs.openflare')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='security' value='security'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
安全设置 {t('tabs.security')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='operation' value='operation'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
业务设置 {t('tabs.operation')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='system' value='system'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
系统设置 {t('tabs.system')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='other' value='other'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
其他设置 {t('tabs.other')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='status' value='status'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
系统状态 {t('tabs.status')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value='info' className='px-0 pb-2 text-xs font-semibold'> <TabsTrigger value='info' className='px-0 pb-2 text-xs font-semibold'>
系统信息 {t('tabs.info')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
+26 -19
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { import {
@@ -51,13 +52,14 @@ function SystemConfigDetailPanel({
onSave: () => void; onSave: () => void;
saving: boolean; saving: boolean;
}) { }) {
const t = useTranslations('admin.system');
return ( return (
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}> <ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
<div className='grid grid-cols-1 gap-0'> <div className='grid grid-cols-1 gap-0'>
<div className='border border-dashed rounded-lg'> <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'> <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'> <label className='text-xs font-medium text-muted-foreground'>
配置键 {t('configKey')}
</label> </label>
<p className='text-xs text-muted-foreground font-mono'> <p className='text-xs text-muted-foreground font-mono'>
{config?.key} {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'> <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'> <label className='text-xs font-medium text-muted-foreground'>
配置值 {t('configValue')}
</label> </label>
<div className='flex gap-1 w-[90%] justify-end items-center pr-3'> <div className='flex gap-1 w-[90%] justify-end items-center pr-3'>
{config?.key.endsWith('_enabled') ? ( {config?.key.endsWith('_enabled') ? (
@@ -107,7 +109,9 @@ function SystemConfigDetailPanel({
: config?.value || '' : config?.value || ''
} }
placeholder={ placeholder={
editData.value === undefined && !config?.value ? '必需' : '' editData.value === undefined && !config?.value
? t('required')
: ''
} }
onChange={(e) => { onChange={(e) => {
const value = e.target.value; 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'> <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'> <label className='text-xs font-medium text-muted-foreground'>
配置描述 {t('configDescription')}
</label> </label>
<div className='flex gap-1 w-[90%]'> <div className='flex gap-1 w-[90%]'>
<Input <Input
@@ -138,7 +142,7 @@ function SystemConfigDetailPanel({
? editData.description ? editData.description
: config?.description || '' : config?.description || ''
} }
placeholder='可选描述' placeholder={t('optionalDescription')}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
onEditDataChange('description', 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'> <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'> <label className='text-xs font-medium text-muted-foreground'>
公共可见 {t('publicVisibility')}
</label> </label>
<Switch <Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1} 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'> <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'> <label className='text-xs font-medium text-muted-foreground'>
配置类型 {t('configType')}
</label> </label>
<span <span
className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${ 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' : '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> </span>
</div> </div>
<div className='px-3 py-2 flex items-center justify-between'> <div className='px-3 py-2 flex items-center justify-between'>
<label className='text-xs font-medium text-muted-foreground'> <label className='text-xs font-medium text-muted-foreground'>
创建时间 {t('createdAt')}
</label> </label>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
{config ? formatDateTime(config.created_at) : ''} {config ? formatDateTime(config.created_at) : ''}
@@ -199,6 +205,7 @@ function SystemConfigDetailPanel({
* @returns {React.ReactNode} 系统配置管理组件 * @returns {React.ReactNode} 系统配置管理组件
*/ */
export function SystemConfigs() { export function SystemConfigs() {
const t = useTranslations('admin.system');
const { const {
systemConfigs: configs, systemConfigs: configs,
systemConfigsLoading: loading, systemConfigsLoading: loading,
@@ -236,7 +243,7 @@ export function SystemConfigs() {
return ( return (
<ManagePage<SystemConfig> <ManagePage<SystemConfig>
title='系统配置' title={t('pageTitle')}
icon={ShieldCheck} icon={ShieldCheck}
data={configs} data={configs}
loading={loading} loading={loading}
@@ -245,8 +252,8 @@ export function SystemConfigs() {
getInitialEditData={getInitialEditData} getInitialEditData={getInitialEditData}
onSave={handleSave} onSave={handleSave}
getId={(config) => config.key} getId={(config) => config.key}
emptyDescription='未发现系统配置' emptyDescription={t('emptyDescription')}
loadingDescription='配置加载中' loadingDescription={t('loadingDescription')}
headerExtra={ headerExtra={
<Tabs <Tabs
value={activeTab} value={activeTab}
@@ -255,24 +262,24 @@ export function SystemConfigs() {
> >
<TabsList className='grid w-full grid-cols-2 h-8'> <TabsList className='grid w-full grid-cols-2 h-8'>
<TabsTrigger value='business' className='text-[11px] h-7'> <TabsTrigger value='business' className='text-[11px] h-7'>
业务配置 {t('businessConfig')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value='system' className='text-[11px] h-7'> <TabsTrigger value='system' className='text-[11px] h-7'>
系统配置 {t('systemConfig')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
} }
columns={[ columns={[
{ {
header: '配置键', header: t('configKey'),
cell: (item) => ( cell: (item) => (
<span className='font-mono font-medium'>{item.key}</span> <span className='font-mono font-medium'>{item.key}</span>
), ),
width: '200px', width: '200px',
}, },
{ {
header: '配置值', header: t('configValue'),
cell: (item) => ( cell: (item) => (
<span <span
className='truncate max-w-[120px] inline-block' className='truncate max-w-[120px] inline-block'
@@ -284,14 +291,14 @@ export function SystemConfigs() {
width: '120px', width: '120px',
}, },
{ {
header: '公共可见', header: t('publicVisibility'),
cell: (item) => ( cell: (item) => (
<span>{item.visibility === 1 ? '可见' : '不可见'}</span> <span>{item.visibility === 1 ? t('visible') : t('invisible')}</span>
), ),
width: '80px', width: '80px',
}, },
{ {
header: '描述', header: t('description'),
cell: (item) => ( cell: (item) => (
<span <span
className='truncate max-w-[200px] inline-block text-muted-foreground' className='truncate max-w-[200px] inline-block text-muted-foreground'
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -49,18 +50,18 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
const STATUS_LABELS: Record<TaskExecutionStatus, string> = { const STATUS_LABELS_KEYS: Record<TaskExecutionStatus, string> = {
pending: '等待中', pending: 'statusPending',
running: '执行中', running: 'statusRunning',
succeeded: '成功', succeeded: 'statusSucceeded',
failed: '失败', failed: 'statusFailed',
}; };
const TRIGGER_LABELS: Record<string, string> = { const TRIGGER_LABELS_KEYS: Record<string, string> = {
system: '系统', system: 'triggerSystem',
manual: '手动', manual: 'triggerManual',
retry: '重试', retry: 'triggerRetry',
schedule: '定时', schedule: 'triggerSchedule',
}; };
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -85,6 +86,7 @@ function statusVariant(status: TaskExecutionStatus) {
} }
export function TaskExecutionsManager() { export function TaskExecutionsManager() {
const t = useTranslations('admin.tasks');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [executionsPage, setExecutionsPage] = useState(1); const [executionsPage, setExecutionsPage] = useState(1);
const [executionStatus, setExecutionStatus] = useState< const [executionStatus, setExecutionStatus] = useState<
@@ -129,16 +131,16 @@ export function TaskExecutionsManager() {
const retryMutation = useMutation({ const retryMutation = useMutation({
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id), mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
onSuccess: (taskID) => { onSuccess: (taskID) => {
toast.success('任务已重新下发', { toast.success(t('retrySuccess'), {
description: `新任务 ID:${taskID}`, description: t('retrySuccessDesc', { taskId: taskID }),
}); });
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ['admin', 'task-executions'], queryKey: ['admin', 'task-executions'],
}); });
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast.error('任务重试失败', { toast.error(t('retryFailed'), {
description: err.message || '未知错误', description: err.message || t('unknownError'),
}); });
}, },
}); });
@@ -187,14 +189,14 @@ export function TaskExecutionsManager() {
} }
> >
<SelectTrigger size='sm' className='w-[120px]'> <SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' /> <SelectValue placeholder={t('colStatus')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='all'>全部状态</SelectItem> <SelectItem value='all'>{t('allStatuses')}</SelectItem>
<SelectItem value='pending'>等待中</SelectItem> <SelectItem value='pending'>{t('statusPending')}</SelectItem>
<SelectItem value='running'>执行中</SelectItem> <SelectItem value='running'>{t('statusRunning')}</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem> <SelectItem value='succeeded'>{t('statusSucceeded')}</SelectItem>
<SelectItem value='failed'>失败</SelectItem> <SelectItem value='failed'>{t('statusFailed')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select <Select
@@ -202,10 +204,10 @@ export function TaskExecutionsManager() {
onValueChange={handleTaskTypeChange} onValueChange={handleTaskTypeChange}
> >
<SelectTrigger size='sm' className='w-[180px]'> <SelectTrigger size='sm' className='w-[180px]'>
<SelectValue placeholder='任务类型' /> <SelectValue placeholder={t('taskTypeFilter')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='all'>全部任务</SelectItem> <SelectItem value='all'>{t('allTasks')}</SelectItem>
{taskTypes.map((task) => ( {taskTypes.map((task) => (
<SelectItem key={task.type} value={task.type}> <SelectItem key={task.type} value={task.type}>
{task.name || task.type} {task.name || task.type}
@@ -224,7 +226,7 @@ export function TaskExecutionsManager() {
) : ( ) : (
<RefreshCw className='size-4' /> <RefreshCw className='size-4' />
)} )}
刷新 {t('refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -240,22 +242,22 @@ export function TaskExecutionsManager() {
) : executionsLoading && executions.length === 0 ? ( ) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Activity} icon={Activity}
description='加载任务执行记录中...' description={t('loadingExecutions')}
/> />
) : executions.length === 0 ? ( ) : executions.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description='暂无任务执行记录' /> <EmptyStateWithBorder icon={Activity} description={t('noExecutions')} />
) : ( ) : (
<div className='rounded-lg border bg-card'> <div className='rounded-lg border bg-card'>
<Table className='min-w-[900px]'> <Table className='min-w-[900px]'>
<TableHeader> <TableHeader>
<TableRow className='hover:bg-transparent'> <TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务</TableHead> <TableHead className='w-[180px]'>{t('colTask')}</TableHead>
<TableHead className='w-[100px]'>状态</TableHead> <TableHead className='w-[100px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[110px]'>触发</TableHead> <TableHead className='w-[110px]'>{t('colTrigger')}</TableHead>
<TableHead className='w-[120px]'>重试</TableHead> <TableHead className='w-[120px]'>{t('colRetry')}</TableHead>
<TableHead className='w-[120px]'>耗时</TableHead> <TableHead className='w-[120px]'>{t('colDuration')}</TableHead>
<TableHead className='min-w-[220px]'>结果/错误</TableHead> <TableHead className='min-w-[220px]'>{t('colResult')}</TableHead>
<TableHead className='w-[170px]'>创建时间</TableHead> <TableHead className='w-[170px]'>{t('colCreatedAt')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -277,12 +279,13 @@ export function TaskExecutionsManager() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={statusVariant(execution.status)}> <Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status} {t(STATUS_LABELS_KEYS[execution.status]) ||
execution.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant='outline'> <Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] || {t(TRIGGER_LABELS_KEYS[execution.triggered_by]) ||
execution.triggered_by} execution.triggered_by}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -307,7 +310,11 @@ export function TaskExecutionsManager() {
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页 {t('paginationInfo', {
total: executionsTotal,
current: executionsPage,
totalPages,
})}
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button <Button
@@ -317,7 +324,7 @@ export function TaskExecutionsManager() {
disabled={executionsPage <= 1 || executionsLoading} disabled={executionsPage <= 1 || executionsLoading}
> >
<ChevronLeft className='size-4' /> <ChevronLeft className='size-4' />
上一页 {t('prevPage')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -327,7 +334,7 @@ export function TaskExecutionsManager() {
} }
disabled={executionsPage >= totalPages || executionsLoading} disabled={executionsPage >= totalPages || executionsLoading}
> >
下一页 {t('nextPage')}
<ChevronRight className='size-4' /> <ChevronRight className='size-4' />
</Button> </Button>
</div> </div>
@@ -336,11 +343,11 @@ export function TaskExecutionsManager() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}> <Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px]'> <SheetContent className='w-full p-0 sm:max-w-[640px]'>
<SheetHeader className='border-b'> <SheetHeader className='border-b'>
<SheetTitle>任务执行详情</SheetTitle> <SheetTitle>{t('executionDetailTitle')}</SheetTitle>
<SheetDescription> <SheetDescription>
{selectedExecution?.task_name || {selectedExecution?.task_name ||
selectedExecution?.task_type || selectedExecution?.task_type ||
'任务记录'} t('executionRecord')}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -348,32 +355,35 @@ export function TaskExecutionsManager() {
{detailLoading && !selectedExecution ? ( {detailLoading && !selectedExecution ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Activity} icon={Activity}
description='加载任务详情中...' description={t('loadingDetail')}
/> />
) : selectedExecution ? ( ) : selectedExecution ? (
<div className='space-y-5 py-4'> <div className='space-y-5 py-4'>
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-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'> <div className='mt-2'>
<Badge variant={statusVariant(selectedExecution.status)}> <Badge variant={statusVariant(selectedExecution.status)}>
{STATUS_LABELS[selectedExecution.status] || {t(STATUS_LABELS_KEYS[selectedExecution.status]) ||
selectedExecution.status} selectedExecution.status}
</Badge> </Badge>
</div> </div>
</div> </div>
<div className='rounded-lg border p-3'> <div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
触发来源 {t('detailTrigger')}
</div> </div>
<div className='mt-2 text-sm font-medium'> <div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selectedExecution.triggered_by] || {t(
selectedExecution.triggered_by} TRIGGER_LABELS_KEYS[selectedExecution.triggered_by],
) || selectedExecution.triggered_by}
</div> </div>
</div> </div>
<div className='rounded-lg border p-3'> <div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
重试次数 {t('detailRetryCount')}
</div> </div>
<div className='mt-2 font-mono text-sm'> <div className='mt-2 font-mono text-sm'>
{selectedExecution.retry_count}/ {selectedExecution.retry_count}/
@@ -381,7 +391,9 @@ export function TaskExecutionsManager() {
</div> </div>
</div> </div>
<div className='rounded-lg border p-3'> <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'> <div className='mt-2 font-mono text-sm'>
{formatDuration(selectedExecution.duration)} {formatDuration(selectedExecution.duration)}
</div> </div>
@@ -389,7 +401,7 @@ export function TaskExecutionsManager() {
</div> </div>
<div className='grid gap-2'> <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'> <div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selectedExecution.task_id} {selectedExecution.task_id}
</div> </div>
@@ -397,25 +409,25 @@ export function TaskExecutionsManager() {
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'> <div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label>创建时间</Label> <Label>{t('detailCreatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'> <div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.created_at)} {formatDateTime(selectedExecution.created_at)}
</div> </div>
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label>开始时间</Label> <Label>{t('detailStartedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'> <div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.started_at)} {formatDateTime(selectedExecution.started_at)}
</div> </div>
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label>结束时间</Label> <Label>{t('detailFinishedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'> <div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.finished_at)} {formatDateTime(selectedExecution.finished_at)}
</div> </div>
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label>更新时间</Label> <Label>{t('detailUpdatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'> <div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.updated_at)} {formatDateTime(selectedExecution.updated_at)}
</div> </div>
@@ -423,7 +435,7 @@ export function TaskExecutionsManager() {
</div> </div>
<div className='grid gap-2'> <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'> <div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selectedExecution.result || '-'} {selectedExecution.result || '-'}
</div> </div>
@@ -431,7 +443,7 @@ export function TaskExecutionsManager() {
{selectedExecution.error_message && ( {selectedExecution.error_message && (
<div className='grid gap-2'> <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'> <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} {selectedExecution.error_message}
</div> </div>
@@ -439,23 +451,23 @@ export function TaskExecutionsManager() {
)} )}
<div className='grid gap-2'> <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'> <pre className='max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selectedExecution.payload || '{}'} {selectedExecution.payload || '{}'}
</pre> </pre>
</div> </div>
<div className='grid gap-2'> <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'> <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> </pre>
</div> </div>
</div> </div>
) : ( ) : (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Activity} icon={Activity}
description='未选择任务记录' description={t('noExecutionSelected')}
/> />
)} )}
</div> </div>
@@ -471,7 +483,7 @@ export function TaskExecutionsManager() {
) : ( ) : (
<RefreshCw className='size-4' /> <RefreshCw className='size-4' />
)} )}
刷新详情 {t('refreshDetail')}
</Button> </Button>
{selectedExecution && {selectedExecution &&
selectedExecution.status === 'failed' && selectedExecution.status === 'failed' &&
@@ -485,7 +497,7 @@ export function TaskExecutionsManager() {
) : ( ) : (
<RotateCcw className='size-4' /> <RotateCcw className='size-4' />
)} )}
重试任务 {t('retryTask')}
</Button> </Button>
)} )}
</SheetFooter> </SheetFooter>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -88,10 +89,10 @@ const TASK_CONFIGS: Record<
}, },
}; };
const LOG_DATABASE_LABELS: Record<string, string> = { const LOG_DATABASE_LABEL_KEYS: Record<string, string> = {
postgres: 'PostgreSQL(主库)', postgres: 'dbPostgresPrimary',
sqlite: 'SQLite(主库)', sqlite: 'dbSqlitePrimary',
clickhouse: 'ClickHouse', clickhouse: 'dbClickhouse',
}; };
const DEFAULT_TASK_CONFIG = { const DEFAULT_TASK_CONFIG = {
@@ -108,6 +109,7 @@ function DatePickerWithTime({
date: Date | undefined; date: Date | undefined;
setDate: (date: Date | undefined) => void; setDate: (date: Date | undefined) => void;
}) { }) {
const t = useTranslations('admin.tasks');
const timeString = date ? format(date, 'HH:mm:ss') : ''; const timeString = date ? format(date, 'HH:mm:ss') : '';
const handleDateSelect = (newDate: Date | undefined) => { const handleDateSelect = (newDate: Date | undefined) => {
@@ -163,7 +165,7 @@ function DatePickerWithTime({
)} )}
> >
<CalendarIcon className='mr-1 size-3' /> <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> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'> <PopoverContent className='w-auto p-0' align='start'>
@@ -190,6 +192,7 @@ function DatePickerWithTime({
} }
export function TaskManager() { export function TaskManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]); const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]);
@@ -210,11 +213,11 @@ export function TaskManager() {
const data = await services.adminTask.getTaskTypes(); const data = await services.adminTask.getTaskTypes();
setTaskTypes(data); setTaskTypes(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败')); setError(err instanceof Error ? err : new Error(t('loadTaskTypesFailed')));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
const [logDbStatus, setLogDbStatus] = useState<LogDatabaseStatus | null>( const [logDbStatus, setLogDbStatus] = useState<LogDatabaseStatus | null>(
null, null,
@@ -302,8 +305,11 @@ export function TaskManager() {
const taskID = await services.adminTask.dispatchTask(params); const taskID = await services.adminTask.dispatchTask(params);
toast.success('任务下发成功', { toast.success(t('dispatchSuccess'), {
description: `已成功将任务 ${targetTask?.name || selectedTaskType} 加入队列:${taskID}`, description: t('dispatchSuccessDesc', {
name: targetTask?.name || selectedTaskType,
taskId: taskID,
}),
}); });
setDialogOpen(false); setDialogOpen(false);
@@ -312,8 +318,8 @@ export function TaskManager() {
setUserId(''); setUserId('');
setParamValues({}); setParamValues({});
} catch (err) { } catch (err) {
toast.error('任务下发失败', { toast.error(t('dispatchFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setDispatching(false); setDispatching(false);
@@ -345,10 +351,10 @@ export function TaskManager() {
) : loading && taskTypes.length === 0 ? ( ) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Layers} icon={Layers}
description='加载任务类型中...' description={t('loadingTaskTypes')}
/> />
) : taskTypes.length === 0 ? ( ) : 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'> <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'>
{taskTypes.map((task, index) => { {taskTypes.map((task, index) => {
@@ -386,13 +392,15 @@ export function TaskManager() {
variant='outline' variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1' 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>
<Badge <Badge
variant='outline' variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1' 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> </Badge>
</div> </div>
</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='pt-3 mt-3 border-t border-border/50 space-y-1.5'>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'> <span className='text-[10px] text-muted-foreground shrink-0'>
日志主库 {t('logPrimaryDb')}
</span> </span>
<span className='text-[10px] font-mono text-foreground truncate'> <span className='text-[10px] font-mono text-foreground truncate'>
{LOG_DATABASE_LABELS[logDbStatus.active_database] || {LOG_DATABASE_LABEL_KEYS[
logDbStatus.active_database} logDbStatus.active_database
]
? t(
LOG_DATABASE_LABEL_KEYS[
logDbStatus.active_database
],
)
: logDbStatus.active_database}
</span> </span>
</div> </div>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'> <span className='text-[10px] text-muted-foreground shrink-0'>
保留天数 {t('retentionDays')}
</span> </span>
<span className='text-[10px] font-mono text-muted-foreground truncate'> <span className='text-[10px] font-mono text-muted-foreground truncate'>
{retentionSummary || '-'} {retentionSummary || '-'}
@@ -418,7 +433,7 @@ export function TaskManager() {
</div> </div>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<span className='text-[10px] text-muted-foreground shrink-0'> <span className='text-[10px] text-muted-foreground shrink-0'>
迁移状态 {t('migrationStatus')}
</span> </span>
<Badge <Badge
variant={ variant={
@@ -429,8 +444,8 @@ export function TaskManager() {
className='text-[10px] h-5 px-1.5' className='text-[10px] h-5 px-1.5'
> >
{logDbStatus.migration === 'migrating' {logDbStatus.migration === 'migrating'
? '迁移中' ? t('migrating')
: '空闲'} : t('idle')}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -443,7 +458,7 @@ export function TaskManager() {
onClick={() => openDispatchDialog(task.type)} onClick={() => openDispatchDialog(task.type)}
> >
<Play className='size-3 mr-1' /> <Play className='size-3 mr-1' />
立即执行 {t('executeNow')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -457,15 +472,13 @@ export function TaskManager() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className='sm:max-w-[500px]'> <DialogContent className='sm:max-w-[500px]'>
<DialogHeader> <DialogHeader>
<DialogTitle>下发任务</DialogTitle> <DialogTitle>{t('dispatchTask')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('dispatchTaskDesc')}</DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className='grid gap-4 py-4'> <div className='grid gap-4 py-4'>
<div className='grid gap-2'> <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'> <div className='flex items-center gap-2 p-2 rounded-lg bg-muted/50'>
{(() => { {(() => {
const meta = getSelectedTaskMeta(); const meta = getSelectedTaskMeta();
@@ -495,20 +508,20 @@ export function TaskManager() {
return ( return (
<> <>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label>开始时间</Label> <Label>{t('startTime')}</Label>
<DatePickerWithTime <DatePickerWithTime
date={startTime} date={startTime}
setDate={setStartTime} setDate={setStartTime}
/> />
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
选择同步的起始时间点 {t('startTimeDesc')}
</p> </p>
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label>结束时间</Label> <Label>{t('endTime')}</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} /> <DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
选择同步的结束时间点 {t('endTimeDesc')}
</p> </p>
</div> </div>
</> </>
@@ -572,8 +585,8 @@ export function TaskManager() {
/> />
<span className='text-xs text-muted-foreground'> <span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true' {paramValues[param.name] === 'true'
? '开启' ? t('paramOn')
: '关闭'} : t('paramOff')}
</span> </span>
</div> </div>
) : isSwitchTarget && ) : isSwitchTarget &&
@@ -593,12 +606,14 @@ export function TaskManager() {
className='w-full text-xs' className='w-full text-xs'
size='sm' size='sm'
> >
<SelectValue placeholder='选择目标日志库...' /> <SelectValue placeholder={t('selectLogDb')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableLogDbTargets.map((target) => ( {availableLogDbTargets.map((target) => (
<SelectItem key={target} value={target}> <SelectItem key={target} value={target}>
{LOG_DATABASE_LABELS[target] || target} {LOG_DATABASE_LABEL_KEYS[target]
? t(LOG_DATABASE_LABEL_KEYS[target])
: target}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -639,7 +654,7 @@ export function TaskManager() {
return ( return (
<div className='flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed'> <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' /> <Info className='h-4 w-4' />
<span>此任务无需额外配置参数,确认后将直接下发。</span> <span>{t('noParamsHint')}</span>
</div> </div>
); );
} }
@@ -654,7 +669,7 @@ export function TaskManager() {
disabled={dispatching} disabled={dispatching}
className='h-8 text-xs' className='h-8 text-xs'
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleDispatch} onClick={handleDispatch}
@@ -666,7 +681,7 @@ export function TaskManager() {
) : ( ) : (
<Play className='size-3' /> <Play className='size-3' />
)} )}
{dispatching ? '下发中' : '启动'} {dispatching ? t('dispatching') : t('start')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -39,6 +40,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
export function TaskSchedulesManager() { export function TaskSchedulesManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const [schedules, setSchedules] = useState<Schedule[]>([]); const [schedules, setSchedules] = useState<Schedule[]>([]);
@@ -73,11 +75,11 @@ export function TaskSchedulesManager() {
setSchedules(schedulesData || []); setSchedules(schedulesData || []);
setTaskTypes(taskTypesData || []); setTaskTypes(taskTypesData || []);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err : new Error('加载定时任务配置失败')); setError(err instanceof Error ? err : new Error(t('loadSchedulesFailed')));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@@ -130,12 +132,12 @@ export function TaskSchedulesManager() {
); );
toast.success( toast.success(
updated.is_active updated.is_active
? `已启用定时任务:${updated.name}` ? t('scheduleEnabled', { name: updated.name })
: `已停用定时任务:${updated.name}`, : t('scheduleDisabled', { name: updated.name }),
); );
} catch (err) { } catch (err) {
toast.error('修改任务状态失败', { toast.error(t('changeScheduleStatusFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} }
}; };
@@ -161,11 +163,11 @@ export function TaskSchedulesManager() {
const handleSubmit = async () => { const handleSubmit = async () => {
if (!name.trim()) { if (!name.trim()) {
toast.error('任务名称不能为空'); toast.error(t('scheduleNameRequired'));
return; return;
} }
if (!cron.trim()) { if (!cron.trim()) {
toast.error('Cron 表达式不能为空'); toast.error(t('cronRequired'));
return; return;
} }
@@ -203,7 +205,7 @@ export function TaskSchedulesManager() {
setSchedules((prev) => setSchedules((prev) =>
prev.map((s) => (s.id === editingSchedule.id ? updated : s)), prev.map((s) => (s.id === editingSchedule.id ? updated : s)),
); );
toast.success('定时任务更新成功'); toast.success(t('scheduleUpdateSuccess'));
} else { } else {
const req: CreateScheduleRequest = { const req: CreateScheduleRequest = {
name, name,
@@ -214,13 +216,13 @@ export function TaskSchedulesManager() {
}; };
const created = await services.adminTask.createSchedule(req); const created = await services.adminTask.createSchedule(req);
setSchedules((prev) => [created, ...prev]); setSchedules((prev) => [created, ...prev]);
toast.success('定时任务创建成功'); toast.success(t('scheduleCreateSuccess'));
} }
setDialogOpen(false); setDialogOpen(false);
} catch (err) { } catch (err) {
toast.error('提交失败', { toast.error(t('submitFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setSubmitLoading(false); setSubmitLoading(false);
@@ -238,11 +240,11 @@ export function TaskSchedulesManager() {
setDeleteLoading(true); setDeleteLoading(true);
await services.adminTask.deleteSchedule(deletingId); await services.adminTask.deleteSchedule(deletingId);
setSchedules((prev) => prev.filter((s) => s.id !== deletingId)); setSchedules((prev) => prev.filter((s) => s.id !== deletingId));
toast.success('定时任务删除成功'); toast.success(t('scheduleDeleteSuccess'));
setDeleteOpen(false); setDeleteOpen(false);
} catch (err) { } catch (err) {
toast.error('删除失败', { toast.error(t('deleteFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setDeleteLoading(false); setDeleteLoading(false);
@@ -274,7 +276,7 @@ export function TaskSchedulesManager() {
) : ( ) : (
<RefreshCw className='size-4' /> <RefreshCw className='size-4' />
)} )}
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
size='sm' size='sm'
@@ -283,7 +285,7 @@ export function TaskSchedulesManager() {
variant={'secondary'} variant={'secondary'}
> >
<Plus className='size-4 mr-1' /> <Plus className='size-4 mr-1' />
新增定时任务 {t('addSchedule')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -299,28 +301,34 @@ export function TaskSchedulesManager() {
) : loading && schedules.length === 0 ? ( ) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Clock} icon={Clock}
description='加载定时任务配置中...' description={t('loadingSchedules')}
/> />
) : schedules.length === 0 ? ( ) : schedules.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Clock} icon={Clock}
description='暂无定时任务配置,点击上方按钮新增' description={t('noSchedules')}
/> />
) : ( ) : (
<div className='rounded-lg border bg-card'> <div className='rounded-lg border bg-card'>
<Table className='min-w-[800px]'> <Table className='min-w-[800px]'>
<TableHeader> <TableHeader>
<TableRow className='hover:bg-transparent'> <TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务名称</TableHead> <TableHead className='w-[180px]'>
<TableHead className='w-[160px]'>关联异步任务</TableHead> {t('colScheduleName')}
<TableHead className='w-[120px]'>Cron 表达式</TableHead> </TableHead>
<TableHead className='w-[160px]'>
{t('colAssociatedTask')}
</TableHead>
<TableHead className='w-[120px]'>{t('colCron')}</TableHead>
<TableHead className='min-w-[200px]'> <TableHead className='min-w-[200px]'>
执行参数 (Payload) {t('colPayload')}
</TableHead> </TableHead>
<TableHead className='w-[100px] text-center'> <TableHead className='w-[100px] text-center'>
启用状态 {t('colEnabled')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('colActions')}
</TableHead> </TableHead>
<TableHead className='w-[120px] text-right'>操作</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -385,19 +393,19 @@ export function TaskSchedulesManager() {
<DialogContent className='sm:max-w-[500px]'> <DialogContent className='sm:max-w-[500px]'>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editingSchedule ? '修改定时任务' : '新增定时任务'} {editingSchedule ? t('editSchedule') : t('addSchedule')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
配置定时任务的调度参数和运行载荷。 {t('scheduleDialogDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className='grid gap-4 py-4'> <div className='grid gap-4 py-4'>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='sched-name'>任务名称</Label> <Label htmlFor='sched-name'>{t('scheduleName')}</Label>
<Input <Input
id='sched-name' id='sched-name'
placeholder='例如:每日数据清理' placeholder={t('scheduleNamePlaceholder')}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className='text-xs' className='text-xs'
@@ -405,7 +413,7 @@ export function TaskSchedulesManager() {
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='sched-type'>关联异步任务</Label> <Label htmlFor='sched-type'>{t('associatedTask')}</Label>
<select <select
id='sched-type' id='sched-type'
value={selectedTaskType} value={selectedTaskType}
@@ -422,24 +430,24 @@ export function TaskSchedulesManager() {
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='sched-cron'>Cron 表达式</Label> <Label htmlFor='sched-cron'>{t('cronExpression')}</Label>
<Input <Input
id='sched-cron' id='sched-cron'
placeholder='e.g. 0 */2 * * * 或 @daily' placeholder={t('cronPlaceholder')}
value={cron} value={cron}
onChange={(e) => setCron(e.target.value)} onChange={(e) => setCron(e.target.value)}
className='text-xs font-mono' className='text-xs font-mono'
/> />
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。 {t('cronHint')}
</p> </p>
</div> </div>
<div className='flex items-center justify-between rounded-lg border p-3'> <div className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'> <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'> <p className='text-[10px] text-muted-foreground'>
决定此定时任务是否会定时触发执行。 {t('scheduleActiveDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -459,14 +467,14 @@ export function TaskSchedulesManager() {
return ( return (
<div className='flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed'> <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' /> <Info className='h-4 w-4 shrink-0' />
<span>所选任务类型不需要运行参数参数。</span> <span>{t('noParamsRequired')}</span>
</div> </div>
); );
} }
return ( return (
<div className='space-y-4 pt-2 border-t'> <div className='space-y-4 pt-2 border-t'>
<Label className='text-xs font-semibold'> <Label className='text-xs font-semibold'>
运行参数配置 (Payload) {t('payloadConfig')}
</Label> </Label>
{targetTask.params.map((param) => ( {targetTask.params.map((param) => (
<div <div
@@ -509,8 +517,8 @@ export function TaskSchedulesManager() {
/> />
<span className='text-xs text-muted-foreground'> <span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true' {paramValues[param.name] === 'true'
? '开启' ? t('paramOn')
: '关闭'} : t('paramOff')}
</span> </span>
</div> </div>
) : ( ) : (
@@ -547,7 +555,7 @@ export function TaskSchedulesManager() {
disabled={submitLoading} disabled={submitLoading}
className='h-8 text-xs' className='h-8 text-xs'
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
@@ -555,7 +563,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs' className='h-8 text-xs'
> >
{submitLoading && <Spinner className='size-3 mr-1' />} {submitLoading && <Spinner className='size-3 mr-1' />}
{editingSchedule ? '保存修改' : '确认创建'} {editingSchedule ? t('saveChanges') : t('confirmCreate')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -565,9 +573,9 @@ export function TaskSchedulesManager() {
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className='sm:max-w-[400px]'> <DialogContent className='sm:max-w-[400px]'>
<DialogHeader> <DialogHeader>
<DialogTitle>删除定时任务</DialogTitle> <DialogTitle>{t('deleteScheduleTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。 {t('deleteScheduleDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className='gap-2 sm:gap-0'> <DialogFooter className='gap-2 sm:gap-0'>
@@ -577,7 +585,7 @@ export function TaskSchedulesManager() {
disabled={deleteLoading} disabled={deleteLoading}
className='h-8 text-xs' className='h-8 text-xs'
> >
取消 {t('cancel')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -586,7 +594,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs' className='h-8 text-xs'
> >
{deleteLoading && <Spinner className='size-3 mr-1' />} {deleteLoading && <Spinner className='size-3 mr-1' />}
确认删除 {t('confirmDelete')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+8 -4
View File
@@ -2,6 +2,7 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TaskManager } from './components/task-manager'; import { TaskManager } from './components/task-manager';
import { TaskSchedulesManager } from './components/task-schedules'; import { TaskSchedulesManager } from './components/task-schedules';
@@ -10,6 +11,7 @@ import { Spinner } from '@/components/ui/spinner';
import { Layers } from 'lucide-react'; import { Layers } from 'lucide-react';
function TasksPageContent() { function TasksPageContent() {
const t = useTranslations('admin.tasks');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const activeTab = searchParams.get('tab') || 'tasks'; const activeTab = searchParams.get('tab') || 'tasks';
@@ -22,7 +24,9 @@ function TasksPageContent() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Layers className='size-5 text-primary' /> <Layers className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>任务管理</h1> <h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div> </div>
</div> </div>
@@ -36,19 +40,19 @@ function TasksPageContent() {
value='tasks' value='tasks'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
任务管理 {t('tabTasks')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='schedules' value='schedules'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
定时任务 {t('tabSchedules')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value='executions' value='executions'
className='px-0 pb-2 text-xs font-semibold' className='px-0 pb-2 text-xs font-semibold'
> >
任务日志 {t('tabExecutions')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='tasks' className='space-y-4 outline-none'> <TabsContent value='tasks' className='space-y-4 outline-none'>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -31,6 +32,7 @@ export function CreateUserModal({
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const t = useTranslations('admin.users');
const { createUser } = useAdminUsers(); const { createUser } = useAdminUsers();
const [form, setForm] = useState<CreateUserRequest>(emptyForm); const [form, setForm] = useState<CreateUserRequest>(emptyForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -48,21 +50,21 @@ export function CreateUserModal({
const validate = () => { const validate = () => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!form.username.trim()) { if (!form.username.trim()) {
newErrors.username = '用户名不能为空'; newErrors.username = t('validation.usernameRequired');
} else if (form.username.trim().length < 3) { } else if (form.username.trim().length < 3) {
newErrors.username = '用户名长度不能少于 3 位'; newErrors.username = t('validation.usernameTooShort');
} }
if (!form.email.trim()) { if (!form.email.trim()) {
newErrors.email = '邮箱不能为空'; newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) { } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确'; newErrors.email = t('validation.emailInvalid');
} }
if (!form.password) { if (!form.password) {
newErrors.password = '密码不能为空'; newErrors.password = t('validation.passwordRequired');
} else if (form.password.length < 8) { } else if (form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位'; newErrors.password = t('validation.passwordTooShort');
} }
setErrors(newErrors); setErrors(newErrors);
@@ -93,22 +95,20 @@ export function CreateUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'> <DialogContent className='max-w-md'>
<DialogHeader> <DialogHeader>
<DialogTitle>新增用户</DialogTitle> <DialogTitle>{t('createTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('createDesc')}</DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'> <form onSubmit={handleSave} className='space-y-4 pt-2'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='username'>用户名</Label> <Label htmlFor='username'>{t('username')}</Label>
<Input <Input
id='username' id='username'
value={form.username} value={form.username}
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, username: e.target.value })) setForm((prev) => ({ ...prev, username: e.target.value }))
} }
placeholder='请输入用户名 (至少 3 位)' placeholder={t('usernamePlaceholder')}
/> />
{errors.username && ( {errors.username && (
<p className='text-xs text-destructive'>{errors.username}</p> <p className='text-xs text-destructive'>{errors.username}</p>
@@ -116,19 +116,19 @@ export function CreateUserModal({
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label> <Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input <Input
id='nickname' id='nickname'
value={form.nickname} value={form.nickname}
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value })) setForm((prev) => ({ ...prev, nickname: e.target.value }))
} }
placeholder='请输入昵称' placeholder={t('nicknamePlaceholder')}
/> />
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label> <Label htmlFor='email'>{t('email')}</Label>
<Input <Input
id='email' id='email'
type='email' type='email'
@@ -136,7 +136,7 @@ export function CreateUserModal({
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value })) setForm((prev) => ({ ...prev, email: e.target.value }))
} }
placeholder='请输入邮箱地址' placeholder={t('emailPlaceholder')}
/> />
{errors.email && ( {errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p> <p className='text-xs text-destructive'>{errors.email}</p>
@@ -144,7 +144,7 @@ export function CreateUserModal({
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='password'>密码</Label> <Label htmlFor='password'>{t('password')}</Label>
<Input <Input
id='password' id='password'
type='password' type='password'
@@ -152,7 +152,7 @@ export function CreateUserModal({
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value })) setForm((prev) => ({ ...prev, password: e.target.value }))
} }
placeholder='请输入登录密码 (至少 8 位)' placeholder={t('passwordPlaceholder')}
/> />
{errors.password && ( {errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p> <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 className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div> <div>
<div className='font-medium text-sm'>启用账户</div> <div className='font-medium text-sm'>
{t('enableAccountLabel')}
</div>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
禁用后此账号将无法登录系统。 {t('enableAccountDesc')}
</div> </div>
</div> </div>
<Switch <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 className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div> <div>
<div className='font-medium text-sm'>管理员权限</div> <div className='font-medium text-sm'>{t('adminPermission')}</div>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
开启后此账号将拥有后台管理权限。 {t('adminPermissionDesc')}
</div> </div>
</div> </div>
<Switch <Switch
@@ -191,10 +193,10 @@ export function CreateUserModal({
<div className='flex justify-end gap-2 pt-2 border-t mt-2'> <div className='flex justify-end gap-2 pt-2 border-t mt-2'>
<Button variant='outline' type='button' onClick={onClose}> <Button variant='outline' type='button' onClick={onClose}>
取消 {t('cancel')}
</Button> </Button>
<Button type='submit' disabled={saving} variant='secondary'> <Button type='submit' disabled={saving} variant='secondary'>
{saving ? '创建中...' : '创建'} {saving ? t('creating') : t('create')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -43,6 +44,7 @@ export function EditUserModal({
onClose: () => void; onClose: () => void;
user: AdminUser | null; user: AdminUser | null;
}) { }) {
const t = useTranslations('admin.users');
const { updateUser, deleteUser, getUserDetail } = useAdminUsers(); const { updateUser, deleteUser, getUserDetail } = useAdminUsers();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
@@ -96,13 +98,13 @@ export function EditUserModal({
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!form.email.trim()) { if (!form.email.trim()) {
newErrors.email = '邮箱不能为空'; newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) { } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确'; newErrors.email = t('validation.emailInvalid');
} }
if (form.password && form.password.length < 8) { if (form.password && form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位'; newErrors.password = t('validation.passwordTooShort');
} }
setErrors(newErrors); setErrors(newErrors);
@@ -150,10 +152,8 @@ export function EditUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'> <DialogContent className='max-w-md'>
<DialogHeader> <DialogHeader>
<DialogTitle>编辑用户</DialogTitle> <DialogTitle>{t('editTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('editDesc')}</DialogDescription>
修改用户档案、授予/撤销管理权限或重置其密码。
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'> <form onSubmit={handleSave} className='space-y-4 pt-2'>
@@ -162,7 +162,7 @@ export function EditUserModal({
htmlFor='username' htmlFor='username'
className='text-xs text-muted-foreground' className='text-xs text-muted-foreground'
> >
用户名 (不可修改) {t('usernameImmutable')}
</Label> </Label>
<Input <Input
id='username' id='username'
@@ -173,19 +173,19 @@ export function EditUserModal({
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label> <Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input <Input
id='nickname' id='nickname'
value={form.nickname} value={form.nickname}
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value })) setForm((prev) => ({ ...prev, nickname: e.target.value }))
} }
placeholder='请输入昵称' placeholder={t('nicknamePlaceholder')}
/> />
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label> <Label htmlFor='email'>{t('email')}</Label>
<Input <Input
id='email' id='email'
type='email' type='email'
@@ -193,7 +193,7 @@ export function EditUserModal({
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value })) setForm((prev) => ({ ...prev, email: e.target.value }))
} }
placeholder='请输入邮箱地址' placeholder={t('emailPlaceholder')}
/> />
{errors.email && ( {errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p> <p className='text-xs text-destructive'>{errors.email}</p>
@@ -201,7 +201,7 @@ export function EditUserModal({
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='password'>重置密码 (选填)</Label> <Label htmlFor='password'>{t('resetPasswordOptional')}</Label>
<Input <Input
id='password' id='password'
type='password' type='password'
@@ -209,7 +209,7 @@ export function EditUserModal({
onChange={(e) => onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value })) setForm((prev) => ({ ...prev, password: e.target.value }))
} }
placeholder='留空表示保持当前密码,输入则重置 (至少 8 位)' placeholder={t('resetPasswordPlaceholder')}
/> />
{errors.password && ( {errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p> <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 className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div> <div>
<div className='font-medium text-sm'>管理员权限</div> <div className='font-medium text-sm'>{t('adminPermission')}</div>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
{isSelf {isSelf
? '不能撤销当前登录用户的管理员权限。' ? t('cannotRevokeSelfAdmin')
: '开启后此账号将拥有后台管理权限。'} : t('adminPermissionDesc')}
</div> </div>
</div> </div>
<Switch <Switch
@@ -255,7 +255,7 @@ export function EditUserModal({
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
disabled={saving} disabled={saving}
> >
删除用户 {t('deleteUser')}
</Button> </Button>
)} )}
</div> </div>
@@ -266,10 +266,10 @@ export function EditUserModal({
onClick={onClose} onClick={onClose}
disabled={saving} disabled={saving}
> >
取消 {t('cancel')}
</Button> </Button>
<Button type='submit' disabled={saving} variant='secondary'> <Button type='submit' disabled={saving} variant='secondary'>
{saving ? '保存中...' : '保存'} {saving ? t('saving') : t('save')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -280,14 +280,15 @@ export function EditUserModal({
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle> <AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确定要删除用户 {user.nickname || user.username}{' '} {t('deleteConfirmDesc', {
吗?该操作会移除用户账号,删除后无法撤销。 name: user.nickname || user.username,
})}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel> <AlertDialogCancel disabled={saving}>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -296,7 +297,7 @@ export function EditUserModal({
disabled={saving} disabled={saving}
> >
{saving && <Loader2 className='size-3 animate-spin mr-1' />} {saving && <Loader2 className='size-3 animate-spin mr-1' />}
确认删除 {t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { import {
Globe, Globe,
Loader2, Loader2,
@@ -33,13 +34,14 @@ export function UserDetailSheet({
detailLoading, detailLoading,
onStatusToggle, onStatusToggle,
}: UserDetailSheetProps) { }: UserDetailSheetProps) {
const t = useTranslations('admin.users');
const displayValue = (value?: string) => const displayValue = (value?: string) =>
value && value.trim() ? value : '-'; value && value.trim() ? value : '-';
return ( return (
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-[400px] w-full p-0 flex flex-col gap-0'> <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 && ( {selectedUser && (
<> <>
@@ -79,14 +81,14 @@ export function UserDetailSheet({
{detailLoading && ( {detailLoading && (
<div className='flex items-center gap-1 text-[10px] text-muted-foreground'> <div className='flex items-center gap-1 text-[10px] text-muted-foreground'>
<Loader2 className='size-3 animate-spin' /> <Loader2 className='size-3 animate-spin' />
正在刷新详情 {t('refreshingDetail')}
</div> </div>
)} )}
<div className='gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50'> <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'> <div className='flex flex-col gap-0.5'>
<span className='text-[9px] uppercase tracking-widest text-muted-foreground font-medium'> <span className='text-[9px] uppercase tracking-widest text-muted-foreground font-medium'>
注册时间 {t('registeredAt')}
</span> </span>
<span className='font-mono text-xs font-semibold'> <span className='font-mono text-xs font-semibold'>
{ {
@@ -103,13 +105,13 @@ export function UserDetailSheet({
<div className='p-6 space-y-6'> <div className='p-6 space-y-6'>
<div className='space-y-4'> <div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'> <h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
个人资料 {t('personalInfo')}
</h4> </h4>
<div className='rounded-lg border divide-y bg-background/50'> <div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'> <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'> <span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Mail className='size-3' /> <Mail className='size-3' />
邮箱 {t('email')}
</span> </span>
<span className='min-w-0 truncate text-right text-[10px]'> <span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.email)} {displayValue(selectedUser.email)}
@@ -118,7 +120,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'> <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'> <span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Smartphone className='size-3' /> <Smartphone className='size-3' />
手机 {t('phone')}
</span> </span>
<span className='min-w-0 truncate text-right text-[10px]'> <span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.phone)} {displayValue(selectedUser.phone)}
@@ -129,7 +131,7 @@ export function UserDetailSheet({
<span className='size-3 flex items-center justify-center font-bold text-[9px]'> <span className='size-3 flex items-center justify-center font-bold text-[9px]'>
⚧ ⚧
</span> </span>
性别 {t('gender')}
</span> </span>
<span className='min-w-0 truncate text-right text-[10px]'> <span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.gender)} {displayValue(selectedUser.gender)}
@@ -138,7 +140,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'> <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'> <span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<MapPin className='size-3' /> <MapPin className='size-3' />
所在地 {t('location')}
</span> </span>
<span className='min-w-0 truncate text-right text-[10px]'> <span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.location)} {displayValue(selectedUser.location)}
@@ -147,7 +149,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'> <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'> <span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Globe className='size-3' /> <Globe className='size-3' />
网站 {t('website')}
</span> </span>
<span className='min-w-0 truncate text-right text-[10px]'> <span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.website)} {displayValue(selectedUser.website)}
@@ -155,7 +157,7 @@ export function UserDetailSheet({
</div> </div>
<div className='flex flex-col gap-2 p-3.5 text-sm'> <div className='flex flex-col gap-2 p-3.5 text-sm'>
<span className='text-[10px] text-muted-foreground'> <span className='text-[10px] text-muted-foreground'>
简介 {t('bio')}
</span> </span>
<span className='break-words text-[10px] leading-5'> <span className='break-words text-[10px] leading-5'>
{displayValue(selectedUser.bio)} {displayValue(selectedUser.bio)}
@@ -166,40 +168,42 @@ export function UserDetailSheet({
<div className='space-y-4'> <div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'> <h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
系统记录 {t('systemRecords')}
</h4> </h4>
<div className='rounded-lg border divide-y bg-background/50'> <div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between p-3.5 text-sm'> <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 <Badge
variant={ variant={
selectedUser.is_active ? 'secondary' : 'outline' selectedUser.is_active ? 'secondary' : 'outline'
} }
className='text-[10px]' className='text-[10px]'
> >
{selectedUser.is_active ? '正常' : '禁用'} {selectedUser.is_active
? t('statusActive')
: t('statusDisabled')}
</Badge> </Badge>
</div> </div>
<div className='flex items-center justify-between p-3.5 text-sm'> <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]'> <span className='font-mono text-[10px]'>
{selectedUser.is_admin ? '是' : '否'} {selectedUser.is_admin ? t('yes') : t('no')}
</span> </span>
</div> </div>
<div className='flex items-center justify-between p-3.5 text-sm'> <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]'> <span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.last_login_at)} {formatDateTime(selectedUser.last_login_at)}
</span> </span>
</div> </div>
<div className='flex items-center justify-between p-3.5 text-sm'> <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]'> <span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.created_at)} {formatDateTime(selectedUser.created_at)}
</span> </span>
</div> </div>
<div className='flex items-center justify-between p-3.5 text-sm'> <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]'> <span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.updated_at)} {formatDateTime(selectedUser.updated_at)}
</span> </span>
@@ -219,12 +223,12 @@ export function UserDetailSheet({
{selectedUser.is_active ? ( {selectedUser.is_active ? (
<> <>
<ShieldCheck className='size-3 mr-1' /> <ShieldCheck className='size-3 mr-1' />
封禁账户 {t('banAccount')}
</> </>
) : ( ) : (
<> <>
<UserCheck className='size-3 mr-1' /> <UserCheck className='size-3 mr-1' />
解除封禁 {t('unbanAccount')}
</> </>
)} )}
</Button> </Button>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useTranslations } from 'next-intl';
import { import {
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
@@ -24,6 +25,7 @@ import { useAdminUsers } from '@/contexts/admin-users-context';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function UserFilterBar() { export function UserFilterBar() {
const t = useTranslations('admin.users');
const { const {
total, total,
loading, loading,
@@ -61,7 +63,7 @@ export function UserFilterBar() {
)} )}
> >
<Search className='size-3 mr-1' /> <Search className='size-3 mr-1' />
搜索 {t('search')}
{hasSearchFilter && ( {hasSearchFilter && (
<> <>
<Separator orientation='vertical' className='mx-1' /> <Separator orientation='vertical' className='mx-1' />
@@ -79,19 +81,19 @@ export function UserFilterBar() {
<div className='space-y-2.5'> <div className='space-y-2.5'>
<input <input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background' 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} value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)} onChange={(e) => setSearchUserId(e.target.value)}
/> />
<input <input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background' 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} value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)} onChange={(e) => setSearchUsername(e.target.value)}
/> />
<input <input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background' 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} value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)} onChange={(e) => setSearchEmail(e.target.value)}
/> />
@@ -106,7 +108,7 @@ export function UserFilterBar() {
setSearchEmail(''); setSearchEmail('');
}} }}
> >
清除 {t('clear')}
</Button> </Button>
)} )}
</div> </div>
@@ -124,7 +126,7 @@ export function UserFilterBar() {
)} )}
> >
<Filter className='size-3' /> <Filter className='size-3' />
状态 {t('status')}
{statusFilter !== 'all' && ( {statusFilter !== 'all' && (
<> <>
<Separator orientation='vertical' className='mx-1' /> <Separator orientation='vertical' className='mx-1' />
@@ -153,7 +155,7 @@ export function UserFilterBar() {
: 'opacity-50', : 'opacity-50',
)} )}
/> />
<span className='text-xs'>全部状态</span> <span className='text-xs'>{t('statusAll')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => { onSelect={(e) => {
@@ -169,7 +171,7 @@ export function UserFilterBar() {
: 'opacity-50', : 'opacity-50',
)} )}
/> />
<span className='text-xs'>正常</span> <span className='text-xs'>{t('statusActive')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => { onSelect={(e) => {
@@ -185,7 +187,7 @@ export function UserFilterBar() {
: 'opacity-50', : 'opacity-50',
)} )}
/> />
<span className='text-xs'>禁用</span> <span className='text-xs'>{t('statusDisabled')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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' className='h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground'
> >
<X className='size-3' /> <X className='size-3' />
清空筛选 {t('clearFilters')}
</Button> </Button>
</> </>
)} )}
@@ -215,7 +217,7 @@ export function UserFilterBar() {
<div className='flex items-center gap-1.5 self-end lg:self-auto'> <div className='flex items-center gap-1.5 self-end lg:self-auto'>
<span className='text-[10px] text-muted-foreground whitespace-nowrap'> <span className='text-[10px] text-muted-foreground whitespace-nowrap'>
{total} 条记录 {t('totalRecords', { total })}
</span> </span>
<div className='flex items-center border border-dashed rounded-md shadow-none'> <div className='flex items-center border border-dashed rounded-md shadow-none'>
<Button <Button
@@ -249,7 +251,7 @@ export function UserFilterBar() {
className='h-6 border-dashed text-[10px] px-2 font-mono shadow-none' className='h-6 border-dashed text-[10px] px-2 font-mono shadow-none'
disabled={loading} disabled={loading}
> >
{pageSize}条/页 {t('perPage', { count: pageSize })}
<ChevronDown className='size-3 opacity-50' /> <ChevronDown className='size-3 opacity-50' />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -263,7 +265,7 @@ export function UserFilterBar() {
pageSize === size && 'bg-accent', pageSize === size && 'bg-accent',
)} )}
> >
{size}条/页 {t('perPage', { count: size })}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -275,7 +277,7 @@ export function UserFilterBar() {
className='h-6 w-6 border-dashed shadow-none' className='h-6 w-6 border-dashed shadow-none'
onClick={() => fetchUsers(true)} onClick={() => fetchUsers(true)}
disabled={loading} disabled={loading}
title='刷新数据' title={t('refreshData')}
> >
<Loader2 className={cn('size-3', loading && 'animate-spin')} /> <Loader2 className={cn('size-3', loading && 'animate-spin')} />
</Button> </Button>
+19 -15
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { import {
@@ -33,6 +34,7 @@ import { UserDetailSheet } from './components/user-detail-sheet';
import { EditUserModal } from './components/edit-user-modal'; import { EditUserModal } from './components/edit-user-modal';
export default function UsersPage() { export default function UsersPage() {
const t = useTranslations('admin.users');
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } = const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
useAdminUsers(); useAdminUsers();
@@ -78,7 +80,9 @@ export default function UsersPage() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<UserRound className='size-5 text-primary' /> <UserRound className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>用户管理</h1> <h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div> </div>
</div> </div>
<Button <Button
@@ -88,7 +92,7 @@ export default function UsersPage() {
onClick={() => setCreateModalOpen(true)} onClick={() => setCreateModalOpen(true)}
> >
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
新增用户 {t('createUser')}
</Button> </Button>
</div> </div>
@@ -104,9 +108,9 @@ export default function UsersPage() {
/> />
</div> </div>
) : loading && users.length === 0 ? ( ) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' /> <LoadingStateWithBorder icon={Layers} description={t('loadingUsers')} />
) : users.length === 0 ? ( ) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' /> <EmptyStateWithBorder icon={UserX} description={t('noUsers')} />
) : ( ) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'> <div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
@@ -117,22 +121,22 @@ export default function UsersPage() {
ID ID
</TableHead> </TableHead>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'> <TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
用户 {t('colUser')}
</TableHead> </TableHead>
<TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'> <TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'>
邮箱 {t('colEmail')}
</TableHead> </TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'> <TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次登陆 {t('colLastLogin')}
</TableHead> </TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'> <TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
注册时间 {t('colRegisteredAt')}
</TableHead> </TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'> <TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次更新 {t('colLastUpdate')}
</TableHead> </TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'> <TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作 {t('colActions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -211,10 +215,10 @@ export default function UsersPage() {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='text-xs'> <TooltipContent side='top' className='text-xs'>
{user.is_admin {user.is_admin
? '管理员账户' ? t('adminAccount')
: user.is_active : user.is_active
? '禁用账户' ? t('disableAccount')
: '启用账户'} : t('enableAccount')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -230,7 +234,7 @@ export default function UsersPage() {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='text-xs'> <TooltipContent side='top' className='text-xs'>
查看详情 {t('viewDetail')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -246,7 +250,7 @@ export default function UsersPage() {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side='top' className='text-xs'> <TooltipContent side='top' className='text-xs'>
编辑用户 {t('editUser')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -19,7 +21,8 @@ interface LogDetailSheetProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
function getResultBadge(result: string) { function ResultBadge({ result }: { result: string }) {
const t = useTranslations('applyLogs');
if (result === 'success') { if (result === 'success') {
return ( return (
<Badge <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' 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' /> <span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
成功 {t('success')}
</Badge> </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' 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' /> <span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
警告 {t('warning')}
</Badge> </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' 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' /> <span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败 {t('failed')}
</Badge> </Badge>
); );
} }
@@ -58,58 +61,57 @@ export function LogDetailSheet({
open, open,
onOpenChange, onOpenChange,
}: LogDetailSheetProps) { }: LogDetailSheetProps) {
const t = useTranslations('applyLogs');
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-md w-full p-0 flex flex-col gap-0'> <SheetContent className='sm:max-w-md w-full p-0 flex flex-col gap-0'>
<SheetHeader className='px-5 py-4 border-b'> <SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>应用日志详情</SheetTitle> <SheetTitle>{t('sheetTitle')}</SheetTitle>
<SheetDescription> <SheetDescription>{t('sheetDesc')}</SheetDescription>
查看单条应用日志的完整结果与校验信息。
</SheetDescription>
</SheetHeader> </SheetHeader>
{log ? ( {log ? (
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-4'> <div className='flex-1 overflow-y-auto px-5 py-4 space-y-4'>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{getResultBadge(log.result)} <ResultBadge result={log.result} />
<Badge variant='outline' className='text-[10px] rounded-full'> <Badge variant='outline' className='text-[10px] rounded-full'>
Node: {log.node_id} Node: {log.node_id}
</Badge> </Badge>
<Badge variant='outline' className='text-[10px] rounded-full'> <Badge variant='outline' className='text-[10px] rounded-full'>
版本: {log.version} {t('versionBadge', { version: log.version })}
</Badge> </Badge>
</div> </div>
<div className='grid gap-3 text-xs'> <div className='grid gap-3 text-xs'>
<div> <div>
<p className='text-muted-foreground'>创建时间</p> <p className='text-muted-foreground'>{t('createdAt')}</p>
<p className='font-medium'>{formatDateTime(log.created_at)}</p> <p className='font-medium'>{formatDateTime(log.created_at)}</p>
</div> </div>
<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> <p className='font-mono break-all'>{log.checksum || '—'}</p>
</div> </div>
<div> <div>
<p className='text-muted-foreground'>主配置摘要</p> <p className='text-muted-foreground'>{t('mainChecksum')}</p>
<p className='font-mono break-all'> <p className='font-mono break-all'>
{log.main_config_checksum || '—'} {log.main_config_checksum || '—'}
</p> </p>
</div> </div>
<div> <div>
<p className='text-muted-foreground'>路由配置摘要</p> <p className='text-muted-foreground'>{t('routeChecksum')}</p>
<p className='font-mono break-all'> <p className='font-mono break-all'>
{log.route_config_checksum || '—'} {log.route_config_checksum || '—'}
</p> </p>
</div> </div>
<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> <p className='font-medium'>{log.support_file_count}</p>
</div> </div>
</div> </div>
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground mb-2'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground mb-2'>
消息 {t('message')}
</p> </p>
<pre className='text-xs whitespace-pre-wrap break-words'> <pre className='text-xs whitespace-pre-wrap break-words'>
{log.message || '—'} {log.message || '—'}
@@ -120,7 +122,7 @@ export function LogDetailSheet({
<SheetFooter className='px-5 py-4 border-t'> <SheetFooter className='px-5 py-4 border-t'>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
关闭 {t('close')}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
+71 -47
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react'; import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -49,7 +50,8 @@ function truncateHash(value: string) {
return value.length > 12 ? `${value.slice(0, 12)}...` : value; 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') { if (result === 'success') {
return ( return (
<Badge <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' 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' /> <span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
成功 {t('success')}
</Badge> </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' 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' /> <span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
警告 {t('warning')}
</Badge> </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' 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' /> <span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败 {t('failed')}
</Badge> </Badge>
); );
} }
export function ApplyLogsPageClient() { export function ApplyLogsPageClient() {
const t = useTranslations('applyLogs');
const tc = useTranslations('common');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const initialNodeId = searchParams.get('node_id')?.trim() ?? ''; const initialNodeId = searchParams.get('node_id')?.trim() ?? '';
@@ -108,12 +112,12 @@ export function ApplyLogsPageClient() {
const summary = useMemo(() => { const summary = useMemo(() => {
const nodeIds = new Set(rows.map((item) => item.node_id)); const nodeIds = new Set(rows.map((item) => item.node_id));
return [ return [
{ label: '总记录数', value: total }, { label: t('totalRecords'), value: total },
{ label: '当前页', value: current }, { label: t('currentPage'), value: current },
{ label: '总页数', value: totalPage }, { label: t('totalPages'), value: totalPage },
{ label: '当前页节点数', value: nodeIds.size }, { label: t('pageNodes'), value: nodeIds.size },
]; ];
}, [rows, total, current, totalPage]); }, [rows, total, current, totalPage, t]);
const fetchLogs = useCallback(async () => { const fetchLogs = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -129,11 +133,11 @@ export function ApplyLogsPageClient() {
setTotal(data.total); setTotal(data.total);
setTotalPage(data.totalPage); setTotalPage(data.totalPage);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败'); setError(err instanceof Error ? err.message : t('loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [nodeFilter, pageNo, pageSize]); }, [nodeFilter, pageNo, pageSize, t]);
useEffect(() => { useEffect(() => {
void fetchLogs(); void fetchLogs();
@@ -162,15 +166,16 @@ export function ApplyLogsPageClient() {
setCleaning(true); setCleaning(true);
try { try {
const result = await ApplyLogService.cleanup({ delete_all: true }); const result = await ApplyLogService.cleanup({ delete_all: true });
toast.success('应用日志已清空', { toast.success(t('cleared'), {
description: `共删除 ${result.deleted_count} 条记录`, description: t('clearedDesc', { count: result.deleted_count }),
}); });
setCleanupOpen(false); setCleanupOpen(false);
setPageNo(1); setPageNo(1);
await fetchLogs(); await fetchLogs();
} catch (err) { } catch (err) {
toast.error('清空应用日志失败', { toast.error(t('clearFailed'), {
description: err instanceof Error ? err.message : '未知错误', description:
err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setCleaning(false); setCleaning(false);
@@ -183,10 +188,10 @@ export function ApplyLogsPageClient() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<ClipboardList className='size-5 text-primary' /> <ClipboardList className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>应用日志</h1> <h1 className='text-2xl font-semibold tracking-tight'>
<p className='text-sm text-muted-foreground'> {t('title')}
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。 </h1>
</p> <p className='text-sm text-muted-foreground'>{t('description')}</p>
</div> </div>
</div> </div>
@@ -200,7 +205,7 @@ export function ApplyLogsPageClient() {
<RefreshCw <RefreshCw
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
/> />
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -209,10 +214,10 @@ export function ApplyLogsPageClient() {
disabled={loading || cleaning || total === 0} disabled={loading || cleaning || total === 0}
> >
<Trash2 className='size-3.5 mr-1' /> <Trash2 className='size-3.5 mr-1' />
清空日志 {t('clear')}
</Button> </Button>
<Button variant='outline' size='sm' asChild> <Button variant='outline' size='sm' asChild>
<Link href='/nodes'>返回节点</Link> <Link href='/nodes'>{t('backToNodes')}</Link>
</Button> </Button>
</div> </div>
</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='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='grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]'>
<div className='space-y-1.5'> <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'> <div className='relative'>
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' /> <Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
<Input <Input
@@ -243,14 +250,14 @@ export function ApplyLogsPageClient() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleSearch(); if (e.key === 'Enter') handleSearch();
}} }}
placeholder='输入 node_id 过滤应用日志' placeholder={t('nodePlaceholder')}
className='pl-8 h-9 text-xs' className='pl-8 h-9 text-xs'
/> />
</div> </div>
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'> <p className='text-xs font-medium text-muted-foreground'>
每页条数 {t('pageSize')}
</p> </p>
<Select <Select
value={String(pageSize)} value={String(pageSize)}
@@ -265,7 +272,7 @@ export function ApplyLogsPageClient() {
<SelectContent> <SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => ( {PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}> <SelectItem key={option} value={String(option)}>
{option} 条 {t('pageSizeOption', { count: option })}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -274,10 +281,10 @@ export function ApplyLogsPageClient() {
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Button size='sm' onClick={handleSearch}> <Button size='sm' onClick={handleSearch}>
筛选 {t('filter')}
</Button> </Button>
<Button variant='outline' size='sm' onClick={handleResetFilters}> <Button variant='outline' size='sm' onClick={handleResetFilters}>
重置筛选 {t('reset')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -291,23 +298,33 @@ export function ApplyLogsPageClient() {
<LoadingStateWithBorder /> <LoadingStateWithBorder />
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
title='暂无应用日志' title={t('emptyTitle')}
description='当前筛选条件下没有可展示的应用记录。' description={t('emptyDesc')}
/> />
) : ( ) : (
<Table> <Table>
<TableHeader className='bg-muted/40'> <TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'> <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'> <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>
<TableHead className='text-xs font-semibold'>时间</TableHead>
<TableHead className='text-xs font-semibold'>消息</TableHead>
<TableHead className='text-xs font-semibold text-right'> <TableHead className='text-xs font-semibold text-right'>
操作 {t('colActions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -323,7 +340,9 @@ export function ApplyLogsPageClient() {
<TableCell className='text-xs text-muted-foreground'> <TableCell className='text-xs text-muted-foreground'>
{log.version} {log.version}
</TableCell> </TableCell>
<TableCell>{getResultBadge(log.result)}</TableCell> <TableCell>
<ResultBadge result={log.result} />
</TableCell>
<TableCell <TableCell
className='text-xs font-mono text-muted-foreground' className='text-xs font-mono text-muted-foreground'
title={log.checksum} title={log.checksum}
@@ -349,7 +368,7 @@ export function ApplyLogsPageClient() {
}} }}
> >
<Eye className='size-3 mr-1' /> <Eye className='size-3 mr-1' />
详情 {t('detail')}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -362,7 +381,11 @@ export function ApplyLogsPageClient() {
{!loading && rows.length > 0 ? ( {!loading && rows.length > 0 ? (
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'> <div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。 {t('pageInfo', {
current,
total: Math.max(totalPage, 1),
count: total,
})}
</p> </p>
<div className='flex gap-2'> <div className='flex gap-2'>
<Button <Button
@@ -371,7 +394,7 @@ export function ApplyLogsPageClient() {
disabled={current <= 1} disabled={current <= 1}
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))} onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
> >
上一页 {t('prev')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -383,7 +406,7 @@ export function ApplyLogsPageClient() {
) )
} }
> >
下一页 {t('next')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -398,14 +421,15 @@ export function ApplyLogsPageClient() {
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}> <AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认清空应用日志?</AlertDialogTitle> <AlertDialogTitle>{t('clearTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
该操作将删除全部 {total}{' '} {t('clearDesc', { count: total })}
条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>取消</AlertDialogCancel> <AlertDialogCancel disabled={cleaning}>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90' className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
disabled={cleaning} disabled={cleaning}
@@ -414,7 +438,7 @@ export function ApplyLogsPageClient() {
void handleCleanupLogs(); void handleCleanupLogs();
}} }}
> >
{cleaning ? '清空中...' : '确认清空'} {cleaning ? t('clearing') : t('confirmClear')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </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 { CertificateEditorDialog } from '../websites/components/certificate-editor-dialog';
import { CertificateImportDialog } from '../websites/components/certificate-import-dialog'; import { CertificateImportDialog } from '../websites/components/certificate-import-dialog';
import { WebsiteStatusBadge } from '../websites/components/status-badge'; import { WebsiteStatusBadge } from '../websites/components/status-badge';
import { useTranslations } from 'next-intl';
import { import {
getCertificateStatus, getCertificateStatus,
getErrorMessage, getErrorMessage,
@@ -45,6 +47,8 @@ const certificatesQueryKey = ['openflare', 'tls-certificates'];
type CertificateApplyMode = 'edit-acme' | 'convert-upload'; type CertificateApplyMode = 'edit-acme' | 'convert-upload';
export default function CertificatesPage() { export default function CertificatesPage() {
const t = useTranslations('certificates');
const tc = useTranslations('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false); const [applyOpen, setApplyOpen] = useState(false);
@@ -68,20 +72,20 @@ export default function CertificatesPage() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.deleteById(id), mutationFn: (id: number) => TlsCertificateService.deleteById(id),
onSuccess: async () => { onSuccess: async () => {
toast.success('证书已删除'); toast.success(t('deleted'));
setDeleteTarget(null); setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey }); await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const renewMutation = useMutation({ const renewMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.renew(id), mutationFn: (id: number) => TlsCertificateService.renew(id),
onSuccess: async (cert) => { onSuccess: async (cert) => {
toast.success(`证书 ${cert.name} 续期任务已提交`); toast.success(t('renewSubmitted', { name: cert.name }));
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey }); await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const certificates = useMemo( 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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<FileKey className='size-5 text-primary' /> <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>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Button <Button
@@ -119,7 +123,7 @@ export default function CertificatesPage() {
} }
> >
<RefreshCw className='size-3.5 mr-1' /> <RefreshCw className='size-3.5 mr-1' />
刷新证书 {t('refresh')}
</Button> </Button>
<Button <Button
variant='secondary' variant='secondary'
@@ -127,7 +131,7 @@ export default function CertificatesPage() {
className='h-7 text-xs' className='h-7 text-xs'
onClick={() => setImportOpen(true)} onClick={() => setImportOpen(true)}
> >
导入证书 {t('importCert')}
</Button> </Button>
<Button <Button
size='sm' size='sm'
@@ -135,28 +139,29 @@ export default function CertificatesPage() {
onClick={() => setApplyOpen(true)} onClick={() => setApplyOpen(true)}
> >
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
申请证书 {t('applyTitle')}
</Button> </Button>
</div> </div>
</div> </div>
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<CardTitle className='text-base font-semibold'>证书列表</CardTitle> <CardTitle className='text-base font-semibold'>{t('listTitle')}</CardTitle>
<CardDescription> <CardDescription>{t('listDesc')}</CardDescription>
展示证书有效期、备注和状态,支持查看详情、编辑内容或删除证书。
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{certificatesQuery.isLoading ? ( {certificatesQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={FileKey} icon={FileKey}
description='加载证书列表中...' description={t('loadingList')}
/> />
) : certificatesQuery.isError ? ( ) : certificatesQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'> <div className='p-8 border border-dashed rounded-lg'>
<ErrorInline <ErrorInline
message={getErrorMessage(certificatesQuery.error)} message={getErrorMessage(
certificatesQuery.error,
t('requestFailed'),
)}
onRetry={() => void certificatesQuery.refetch()} onRetry={() => void certificatesQuery.refetch()}
className='justify-center' className='justify-center'
/> />
@@ -164,12 +169,12 @@ export default function CertificatesPage() {
) : certificates.length === 0 ? ( ) : certificates.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={FileKey} icon={FileKey}
description='暂无证书,点击右上角「导入证书」或「申请证书」开始录入。' description={t('emptyList')}
/> />
) : ( ) : (
<div className='space-y-3'> <div className='space-y-3'>
{certificates.map((certificate) => { {certificates.map((certificate) => {
const status = getCertificateStatus(certificate); const status = getCertificateStatus(certificate, t);
return ( return (
<div <div
@@ -188,32 +193,50 @@ export default function CertificatesPage() {
/> />
</div> </div>
<div className='text-xs leading-5 text-muted-foreground space-y-0.5'> <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> <p>
来源: {t('notBeforeLabel', {
{certificate.provider === 'acme' value: formatDateTime(certificate.not_before),
? 'ACME 申请' })}
: '手动上传'} </p>
<p>
{t('notAfterLabel', {
value: formatDateTime(certificate.not_after),
})}
</p>
<p>
{t('sourceLabel', {
value:
certificate.provider === 'acme'
? t('sourceAcme')
: t('sourceUpload'),
})}
</p> </p>
{certificate.apply_status === 'applying' ? ( {certificate.apply_status === 'applying' ? (
<p className='text-blue-600'> <p className='text-blue-600'>
状态: {t('statusLabel', {
{certificate.provider === 'upload' value:
? '转换申请中...' certificate.provider === 'upload'
: '申请中...'} ? t('converting')
: t('applying'),
})}
</p> </p>
) : null} ) : null}
{certificate.apply_status === 'error' ? ( {certificate.apply_status === 'error' ? (
<p className='text-destructive'> <p className='text-destructive'>
状态: {t('statusError', {
{certificate.provider === 'upload' value:
? '转换失败' certificate.provider === 'upload'
: '申请失败'} ? t('convertFailed')
({certificate.apply_message}) : t('applyFailed'),
message: certificate.apply_message,
})}
</p> </p>
) : null} ) : null}
<p>备注:{certificate.remark || '暂无备注'}</p> <p>
{t('remarkLabel', {
value: certificate.remark || t('noRemark'),
})}
</p>
</div> </div>
</div> </div>
@@ -227,7 +250,7 @@ export default function CertificatesPage() {
setDetailOpen(true); setDetailOpen(true);
}} }}
> >
查看 {t('view')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -235,7 +258,7 @@ export default function CertificatesPage() {
className='h-7 text-xs' className='h-7 text-xs'
onClick={() => handleOpenEditor(certificate)} onClick={() => handleOpenEditor(certificate)}
> >
编辑 {t('edit')}
</Button> </Button>
{certificate.provider === 'acme' ? ( {certificate.provider === 'acme' ? (
<Button <Button
@@ -245,7 +268,7 @@ export default function CertificatesPage() {
disabled={renewMutation.isPending} disabled={renewMutation.isPending}
onClick={() => renewMutation.mutate(certificate.id)} onClick={() => renewMutation.mutate(certificate.id)}
> >
续期 {t('renew')}
</Button> </Button>
) : null} ) : null}
<Button <Button
@@ -270,7 +293,7 @@ export default function CertificatesPage() {
open={importOpen} open={importOpen}
onOpenChange={setImportOpen} onOpenChange={setImportOpen}
onImported={(certificate) => onImported={(certificate) =>
toast.success(`证书 ${certificate.name} 已导入`) toast.success(t('imported', { name: certificate.name }))
} }
/> />
@@ -278,7 +301,7 @@ export default function CertificatesPage() {
open={applyOpen && !applyCertificate} open={applyOpen && !applyCertificate}
onOpenChange={setApplyOpen} onOpenChange={setApplyOpen}
onApplied={(certificate) => onApplied={(certificate) =>
toast.success(`证书 ${certificate.name} 申请任务已提交`) toast.success(t('applySubmitted', { name: certificate.name }))
} }
/> />
@@ -295,8 +318,8 @@ export default function CertificatesPage() {
setApplyCertificate(null); setApplyCertificate(null);
toast.success( toast.success(
applyMode === 'convert-upload' applyMode === 'convert-upload'
? `证书 ${certificate.name} 转换申请已提交` ? t('convertSubmitted', { name: certificate.name })
: `证书 ${certificate.name} 配置已更新,重新申请中...`, : t('reapplySubmitted', { name: certificate.name }),
); );
}} }}
/> />
@@ -326,7 +349,7 @@ export default function CertificatesPage() {
open={editorOpen} open={editorOpen}
onOpenChange={setEditorOpen} onOpenChange={setEditorOpen}
onSaved={(certificate) => onSaved={(certificate) =>
toast.success(`证书 ${certificate.name} 已更新`) toast.success(t('updated', { name: certificate.name }))
} }
onConvert={(certificate) => { onConvert={(certificate) => {
setEditorOpen(false); setEditorOpen(false);
@@ -342,20 +365,20 @@ export default function CertificatesPage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除证书</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除证书 {deleteTarget?.name} 吗? {t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90' className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id) deleteTarget && deleteMutation.mutate(deleteTarget.id)
} }
> >
删除 {tc('delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -43,6 +44,8 @@ export function GroupDialog({
pending: boolean; pending: boolean;
onSubmit: (payload: CloudflareGroupPayload) => void; onSubmit: (payload: CloudflareGroupPayload) => void;
}) { }) {
const t = useTranslations('cloudflare.groupDialog');
const tCommon = useTranslations('common');
const edgeNodes = useMemo( const edgeNodes = useMemo(
() => nodes.filter((node) => node.node_type === 'edge_node'), () => nodes.filter((node) => node.node_type === 'edge_node'),
[nodes], [nodes],
@@ -66,14 +69,14 @@ export function GroupDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{group ? '编辑指向分组' : '新增指向分组'}</DialogTitle> <DialogTitle>
<DialogDescription> {group ? t('editTitle') : t('createTitle')}
一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。 </DialogTitle>
</DialogDescription> <DialogDescription>{t('description')}</DialogDescription>
</DialogHeader> </DialogHeader>
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel htmlFor='cf-group-name'>分组名称</FieldLabel> <FieldLabel htmlFor='cf-group-name'>{t('name')}</FieldLabel>
<Input <Input
id='cf-group-name' id='cf-group-name'
value={name} value={name}
@@ -81,10 +84,10 @@ export function GroupDialog({
/> />
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor='cf-primary-node'>主节点</FieldLabel> <FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}> <Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
<SelectTrigger id='cf-primary-node' className='w-full'> <SelectTrigger id='cf-primary-node' className='w-full'>
<SelectValue placeholder='选择带 IPv4 的边缘节点' /> <SelectValue placeholder={t('primaryPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -94,7 +97,7 @@ export function GroupDialog({
value={String(node.id)} value={String(node.id)}
disabled={!node.ip} disabled={!node.ip}
> >
{node.name} · {node.ip || '未配置 IP'} {node.name} · {node.ip || t('noIp')}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -102,19 +105,19 @@ export function GroupDialog({
</Select> </Select>
</Field> </Field>
<Field> <Field>
<FieldLabel htmlFor='cf-backup-node'>备用节点</FieldLabel> <FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
<Select value={backupNodeID} onValueChange={setBackupNodeID}> <Select value={backupNodeID} onValueChange={setBackupNodeID}>
<SelectTrigger id='cf-backup-node' className='w-full'> <SelectTrigger id='cf-backup-node' className='w-full'>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value='none'>不设置</SelectItem> <SelectItem value='none'>{t('none')}</SelectItem>
{edgeNodes {edgeNodes
.filter((node) => String(node.id) !== primaryNodeID) .filter((node) => String(node.id) !== primaryNodeID)
.map((node) => ( .map((node) => (
<SelectItem key={node.id} value={String(node.id)}> <SelectItem key={node.id} value={String(node.id)}>
{node.name} · {node.ip || '未配置 IP'} {node.name} · {node.ip || t('noIp')}
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -123,7 +126,7 @@ export function GroupDialog({
</Field> </Field>
<Field orientation='horizontal'> <Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'> <FieldLabel htmlFor='cf-default-proxied'>
新成员默认开启橙云 {t('defaultProxied')}
</FieldLabel> </FieldLabel>
<Switch <Switch
id='cf-default-proxied' id='cf-default-proxied'
@@ -132,7 +135,7 @@ export function GroupDialog({
/> />
</Field> </Field>
<Field orientation='horizontal'> <Field orientation='horizontal'>
<FieldLabel htmlFor='cf-enabled'>启用同步</FieldLabel> <FieldLabel htmlFor='cf-enabled'>{t('enableSync')}</FieldLabel>
<Switch <Switch
id='cf-enabled' id='cf-enabled'
checked={enabled} checked={enabled}
@@ -142,7 +145,7 @@ export function GroupDialog({
</FieldGroup> </FieldGroup>
<DialogFooter> <DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
取消 {tCommon('cancel')}
</Button> </Button>
<Button <Button
disabled={pending || !name.trim() || !primaryNodeID} disabled={pending || !name.trim() || !primaryNodeID}
@@ -157,7 +160,7 @@ export function GroupDialog({
}) })
} }
> >
保存 {t('save')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Search } from 'lucide-react'; import { Check, ChevronDown, Search } from 'lucide-react';
@@ -72,6 +73,8 @@ export function MemberAddDialog({
pending: boolean; pending: boolean;
onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void; onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void;
}) { }) {
const t = useTranslations('cloudflare.memberDialog');
const tCommon = useTranslations('common');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]); const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
const [proxied, setProxied] = useState(defaultProxied); const [proxied, setProxied] = useState(defaultProxied);
@@ -157,15 +160,14 @@ export function MemberAddDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'> <DialogContent className='sm:max-w-lg'>
<DialogHeader> <DialogHeader>
<DialogTitle>添加域名成员</DialogTitle> <DialogTitle>{t('title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('description')}</DialogDescription>
支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名
A 会拒绝同步。
</DialogDescription>
</DialogHeader> </DialogHeader>
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel htmlFor='cf-domain-search'>Zone 域名</FieldLabel> <FieldLabel htmlFor='cf-domain-search'>
{t('zoneDomain')}
</FieldLabel>
<div className='space-y-2'> <div className='space-y-2'>
<div className='relative'> <div className='relative'>
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' /> <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' id='cf-domain-search'
value={keyword} value={keyword}
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
placeholder='搜索域名或顶级域…' placeholder={t('searchPlaceholder')}
className='pl-8' className='pl-8'
disabled={pending} disabled={pending}
/> />
@@ -182,9 +184,9 @@ export function MemberAddDialog({
<div className='flex flex-wrap items-center justify-between gap-2'> <div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'> <div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Badge variant='secondary' className='font-normal'> <Badge variant='secondary' className='font-normal'>
已选 {selectedIDs.length} {t('selected', { count: selectedIDs.length })}
</Badge> </Badge>
<span>可见 {visibleIDs.length}</span> <span>{t('visible', { count: visibleIDs.length })}</span>
</div> </div>
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
<Button <Button
@@ -195,7 +197,7 @@ export function MemberAddDialog({
disabled={pending || visibleIDs.length === 0} disabled={pending || visibleIDs.length === 0}
onClick={toggleAllVisible} onClick={toggleAllVisible}
> >
{allVisibleSelected ? '取消全选' : '全选可见'} {allVisibleSelected ? t('unselectAll') : t('selectVisible')}
</Button> </Button>
<Button <Button
type='button' type='button'
@@ -205,18 +207,18 @@ export function MemberAddDialog({
disabled={pending || selectedIDs.length === 0} disabled={pending || selectedIDs.length === 0}
onClick={() => setSelectedIDs([])} onClick={() => setSelectedIDs([])}
> >
清空 {t('clear')}
</Button> </Button>
</div> </div>
</div> </div>
{domains.length === 0 ? ( {domains.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'> <div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
暂无可用域名(均已加入分组,或尚未在网站管理中添加)。 {t('noAvailable')}
</div> </div>
) : filteredGroups.length === 0 ? ( ) : filteredGroups.length === 0 ? (
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'> <div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
没有匹配的域名 {t('noMatch')}
</div> </div>
) : ( ) : (
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'> <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} disabled={pending}
onCheckedChange={toggleAllVisible} onCheckedChange={toggleAllVisible}
/> />
<span>按顶级域分组</span> <span>{t('groupByZone')}</span>
</label> </label>
{filteredGroups.map((group) => { {filteredGroups.map((group) => {
@@ -270,7 +272,9 @@ export function MemberAddDialog({
} }
disabled={pending} disabled={pending}
onCheckedChange={() => toggleGroup(group)} onCheckedChange={() => toggleGroup(group)}
aria-label={`选择顶级域 ${group.zoneDomain}`} aria-label={t('selectZone', {
name: group.zoneDomain,
})}
className='ml-1' className='ml-1'
/> />
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
@@ -330,7 +334,7 @@ export function MemberAddDialog({
variant='secondary' variant='secondary'
className='shrink-0 text-[10px] font-normal' className='shrink-0 text-[10px] font-normal'
> >
顶级域 {t('zone')}
</Badge> </Badge>
) : null} ) : null}
{checked ? ( {checked ? (
@@ -350,7 +354,7 @@ export function MemberAddDialog({
</div> </div>
</Field> </Field>
<Field orientation='horizontal'> <Field orientation='horizontal'>
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel> <FieldLabel htmlFor='cf-member-proxied'>{t('proxied')}</FieldLabel>
<Switch <Switch
id='cf-member-proxied' id='cf-member-proxied'
checked={proxied} checked={proxied}
@@ -365,7 +369,7 @@ export function MemberAddDialog({
disabled={pending} disabled={pending}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
取消 {tCommon('cancel')}
</Button> </Button>
<Button <Button
disabled={pending || selectedIDs.length === 0} disabled={pending || selectedIDs.length === 0}
@@ -377,8 +381,8 @@ export function MemberAddDialog({
} }
> >
{selectedIDs.length > 1 {selectedIDs.length > 1
? `添加并同步(${selectedIDs.length})` ? t('submitCount', { count: selectedIDs.length })
: '添加并同步'} : t('submit')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -9,6 +9,7 @@ import {
RefreshCw, RefreshCw,
RotateCcw, RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { toast } from 'sonner'; 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) { function formatDateTime(value?: string | null) {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
@@ -143,6 +130,7 @@ function statusVariant(status: TaskExecutionStatus) {
} }
export function SyncTasksPanel() { export function SyncTasksPanel() {
const t = useTranslations('cloudflare.tasks');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all'); const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all');
@@ -175,16 +163,16 @@ export function SyncTasksPanel() {
const retryMutation = useMutation({ const retryMutation = useMutation({
mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id), mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id),
onSuccess: (taskID) => { onSuccess: (taskID) => {
toast.success('同步任务已重新下发', { toast.success(t('retried'), {
description: `新任务 ID:${taskID}`, description: t('newTaskId', { id: taskID }),
}); });
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'], queryKey: [...cloudflareQueryKey, 'sync-executions'],
}); });
}, },
onError: (err: Error) => { onError: (err: Error) => {
toast.error('任务重试失败', { toast.error(t('retryFailed'), {
description: err.message || '未知错误', description: err.message || t('unknownError'),
}); });
}, },
}); });
@@ -197,6 +185,18 @@ export function SyncTasksPanel() {
() => Math.max(1, Math.ceil(total / PAGE_SIZE)), () => Math.max(1, Math.ceil(total / PAGE_SIZE)),
[total], [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) => { const openDetail = (execution: TaskExecution) => {
setPreview(execution); setPreview(execution);
@@ -210,12 +210,9 @@ export function SyncTasksPanel() {
<div className='space-y-1'> <div className='space-y-1'>
<CardTitle className='flex items-center gap-2 text-base'> <CardTitle className='flex items-center gap-2 text-base'>
<Activity className='size-4 text-primary' /> <Activity className='size-4 text-primary' />
同步任务 {t('title')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('description')}</CardDescription>
仅展示 Cloudflare
域名同步(sync_member)与分组同步(sync_group)的执行记录。
</CardDescription>
</div> </div>
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<Select <Select
@@ -226,14 +223,14 @@ export function SyncTasksPanel() {
}} }}
> >
<SelectTrigger size='sm' className='w-[120px]'> <SelectTrigger size='sm' className='w-[120px]'>
<SelectValue placeholder='状态' /> <SelectValue placeholder={t('statusPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='all'>全部状态</SelectItem> <SelectItem value='all'>{t('allStatus')}</SelectItem>
<SelectItem value='pending'>等待中</SelectItem> <SelectItem value='pending'>{t('pending')}</SelectItem>
<SelectItem value='running'>执行中</SelectItem> <SelectItem value='running'>{t('running')}</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem> <SelectItem value='succeeded'>{t('succeeded')}</SelectItem>
<SelectItem value='failed'>失败</SelectItem> <SelectItem value='failed'>{t('failed')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
@@ -247,7 +244,7 @@ export function SyncTasksPanel() {
) : ( ) : (
<RefreshCw data-icon='inline-start' /> <RefreshCw data-icon='inline-start' />
)} )}
刷新 {t('refresh')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -257,32 +254,44 @@ export function SyncTasksPanel() {
message={ message={
executionsQuery.error instanceof Error executionsQuery.error instanceof Error
? executionsQuery.error.message ? executionsQuery.error.message
: '加载同步任务失败' : t('loadFailed')
} }
onRetry={() => void executionsQuery.refetch()} onRetry={() => void executionsQuery.refetch()}
/> />
) : loading && executions.length === 0 ? ( ) : loading && executions.length === 0 ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Activity} icon={Activity}
description='加载同步任务中...' description={t('loading')}
/> />
) : executions.length === 0 ? ( ) : executions.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Activity} icon={Activity}
title='暂无同步任务' title={t('emptyTitle')}
description='添加域名或同步分组后,这里会显示 Cloudflare 域名 / 分组同步执行记录。' description={t('emptyDesc')}
/> />
) : ( ) : (
<div className='rounded-lg border'> <div className='rounded-lg border'>
<Table className='min-w-[720px]'> <Table className='min-w-[720px]'>
<TableHeader> <TableHeader>
<TableRow className='hover:bg-transparent'> <TableRow className='hover:bg-transparent'>
<TableHead className='w-[200px]'>任务</TableHead> <TableHead className='w-[200px]'>
<TableHead className='w-[100px]'>状态</TableHead> {t('columns.task')}
<TableHead className='w-[100px]'>触发</TableHead> </TableHead>
<TableHead className='w-[100px]'>耗时</TableHead> <TableHead className='w-[100px]'>
<TableHead className='min-w-[180px]'>结果/错误</TableHead> {t('columns.status')}
<TableHead className='w-[160px]'>创建时间</TableHead> </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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -304,12 +313,12 @@ export function SyncTasksPanel() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={statusVariant(execution.status)}> <Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status} {statusLabels[execution.status] || execution.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant='outline'> <Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] || {triggerLabels[execution.triggered_by] ||
execution.triggered_by} execution.triggered_by}
</Badge> </Badge>
</TableCell> </TableCell>
@@ -331,7 +340,7 @@ export function SyncTasksPanel() {
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
共 {total} 条,第 {page}/{totalPages} 页 {t('pageSummary', { total, page, pages: totalPages })}
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button <Button
@@ -341,7 +350,7 @@ export function SyncTasksPanel() {
disabled={page <= 1 || loading} disabled={page <= 1 || loading}
> >
<ChevronLeft className='size-4' /> <ChevronLeft className='size-4' />
上一页 {t('prev')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -349,7 +358,7 @@ export function SyncTasksPanel() {
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages || loading} disabled={page >= totalPages || loading}
> >
下一页 {t('next')}
<ChevronRight className='size-4' /> <ChevronRight className='size-4' />
</Button> </Button>
</div> </div>
@@ -359,43 +368,51 @@ export function SyncTasksPanel() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}> <Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[560px]'> <SheetContent className='w-full p-0 sm:max-w-[560px]'>
<SheetHeader className='border-b'> <SheetHeader className='border-b'>
<SheetTitle>同步任务详情</SheetTitle> <SheetTitle>{t('detailTitle')}</SheetTitle>
<SheetDescription> <SheetDescription>
{selected?.task_name || selected?.task_type || 'Cloudflare 同步'} {selected?.task_name || selected?.task_type || t('defaultName')}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'> <div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{detailQuery.isFetching && !selected ? ( {detailQuery.isFetching && !selected ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Activity} icon={Activity}
description='加载任务详情中...' description={t('loadingDetail')}
/> />
) : selected ? ( ) : selected ? (
<> <>
<div className='grid grid-cols-2 gap-3'> <div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-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'> <div className='mt-2'>
<Badge variant={statusVariant(selected.status)}> <Badge variant={statusVariant(selected.status)}>
{STATUS_LABELS[selected.status] || selected.status} {statusLabels[selected.status] || selected.status}
</Badge> </Badge>
</div> </div>
</div> </div>
<div className='rounded-lg border p-3'> <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'> <div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selected.triggered_by] || {triggerLabels[selected.triggered_by] ||
selected.triggered_by} selected.triggered_by}
</div> </div>
</div> </div>
<div className='rounded-lg border p-3'> <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'> <div className='mt-2 font-mono text-sm'>
{selected.retry_count}/{selected.max_retry} {selected.retry_count}/{selected.max_retry}
</div> </div>
</div> </div>
<div className='rounded-lg border p-3'> <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'> <div className='mt-2 font-mono text-sm'>
{formatDuration(selected.duration)} {formatDuration(selected.duration)}
</div> </div>
@@ -403,7 +420,9 @@ export function SyncTasksPanel() {
</div> </div>
<div className='space-y-1'> <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'> <div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selected.task_id} {selected.task_id}
</div> </div>
@@ -412,7 +431,7 @@ export function SyncTasksPanel() {
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'> <div className='space-y-1'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
创建时间 {t('createdAt')}
</div> </div>
<div className='font-mono text-xs'> <div className='font-mono text-xs'>
{formatDateTime(selected.created_at)} {formatDateTime(selected.created_at)}
@@ -420,7 +439,7 @@ export function SyncTasksPanel() {
</div> </div>
<div className='space-y-1'> <div className='space-y-1'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
结束时间 {t('finishedAt')}
</div> </div>
<div className='font-mono text-xs'> <div className='font-mono text-xs'>
{formatDateTime(selected.finished_at)} {formatDateTime(selected.finished_at)}
@@ -429,7 +448,9 @@ export function SyncTasksPanel() {
</div> </div>
<div className='space-y-1'> <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'> <div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selected.result || '-'} {selected.result || '-'}
</div> </div>
@@ -438,7 +459,7 @@ export function SyncTasksPanel() {
{selected.error_message ? ( {selected.error_message ? (
<div className='space-y-1'> <div className='space-y-1'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
错误信息 {t('error')}
</div> </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'> <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} {selected.error_message}
@@ -456,7 +477,7 @@ export function SyncTasksPanel() {
{selected.log ? ( {selected.log ? (
<div className='space-y-1'> <div className='space-y-1'>
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
执行日志 {t('logs')}
</div> </div>
<pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'> <pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.log} {selected.log}
@@ -478,11 +499,11 @@ export function SyncTasksPanel() {
) : ( ) : (
<RotateCcw data-icon='inline-start' /> <RotateCcw data-icon='inline-start' />
)} )}
重试 {t('retry')}
</Button> </Button>
) : null} ) : null}
<Button variant='outline' onClick={() => setDetailOpen(false)}> <Button variant='outline' onClick={() => setDetailOpen(false)}>
关闭 {t('close')}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -11,6 +11,7 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -46,6 +47,7 @@ import { GroupDialog } from '../../components/group-dialog';
import { MemberAddDialog } from '../../components/member-add-dialog'; import { MemberAddDialog } from '../../components/member-add-dialog';
export function CloudflareGroupDetailPageClient() { export function CloudflareGroupDetailPageClient() {
const t = useTranslations('cloudflare');
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const groupID = Number(params.id); const groupID = Number(params.id);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -72,7 +74,7 @@ export function CloudflareGroupDetailPageClient() {
mutationFn: (payload: CloudflareGroupPayload) => mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.updateGroup(groupID, payload), CloudflareService.updateGroup(groupID, payload),
onSuccess: async () => { onSuccess: async () => {
toast.success('分组配置已更新'); toast.success(t('groupUpdated'));
setEditOpen(false); setEditOpen(false);
await invalidate(); await invalidate();
}, },
@@ -102,13 +104,13 @@ export function CloudflareGroupDetailPageClient() {
if (failed === 0) { if (failed === 0) {
toast.success( toast.success(
total === 1 total === 1
? '域名已加入并排队同步' ? t('memberAddedOne')
: `已添加 ${succeeded} 个域名并排队同步`, : t('memberAddedMany', { count: succeeded }),
); );
} else if (succeeded === 0) { } else if (succeeded === 0) {
toast.error(`添加失败:${failed} 个域名未能加入`); toast.error(t('memberAddFailed', { count: failed }));
} else { } else {
toast.warning(`部分成功:${succeeded} 个已加入,${failed} 个失败`); toast.warning(t('memberPartial', { succeeded, failed }));
} }
setAddOpen(false); setAddOpen(false);
await invalidate(); await invalidate();
@@ -124,7 +126,7 @@ export function CloudflareGroupDetailPageClient() {
proxied: boolean; proxied: boolean;
}) => CloudflareService.updateMember(groupID, memberID, proxied), }) => CloudflareService.updateMember(groupID, memberID, proxied),
onSuccess: async () => { onSuccess: async () => {
toast.success('橙云设置已更新并排队同步'); toast.success(t('proxyUpdated'));
await invalidate(); await invalidate();
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error)),
@@ -132,14 +134,14 @@ export function CloudflareGroupDetailPageClient() {
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: (memberID: number) => mutationFn: (memberID: number) =>
CloudflareService.syncMember(groupID, memberID), CloudflareService.syncMember(groupID, memberID),
onSuccess: () => toast.success('成员同步任务已入队'), onSuccess: () => toast.success(t('memberSyncQueued')),
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error)),
}); });
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (memberID: number) => mutationFn: (memberID: number) =>
CloudflareService.removeMember(groupID, memberID), CloudflareService.removeMember(groupID, memberID),
onSuccess: async () => { onSuccess: async () => {
toast.success('成员及远端 A 记录已删除'); toast.success(t('memberDeleted'));
await invalidate(); await invalidate();
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error)),
@@ -148,7 +150,10 @@ export function CloudflareGroupDetailPageClient() {
if (detailQuery.isLoading) if (detailQuery.isLoading)
return ( return (
<div className='w-full py-6 px-1'> <div className='w-full py-6 px-1'>
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' /> <LoadingStateWithBorder
icon={Cloud}
description={t('loadingDetail')}
/>
</div> </div>
); );
if (detailQuery.isError || !detailQuery.data) if (detailQuery.isError || !detailQuery.data)
@@ -168,7 +173,7 @@ export function CloudflareGroupDetailPageClient() {
<Button variant='ghost' size='sm' className='self-start' asChild> <Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'> <Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' /> <ArrowLeft data-icon='inline-start' />
返回列表 {t('back')}
</Link> </Link>
</Button> </Button>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
@@ -190,7 +195,7 @@ export function CloudflareGroupDetailPageClient() {
) : ( ) : (
<RefreshCw data-icon='inline-start' /> <RefreshCw data-icon='inline-start' />
)} )}
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -198,11 +203,11 @@ export function CloudflareGroupDetailPageClient() {
onClick={() => setEditOpen(true)} onClick={() => setEditOpen(true)}
> >
<Settings data-icon='inline-start' /> <Settings data-icon='inline-start' />
编辑 {t('edit')}
</Button> </Button>
<Button size='sm' onClick={() => setAddOpen(true)}> <Button size='sm' onClick={() => setAddOpen(true)}>
<Plus data-icon='inline-start' /> <Plus data-icon='inline-start' />
添加域名 {t('addDomain')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -210,41 +215,50 @@ export function CloudflareGroupDetailPageClient() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base'>当前指向</CardTitle> <CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
<CardDescription> <CardDescription>
生效节点 {group.active_node.name} · {group.active_node.ip} {t('activeNode', {
name: group.active_node.name,
ip: group.active_node.ip,
})}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className='flex flex-wrap gap-2'> <CardContent className='flex flex-wrap gap-2'>
<Badge variant={group.enabled ? 'default' : 'secondary'}> <Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '同步已启用' : '同步已停用'} {group.enabled ? t('syncEnabled') : t('syncDisabled')}
</Badge> </Badge>
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
<Badge variant='outline'> <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> </Badge>
</CardContent> </CardContent>
</Card> </Card>
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base'>域名成员</CardTitle> <CardTitle className='text-base'>{t('members')}</CardTitle>
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription> <CardDescription>{t('membersDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{members.length === 0 ? ( {members.length === 0 ? (
<p className='py-8 text-center text-sm text-muted-foreground'> <p className='py-8 text-center text-sm text-muted-foreground'>
暂无域名成员。 {t('noMembers')}
</p> </p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>域名</TableHead> <TableHead>{t('columns.domain')}</TableHead>
<TableHead>期望 IP</TableHead> <TableHead>{t('columns.desiredIp')}</TableHead>
<TableHead>状态</TableHead> <TableHead>{t('columns.status')}</TableHead>
<TableHead>橙云</TableHead> <TableHead>{t('columns.proxied')}</TableHead>
<TableHead className='text-right'>操作</TableHead> <TableHead className='text-right'>
{t('columns.actions')}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -258,7 +272,9 @@ export function CloudflareGroupDetailPageClient() {
</p> </p>
) : null} ) : null}
</TableCell> </TableCell>
<TableCell>{member.desired_ip || '待同步'}</TableCell> <TableCell>
{member.desired_ip || t('pendingSync')}
</TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
@@ -291,7 +307,7 @@ export function CloudflareGroupDetailPageClient() {
onClick={() => syncMutation.mutate(member.id)} onClick={() => syncMutation.mutate(member.id)}
> >
<RefreshCw data-icon='inline-start' /> <RefreshCw data-icon='inline-start' />
同步 {t('sync')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -299,7 +315,7 @@ export function CloudflareGroupDetailPageClient() {
onClick={() => removeMutation.mutate(member.id)} onClick={() => removeMutation.mutate(member.id)}
> >
<Trash2 data-icon='inline-start' /> <Trash2 data-icon='inline-start' />
移出 {t('remove')}
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -1,5 +1,6 @@
import { Suspense } from 'react';
import { Cloud } from 'lucide-react'; import { Cloud } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';
import { LoadingStateWithBorder } from '@/components/layout/loading'; import { LoadingStateWithBorder } from '@/components/layout/loading';
@@ -11,13 +12,14 @@ export async function generateStaticParams() {
} }
export default async function CloudflareGroupDetailPage() { export default async function CloudflareGroupDetailPage() {
const t = await getTranslations('cloudflare');
return ( return (
<Suspense <Suspense
fallback={ fallback={
<div className='w-full py-6 px-1'> <div className='w-full py-6 px-1'>
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Cloud} icon={Cloud}
description='加载分组详情中...' description={t('loadingDetail')}
/> />
</div> </div>
} }
+33 -32
View File
@@ -10,6 +10,7 @@ import {
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -48,6 +49,8 @@ import {
import { getErrorMessage } from '../websites/components/website-utils'; import { getErrorMessage } from '../websites/components/website-utils';
export default function CloudflarePage() { export default function CloudflarePage() {
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>( const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>(
@@ -72,7 +75,7 @@ export default function CloudflarePage() {
mutationFn: (payload: CloudflareGroupPayload) => mutationFn: (payload: CloudflareGroupPayload) =>
CloudflareService.createGroup(payload), CloudflareService.createGroup(payload),
onSuccess: async () => { onSuccess: async () => {
toast.success('指向分组已创建'); toast.success(t('created'));
setCreateOpen(false); setCreateOpen(false);
await invalidate(); await invalidate();
}, },
@@ -81,7 +84,7 @@ export default function CloudflarePage() {
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: (id: number) => CloudflareService.syncGroup(id), mutationFn: (id: number) => CloudflareService.syncGroup(id),
onSuccess: async () => { onSuccess: async () => {
toast.success('分组同步任务已入队'); toast.success(t('syncQueued'));
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: [...cloudflareQueryKey, 'sync-executions'], queryKey: [...cloudflareQueryKey, 'sync-executions'],
}); });
@@ -91,7 +94,7 @@ export default function CloudflarePage() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => CloudflareService.deleteGroup(id), mutationFn: (id: number) => CloudflareService.deleteGroup(id),
onSuccess: async () => { onSuccess: async () => {
toast.success('指向分组及远端记录已删除'); toast.success(t('deleted'));
setDeleteTarget(null); setDeleteTarget(null);
await invalidate(); await invalidate();
}, },
@@ -105,18 +108,20 @@ export default function CloudflarePage() {
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' /> <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>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button asChild variant='outline' size='sm'> <Button asChild variant='outline' size='sm'>
<Link href='/cloudflare/settings'> <Link href='/cloudflare/settings'>
<Settings data-icon='inline-start' /> <Settings data-icon='inline-start' />
连接设置 {t('connectionSettings')}
</Link> </Link>
</Button> </Button>
<Button size='sm' onClick={() => setCreateOpen(true)}> <Button size='sm' onClick={() => setCreateOpen(true)}>
<Plus data-icon='inline-start' /> <Plus data-icon='inline-start' />
新增分组 {t('addGroup')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -124,7 +129,7 @@ export default function CloudflarePage() {
{overviewQuery.isLoading ? ( {overviewQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Cloud} icon={Cloud}
description='加载 Cloudflare 总览中...' description={t('loadingOverview')}
/> />
) : overviewQuery.isError ? ( ) : overviewQuery.isError ? (
<ErrorInline <ErrorInline
@@ -134,14 +139,11 @@ export default function CloudflarePage() {
) : !overview?.connection.ready ? ( ) : !overview?.connection.ready ? (
<Alert> <Alert>
<AlertTriangle /> <AlertTriangle />
<AlertTitle>Cloudflare 连接尚未就绪</AlertTitle> <AlertTitle>{t('notReadyTitle')}</AlertTitle>
<AlertDescription className='flex flex-col items-start gap-3'> <AlertDescription className='flex flex-col items-start gap-3'>
<span> <span>{t('notReadyDesc')}</span>
请先导入现有 Cloudflare DNS 账号,或录入独立 API Token
并完成连接测试。
</span>
<Button asChild size='sm'> <Button asChild size='sm'>
<Link href='/cloudflare/settings'>配置连接</Link> <Link href='/cloudflare/settings'>{t('configure')}</Link>
</Button> </Button>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -149,16 +151,14 @@ export default function CloudflarePage() {
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div> <div>
<h2 className='text-lg font-semibold'>指向分组</h2> <h2 className='text-lg font-semibold'>{t('groupsTitle')}</h2>
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>{t('groupsDesc')}</p>
管理 Cloudflare A 记录对应的节点与域名成员。
</p>
</div> </div>
{groupsQuery.isLoading ? ( {groupsQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Cloud} icon={Cloud}
description='加载指向分组中...' description={t('loadingGroups')}
/> />
) : groupsQuery.isError ? ( ) : groupsQuery.isError ? (
<ErrorInline <ErrorInline
@@ -168,9 +168,9 @@ export default function CloudflarePage() {
) : (groupsQuery.data ?? []).length === 0 ? ( ) : (groupsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Cloud} icon={Cloud}
title='暂无指向分组' title={t('emptyTitle')}
description='创建分组后,可将域名成员同步到指定的边缘节点。' description={t('emptyDesc')}
actionText='新增分组' actionText={t('addGroup')}
onAction={() => setCreateOpen(true)} onAction={() => setCreateOpen(true)}
/> />
) : ( ) : (
@@ -186,20 +186,22 @@ export default function CloudflarePage() {
</CardDescription> </CardDescription>
</div> </div>
<Badge variant={group.enabled ? 'default' : 'secondary'}> <Badge variant={group.enabled ? 'default' : 'secondary'}>
{group.enabled ? '已启用' : '已停用'} {group.enabled ? t('enabled') : t('disabled')}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className='flex flex-col gap-4'> <CardContent className='flex flex-col gap-4'>
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
成员 {group.member_count} 个 · 新成员默认 {t('memberSummary', {
{group.default_proxied ? '开启' : '关闭'}橙云 count: group.member_count,
proxy: group.default_proxied ? t('proxyOn') : t('proxyOff'),
})}
</p> </p>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Button asChild size='sm'> <Button asChild size='sm'>
<Link href={`/cloudflare/groups/${group.id}`}> <Link href={`/cloudflare/groups/${group.id}`}>
<Settings data-icon='inline-start' /> <Settings data-icon='inline-start' />
管理 {t('manage')}
</Link> </Link>
</Button> </Button>
<Button <Button
@@ -208,7 +210,7 @@ export default function CloudflarePage() {
onClick={() => syncMutation.mutate(group.id)} onClick={() => syncMutation.mutate(group.id)}
> >
<RefreshCw data-icon='inline-start' /> <RefreshCw data-icon='inline-start' />
同步 {t('sync')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -216,7 +218,7 @@ export default function CloudflarePage() {
onClick={() => setDeleteTarget(group)} onClick={() => setDeleteTarget(group)}
> >
<Trash2 data-icon='inline-start' /> <Trash2 data-icon='inline-start' />
删除 {t('delete')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -242,20 +244,19 @@ export default function CloudflarePage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>删除指向分组</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A {t('deleteDesc', { name: deleteTarget?.name ?? '' })}
记录。此操作不可撤销。
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id) deleteTarget && deleteMutation.mutate(deleteTarget.id)
} }
> >
确认删除 {t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslations } from 'next-intl';
import { ArrowLeft, Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react'; import { ArrowLeft, Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
@@ -42,6 +43,7 @@ import { getErrorMessage } from '../../websites/components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'] as const; const dnsAccountsQueryKey = ['openflare', 'dns-accounts'] as const;
export default function CloudflareSettingsPage() { export default function CloudflareSettingsPage() {
const t = useTranslations('cloudflare.settings');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [source, setSource] = const [source, setSource] =
useState<CloudflareConnectionSource>('dns_account'); useState<CloudflareConnectionSource>('dns_account');
@@ -85,7 +87,7 @@ export default function CloudflareSettingsPage() {
api_token: source === 'standalone' ? apiToken : '', api_token: source === 'standalone' ? apiToken : '',
}), }),
onSuccess: async () => { onSuccess: async () => {
toast.success('Cloudflare 连接配置已保存'); toast.success(t('saved'));
setAPIToken(''); setAPIToken('');
await refresh(); await refresh();
}, },
@@ -95,7 +97,7 @@ export default function CloudflareSettingsPage() {
const verifyMutation = useMutation({ const verifyMutation = useMutation({
mutationFn: () => CloudflareService.verifyConnection(), mutationFn: () => CloudflareService.verifyConnection(),
onSuccess: async () => { onSuccess: async () => {
toast.success('Cloudflare 连接验证成功'); toast.success(t('verified'));
await refresh(); await refresh();
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error)),
@@ -104,7 +106,7 @@ export default function CloudflareSettingsPage() {
const clearMutation = useMutation({ const clearMutation = useMutation({
mutationFn: () => CloudflareService.clearConnection(), mutationFn: () => CloudflareService.clearConnection(),
onSuccess: async () => { onSuccess: async () => {
toast.success('Cloudflare 连接已清除'); toast.success(t('cleared'));
setDNSAccountID(''); setDNSAccountID('');
setAPIToken(''); setAPIToken('');
await refresh(); await refresh();
@@ -118,13 +120,13 @@ export default function CloudflareSettingsPage() {
<Button variant='ghost' size='sm' className='self-start' asChild> <Button variant='ghost' size='sm' className='self-start' asChild>
<Link href='/cloudflare'> <Link href='/cloudflare'>
<ArrowLeft data-icon='inline-start' /> <ArrowLeft data-icon='inline-start' />
返回列表 {t('back')}
</Link> </Link>
</Button> </Button>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' /> <Cloud className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'> <h1 className='text-2xl font-semibold tracking-tight'>
Cloudflare 连接设置 {t('title')}
</h1> </h1>
</div> </div>
</div> </div>
@@ -138,16 +140,15 @@ export default function CloudflareSettingsPage() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base'>凭据来源</CardTitle> <CardTitle className='text-base'>{t('sourceTitle')}</CardTitle>
<CardDescription> <CardDescription>
Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API {t('sourceDesc')}
或页面中回显。
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel htmlFor='cf-source'>连接来源</FieldLabel> <FieldLabel htmlFor='cf-source'>{t('source')}</FieldLabel>
<Select <Select
value={source} value={source}
onValueChange={(value) => onValueChange={(value) =>
@@ -160,9 +161,11 @@ export default function CloudflareSettingsPage() {
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value='dns_account'> <SelectItem value='dns_account'>
导入现有 DNS 账号 {t('importDns')}
</SelectItem>
<SelectItem value='standalone'>
{t('standalone')}
</SelectItem> </SelectItem>
<SelectItem value='standalone'>独立 API Token</SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -171,11 +174,11 @@ export default function CloudflareSettingsPage() {
{source === 'dns_account' ? ( {source === 'dns_account' ? (
<Field> <Field>
<FieldLabel htmlFor='cf-dns-account'> <FieldLabel htmlFor='cf-dns-account'>
Cloudflare DNS 账号 {t('dnsAccount')}
</FieldLabel> </FieldLabel>
<Select value={dnsAccountID} onValueChange={setDNSAccountID}> <Select value={dnsAccountID} onValueChange={setDNSAccountID}>
<SelectTrigger id='cf-dns-account' className='w-full'> <SelectTrigger id='cf-dns-account' className='w-full'>
<SelectValue placeholder='选择 DNS 账号' /> <SelectValue placeholder={t('selectAccount')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -188,8 +191,9 @@ export default function CloudflareSettingsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<FieldDescription> <FieldDescription>
仅显示类型为 cloudflare 的 DNS 账号。可前往{' '} {t('dnsHint')}{' '}
<Link href='/dns-accounts'>DNS 账号</Link> 新增。 <Link href='/dns-accounts'>{t('dnsLink')}</Link>
{t('dnsHintAfter')}
</FieldDescription> </FieldDescription>
</Field> </Field>
) : ( ) : (
@@ -203,8 +207,8 @@ export default function CloudflareSettingsPage() {
onChange={(event) => setAPIToken(event.target.value)} onChange={(event) => setAPIToken(event.target.value)}
placeholder={ placeholder={
connectionQuery.data?.configured connectionQuery.data?.configured
? '留空不会回显现有 Token;保存将替换' ? t('tokenKeep')
: '输入 Cloudflare API Token' : t('tokenEnter')
} }
/> />
</Field> </Field>
@@ -219,7 +223,7 @@ export default function CloudflareSettingsPage() {
} }
> >
<Save data-icon='inline-start' /> <Save data-icon='inline-start' />
保存配置 {t('save')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -229,7 +233,7 @@ export default function CloudflareSettingsPage() {
} }
> >
<ShieldCheck data-icon='inline-start' /> <ShieldCheck data-icon='inline-start' />
测试连接 {t('test')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -239,7 +243,7 @@ export default function CloudflareSettingsPage() {
} }
> >
<Trash2 data-icon='inline-start' /> <Trash2 data-icon='inline-start' />
清除连接 {t('clear')}
</Button> </Button>
</div> </div>
</FieldGroup> </FieldGroup>
@@ -248,11 +252,11 @@ export default function CloudflareSettingsPage() {
<Alert> <Alert>
<ShieldCheck /> <ShieldCheck />
<AlertTitle>当前状态</AlertTitle> <AlertTitle>{t('statusTitle')}</AlertTitle>
<AlertDescription> <AlertDescription>
{connectionQuery.data?.ready {connectionQuery.data?.ready
? 'Token 已验证,Cloudflare 指向同步可以执行。' ? t('ready')
: '保存配置后仍需测试连接;未验证状态下同步任务会被拒绝。'} : t('notReady')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart'; import { TrendChart } from '@/components/data/trend-chart';
import { import {
Card, Card,
@@ -14,18 +16,23 @@ import { formatPercent, formatTrendHour } from './dashboard-utils';
export function CapacityTrendChart({ export function CapacityTrendChart({
points, points,
title = '24 小时容量趋势', title,
description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。', description,
}: { }: {
points: CapacityTrendPoint[]; points: CapacityTrendPoint[];
title?: string; title?: string;
description?: string; description?: string;
}) { }) {
const t = useTranslations('dashboard.capacityTrend');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle> <CardTitle className='text-sm font-semibold'>
<CardDescription className='text-xs'>{description}</CardDescription> {title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TrendChart <TrendChart
@@ -35,7 +42,7 @@ export function CapacityTrendChart({
yAxisValueFormatter={formatPercent} yAxisValueFormatter={formatPercent}
series={[ series={[
{ {
label: '平均 CPU', label: t('avgCpu'),
color: '#0f766e', color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)', fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area', variant: 'area',
@@ -43,7 +50,7 @@ export function CapacityTrendChart({
valueFormatter: formatPercent, valueFormatter: formatPercent,
}, },
{ {
label: '平均内存', label: t('avgMemory'),
color: '#2563eb', color: '#2563eb',
values: points.map((point) => point.average_memory_usage_percent), values: points.map((point) => point.average_memory_usage_percent),
valueFormatter: formatPercent, valueFormatter: formatPercent,
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart'; import { RankChart } from '@/components/data/rank-chart';
import { import {
Card, Card,
@@ -25,19 +27,20 @@ export function SourceDistributionChart({
}: { }: {
items: TrafficDistributions['source_countries']; items: TrafficDistributions['source_countries'];
}) { }) {
const t = useTranslations('dashboard.distributions');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>来源分布</CardTitle> <CardTitle className='text-sm font-semibold'>
<CardDescription className='text-xs'> {t('sourceTitle')}
聚合最近 24 小时主要来源国家。 </CardTitle>
</CardDescription> <CardDescription className='text-xs'>{t('sourceDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RankChart <RankChart
items={toRankItems(items)} items={toRankItems(items)}
color='#38bdf8' color='#38bdf8'
emptyMessage='暂无来源分布数据' emptyMessage={t('sourceEmpty')}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -49,22 +52,23 @@ export function StatusCodeDistributionChart({
}: { }: {
items: TrafficDistributions['status_codes']; items: TrafficDistributions['status_codes'];
}) { }) {
const t = useTranslations('dashboard.distributions');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>状态码分布</CardTitle> <CardTitle className='text-sm font-semibold'>
<CardDescription className='text-xs'> {t('statusTitle')}
快速判断成功响应是否仍是主流,以及错误码是否有抬升。 </CardTitle>
</CardDescription> <CardDescription className='text-xs'>{t('statusDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RankChart <RankChart
items={toRankItems(items).map((item) => ({ items={toRankItems(items).map((item) => ({
...item, ...item,
label: `HTTP ${item.label}`, label: t('httpLabel', { code: item.label }),
}))} }))}
color='#f59e0b' color='#f59e0b'
emptyMessage='暂无状态码分布' emptyMessage={t('statusEmpty')}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -76,19 +80,20 @@ export function TopDomainChart({
}: { }: {
items: TrafficDistributions['top_domains']; items: TrafficDistributions['top_domains'];
}) { }) {
const t = useTranslations('dashboard.distributions');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>Top Domain</CardTitle> <CardTitle className='text-sm font-semibold'>
<CardDescription className='text-xs'> {t('domainTitle')}
观察主要流量集中在哪些域名。 </CardTitle>
</CardDescription> <CardDescription className='text-xs'>{t('domainDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RankChart <RankChart
items={toRankItems(items)} items={toRankItems(items)}
color='#34d399' color='#34d399'
emptyMessage='暂无域名分布' emptyMessage={t('domainEmpty')}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -33,6 +34,8 @@ import {
import { formatCompactNumber, formatPercent } from './dashboard-utils'; import { formatCompactNumber, formatPercent } from './dashboard-utils';
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) { export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeHealth');
const tn = useTranslations('nodes');
const sortedNodes = [...nodes].sort((left, right) => { const sortedNodes = [...nodes].sort((left, right) => {
if (right.active_event_count !== left.active_event_count) { if (right.active_event_count !== left.active_event_count) {
return 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'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-start justify-between gap-4'> <CardHeader className='flex flex-row items-start justify-between gap-4'>
<div> <div>
<CardTitle className='text-sm font-semibold'>节点健康列表</CardTitle> <CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
<CardDescription className='text-xs'> <CardDescription className='text-xs'>{t('description')}</CardDescription>
按异常数量和资源压力排序。
</CardDescription>
</div> </div>
<Button variant='outline' size='sm' className='h-8 text-xs' asChild> <Button variant='outline' size='sm' className='h-8 text-xs' asChild>
<Link href='/nodes'>进入节点页</Link> <Link href='/nodes'>{t('openNodes')}</Link>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{sortedNodes.length === 0 ? ( {sortedNodes.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
title='暂无节点' title={t('emptyTitle')}
description='节点接入后,这里会展示系统健康与容量摘要。' description={t('emptyDesc')}
/> />
) : ( ) : (
<div className='border border-dashed rounded-lg overflow-hidden'> <div className='border border-dashed rounded-lg overflow-hidden'>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className='border-dashed hover:bg-transparent'> <TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>节点</TableHead> <TableHead className='py-2 h-8'>{t('colNode')}</TableHead>
<TableHead className='py-2 h-8'>状态</TableHead> <TableHead className='py-2 h-8'>{t('colStatus')}</TableHead>
<TableHead className='py-2 h-8'>CPU / 内存</TableHead> <TableHead className='py-2 h-8'>{t('colCpuMem')}</TableHead>
<TableHead className='py-2 h-8'>请求 / 错误</TableHead> <TableHead className='py-2 h-8'>{t('colReqErr')}</TableHead>
<TableHead className='py-2 h-8'>活动异常</TableHead> <TableHead className='py-2 h-8'>{t('colEvents')}</TableHead>
<TableHead className='py-2 h-8'>最近心跳</TableHead> <TableHead className='py-2 h-8'>{t('colHeartbeat')}</TableHead>
<TableHead className='py-2 h-8 text-right'>操作</TableHead> <TableHead className='py-2 h-8 text-right'>
{t('colActions')}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -96,18 +99,21 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
{node.node_id} {node.node_id}
</p> </p>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
{node.geo_name || '未配置地图点位'} {node.geo_name || t('noGeo')}
</p> </p>
</div> </div>
</TableCell> </TableCell>
<TableCell className='py-3'> <TableCell className='py-3'>
<div className='flex flex-wrap gap-1.5'> <div className='flex flex-wrap gap-1.5'>
<NodeStatusBadge <NodeStatusBadge
label={getNodeStatusLabel(node.status)} label={getNodeStatusLabel(node.status, tn)}
tone={getNodeStatusTone(node.status)} tone={getNodeStatusTone(node.status)}
/> />
<NodeStatusBadge <NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)} label={getOpenrestyStatusLabel(
node.openresty_status,
tn,
)}
tone={getOpenrestyStatusTone(node.openresty_status)} tone={getOpenrestyStatusTone(node.openresty_status)}
/> />
</div> </div>
@@ -130,10 +136,10 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
</TableCell> </TableCell>
<TableCell className='py-3 text-xs text-muted-foreground'> <TableCell className='py-3 text-xs text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at) {isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接' ? t('wsConnected')
: node.last_seen_at : node.last_seen_at
? formatRelativeTime(node.last_seen_at) ? formatRelativeTime(node.last_seen_at, tn)
: '暂无'} : t('na')}
</TableCell> </TableCell>
<TableCell className='py-3 text-right'> <TableCell className='py-3 text-right'>
<Button <Button
@@ -142,7 +148,9 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
className='h-8 text-xs' className='h-8 text-xs'
asChild asChild
> >
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link> <Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart'; import { RankChart } from '@/components/data/rank-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { DashboardNodeHealth } from '@/lib/services/openflare'; import type { DashboardNodeHealth } from '@/lib/services/openflare';
@@ -27,25 +29,26 @@ function buildNodeRankItems(
} }
export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) { export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeRank');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>Top 节点榜单</CardTitle> <CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className='grid gap-6'> <CardContent className='grid gap-6'>
<div> <div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'> <p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
流量最高节点 {t('traffic')}
</p> </p>
<RankChart <RankChart
items={buildNodeRankItems(nodes, (node) => node.request_count)} items={buildNodeRankItems(nodes, (node) => node.request_count)}
color='#38bdf8' color='#38bdf8'
emptyMessage='暂无流量榜单' emptyMessage={t('trafficEmpty')}
/> />
</div> </div>
<div> <div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'> <p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
容量压力节点 {t('pressure')}
</p> </p>
<RankChart <RankChart
items={buildNodeRankItems(nodes, (node) => items={buildNodeRankItems(nodes, (node) =>
@@ -59,7 +62,7 @@ export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
)} )}
color='#ef4444' color='#ef4444'
valueFormatter={(value) => `${value}%`} valueFormatter={(value) => `${value}%`}
emptyMessage='暂无容量压力数据' emptyMessage={t('pressureEmpty')}
/> />
</div> </div>
</CardContent> </CardContent>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react'; import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
@@ -21,6 +22,7 @@ export function DashboardStatCards({
traffic: DashboardTraffic; traffic: DashboardTraffic;
capacity: DashboardCapacity; capacity: DashboardCapacity;
}) { }) {
const t = useTranslations('dashboard.stats');
const onlineRate = const onlineRate =
summary.total_nodes > 0 summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100 ? (summary.online_nodes / summary.total_nodes) * 100
@@ -31,7 +33,7 @@ export function DashboardStatCards({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'> <CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'> <span className='text-xs font-medium text-muted-foreground'>
24h 请求量 {t('requests24h')}
</span> </span>
<Activity className='size-4 text-primary' /> <Activity className='size-4 text-primary' />
</CardHeader> </CardHeader>
@@ -40,9 +42,11 @@ export function DashboardStatCards({
{formatCompactNumber(traffic.request_count)} {formatCompactNumber(traffic.request_count)}
</div> </div>
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
24h 独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '} {t('requestsMeta', {
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '} visitors: formatCompactNumber(traffic.unique_visitors),
{traffic.estimated_qps.toFixed(2)} errors: formatCompactNumber(traffic.error_count),
qps: traffic.estimated_qps.toFixed(2),
})}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -50,7 +54,7 @@ export function DashboardStatCards({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'> <CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'> <span className='text-xs font-medium text-muted-foreground'>
集群容量 {t('clusterCapacity')}
</span> </span>
<Cpu className='size-4 text-primary' /> <Cpu className='size-4 text-primary' />
</CardHeader> </CardHeader>
@@ -66,9 +70,12 @@ export function DashboardStatCards({
className='h-1.5' className='h-1.5'
/> />
<p className='text-[10px] text-muted-foreground'> <p className='text-[10px] text-muted-foreground'>
平均内存 {formatPercent(capacity.average_memory_usage_percent)} · {t('avgMemoryHighLoad', {
高负载节点 CPU {capacity.high_cpu_nodes} / 内存{' '} memory: formatPercent(capacity.average_memory_usage_percent),
{capacity.high_memory_nodes} / 存储 {capacity.high_storage_nodes} cpu: capacity.high_cpu_nodes,
memoryNodes: capacity.high_memory_nodes,
storage: capacity.high_storage_nodes,
})}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -76,7 +83,7 @@ export function DashboardStatCards({
<Card className='border-dashed shadow-none md:col-span-2 xl:col-span-1'> <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'> <CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'> <span className='text-xs font-medium text-muted-foreground'>
节点概览 {t('nodeOverview')}
</span> </span>
<Server className='size-4 text-primary' /> <Server className='size-4 text-primary' />
</CardHeader> </CardHeader>
@@ -85,18 +92,21 @@ export function DashboardStatCards({
{summary.online_nodes} {summary.online_nodes}
<span className='text-sm font-normal text-muted-foreground'> <span className='text-sm font-normal text-muted-foreground'>
{' '} {' '}
/ {summary.total_nodes} 在线 {t('onlineOf', { total: summary.total_nodes })}
</span> </span>
</div> </div>
<Progress value={onlineRate} className='h-1.5' /> <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'> <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'> <span className='inline-flex items-center gap-1'>
<Users className='size-3' /> <Users className='size-3' />
待接入 {summary.pending_nodes} {t('pending', { count: summary.pending_nodes })}
</span> </span>
<span className='inline-flex items-center gap-1'> <span className='inline-flex items-center gap-1'>
<Gauge className='size-3' /> <Gauge className='size-3' />
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes} {t('unhealthyOffline', {
unhealthy: summary.unhealthy_nodes,
offline: summary.offline_nodes,
})}
</span> </span>
</p> </p>
</CardContent> </CardContent>
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart'; import { TrendChart } from '@/components/data/trend-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { import type {
@@ -17,11 +19,13 @@ export function TrafficCapacityTrendChart({
networkPoints: NetworkTrendPoint[]; networkPoints: NetworkTrendPoint[];
capacityPoints: CapacityTrendPoint[]; capacityPoints: CapacityTrendPoint[];
}) { }) {
const t = useTranslations('dashboard.trafficCapacity');
const tc = useTranslations('dashboard.capacityTrend');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'> <CardTitle className='text-sm font-semibold'>
24 小时业务流量与容量趋势 {t('title')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-6'> <CardContent className='space-y-6'>
@@ -31,11 +35,11 @@ export function TrafficCapacityTrendChart({
)} )}
height={180} height={180}
summaryScope='total' summaryScope='total'
summaryHint='近 24 小时 · 来自访问日志' summaryHint={t('networkHint')}
yAxisValueFormatter={formatBytes} yAxisValueFormatter={formatBytes}
series={[ series={[
{ {
label: '接收数据', label: t('received'),
color: '#22c55e', color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)', fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area', variant: 'area',
@@ -43,7 +47,7 @@ export function TrafficCapacityTrendChart({
valueFormatter: formatBytes, valueFormatter: formatBytes,
}, },
{ {
label: '已提供数据', label: t('provided'),
color: '#38bdf8', color: '#38bdf8',
values: networkPoints.map((point) => point.bytes_provided), values: networkPoints.map((point) => point.bytes_provided),
valueFormatter: formatBytes, valueFormatter: formatBytes,
@@ -57,11 +61,11 @@ export function TrafficCapacityTrendChart({
)} )}
height={180} height={180}
summaryScope='average' summaryScope='average'
summaryHint='近 24 小时 · 宿主机容量 · 平均值' summaryHint={t('capacityHint')}
yAxisValueFormatter={formatPercent} yAxisValueFormatter={formatPercent}
series={[ series={[
{ {
label: '平均 CPU', label: tc('avgCpu'),
color: '#0f766e', color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)', fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area', variant: 'area',
@@ -71,7 +75,7 @@ export function TrafficCapacityTrendChart({
valueFormatter: formatPercent, valueFormatter: formatPercent,
}, },
{ {
label: '平均内存', label: tc('avgMemory'),
color: '#2563eb', color: '#2563eb',
values: capacityPoints.map( values: capacityPoints.map(
(point) => point.average_memory_usage_percent, (point) => point.average_memory_usage_percent,
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart'; import { TrendChart } from '@/components/data/trend-chart';
import { import {
Card, Card,
@@ -14,18 +16,23 @@ import { formatTrendHour } from './dashboard-utils';
export function TrafficTrendChart({ export function TrafficTrendChart({
points, points,
title = '24 小时请求趋势', title,
description = '按小时拆分请求总量与 2xx/4xx/5xx 状态码请求量,判断各状态是否异常抬升。', description,
}: { }: {
points: TrafficTrendPoint[]; points: TrafficTrendPoint[];
title?: string; title?: string;
description?: string; description?: string;
}) { }) {
const t = useTranslations('dashboard.trafficTrend');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle> <CardTitle className='text-sm font-semibold'>
<CardDescription className='text-xs'>{description}</CardDescription> {title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TrendChart <TrendChart
@@ -33,27 +40,27 @@ export function TrafficTrendChart({
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
)} )}
summaryScope='total' summaryScope='total'
summaryHint='近 24 小时' summaryHint={t('summaryHint')}
series={[ series={[
{ {
label: '请求量', label: t('requests'),
color: '#f59e0b', color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)', fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area', variant: 'area',
values: points.map((point) => point.request_count), values: points.map((point) => point.request_count),
}, },
{ {
label: '2xx 请求', label: t('status2xx'),
color: '#22c55e', color: '#22c55e',
values: points.map((point) => point.status_2xx_count), values: points.map((point) => point.status_2xx_count),
}, },
{ {
label: '4xx 请求', label: t('status4xx'),
color: '#f97316', color: '#f97316',
values: points.map((point) => point.status_4xx_count), values: points.map((point) => point.status_4xx_count),
}, },
{ {
label: '5xx 请求', label: t('status5xx'),
color: '#ef4444', color: '#ef4444',
values: points.map((point) => point.status_5xx_count), 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 { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers'; import { CanvasRenderer } from 'echarts/renderers';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
@@ -276,6 +277,8 @@ export function WorldStageMap({
nodes: DashboardNodeHealth[]; nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[]; sourceCountries: DistributionItem[];
}) { }) {
const t = useTranslations('dashboard.map');
const tn = useTranslations('nodes');
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark'; const isDark = resolvedTheme === 'dark';
const router = useRouter(); const router = useRouter();
@@ -503,7 +506,7 @@ export function WorldStageMap({
) { ) {
return [ return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`, `<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(''); ].join('');
} }
@@ -513,14 +516,22 @@ export function WorldStageMap({
const locationLine = data.derivedFromGeo const locationLine = data.derivedFromGeo
? data.geoName ? data.geoName
: `${data.geoName} · 预设落点`; : `${data.geoName} · ${t('presetPin')}`;
return [ return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`, `<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`, `<div>${locationLine}</div>`,
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`, `<div>${t('requestsErrors', {
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`, requests: data.requestCount.toLocaleString('zh-CN'),
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`, 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(''); ].join('');
}, },
}, },
@@ -610,6 +621,8 @@ export function WorldStageMap({
mapNodes, mapNodes,
mapPalette, mapPalette,
responsiveMapScale, responsiveMapScale,
t,
tn,
], ],
); );
@@ -617,12 +630,8 @@ export function WorldStageMap({
return ( return (
<div className='flex h-full items-center justify-center'> <div className='flex h-full items-center justify-center'>
<EmptyState <EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'} title={mapFailed ? t('loadFailed') : t('loading')}
description={ description={mapFailed ? t('loadFailedDesc') : t('loadingDesc')}
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
/> />
</div> </div>
); );
@@ -654,8 +663,8 @@ export function WorldStageMap({
) : ( ) : (
<div className='flex h-full items-center justify-center'> <div className='flex h-full items-center justify-center'>
<EmptyState <EmptyState
title='全球地图加载中' title={t('loading')}
description='正在测量地图容器尺寸...' description={t('measuring')}
iconSize='sm' iconSize='sm'
/> />
</div> </div>
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { type ComponentType, useEffect, useRef, useState } from 'react'; import { type ComponentType, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
Activity, Activity,
Cpu, Cpu,
@@ -40,10 +41,10 @@ const WorldStageMap = dynamic(
); );
const LEGEND_ITEMS = [ const LEGEND_ITEMS = [
{ dot: 'bg-blue-500', label: '来源' }, { dot: 'bg-blue-500', key: 'legendSource' },
{ dot: 'bg-emerald-500', label: '正常' }, { dot: 'bg-emerald-500', key: 'legendHealthy' },
{ dot: 'bg-amber-500', label: '承压' }, { dot: 'bg-amber-500', key: 'legendPressure' },
{ dot: 'bg-destructive', label: '异常' }, { dot: 'bg-destructive', key: 'legendUnhealthy' },
] as const; ] as const;
function SummaryMetric({ function SummaryMetric({
@@ -128,6 +129,7 @@ export function WorldStage({
nodes: DashboardNodeHealth[]; nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[]; sourceCountries: DistributionItem[];
}) { }) {
const t = useTranslations('dashboard.world');
const mapViewportRef = useRef<HTMLDivElement | null>(null); const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false); const [shouldRenderMap, setShouldRenderMap] = useState(false);
@@ -177,62 +179,71 @@ export function WorldStage({
const mapModeLabel = const mapModeLabel =
sourceCountries.length > 0 sourceCountries.length > 0
? '访客来源' ? t('modeVisitorSource')
: geoConfiguredNodes > 0 : geoConfiguredNodes > 0
? '节点坐标' ? t('modeNodeCoords')
: '覆盖信号'; : t('modeCoverage');
const headlineMetrics = [ const headlineMetrics = [
{ {
label: '在线覆盖', label: t('onlineCoverage'),
value: formatPercent(onlineRate), value: formatPercent(onlineRate),
hint: `${summary.online_nodes}/${summary.total_nodes} 在线`, hint: t('onlineHint', {
online: summary.online_nodes,
total: summary.total_nodes,
}),
icon: Server, icon: Server,
progress: onlineRate, progress: onlineRate,
}, },
{ {
label: '运行健康', label: t('runHealth'),
value: formatPercent(healthyRate), value: formatPercent(healthyRate),
hint: `${summary.unhealthy_nodes} 个异常`, hint: t('unhealthyCount', { count: summary.unhealthy_nodes }),
icon: ShieldCheck, icon: ShieldCheck,
progress: healthyRate, progress: healthyRate,
}, },
{ {
label: '24小时请求', label: t('requests24h'),
value: formatCompactNumber(traffic.request_count), 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, icon: Activity,
}, },
{ {
label: '节点坐标', label: t('nodeCoords'),
value: formatCompactNumber(geoConfiguredNodes), value: formatCompactNumber(geoConfiguredNodes),
hint: hint:
geoConfiguredNodes > 0 geoConfiguredNodes > 0
? `${sourceCountries.length} 个来源国家` ? t('sourceCountries', { count: sourceCountries.length })
: '未配置时使用预设落点', : t('fallbackPins'),
icon: Network, icon: Network,
}, },
] as const; ] as const;
const detailMetrics = [ const detailMetrics = [
{ {
label: '平均 CPU', label: t('avgCpu'),
value: formatPercent(capacity.average_cpu_usage_percent), value: formatPercent(capacity.average_cpu_usage_percent),
hint: `${capacity.high_cpu_nodes} 个偏高`, hint: t('highCount', { count: capacity.high_cpu_nodes }),
icon: Cpu, icon: Cpu,
progress: capacity.average_cpu_usage_percent, progress: capacity.average_cpu_usage_percent,
}, },
{ {
label: '平均内存', label: t('avgMemory'),
value: formatPercent(capacity.average_memory_usage_percent), value: formatPercent(capacity.average_memory_usage_percent),
hint: `${capacity.high_memory_nodes} 个偏高`, hint: t('highCount', { count: capacity.high_memory_nodes }),
icon: MemoryStick, icon: MemoryStick,
progress: capacity.average_memory_usage_percent, progress: capacity.average_memory_usage_percent,
}, },
{ {
label: '高存储', label: t('highStorage'),
value: formatCompactNumber(capacity.high_storage_nodes), 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, icon: HardDrive,
}, },
] as const; ] as const;
@@ -244,7 +255,7 @@ export function WorldStage({
<div className='min-w-0'> <div className='min-w-0'>
<CardTitle className='flex items-center gap-1.5 text-sm font-semibold'> <CardTitle className='flex items-center gap-1.5 text-sm font-semibold'>
<Globe2 className='size-4 shrink-0 text-primary' /> <Globe2 className='size-4 shrink-0 text-primary' />
全球态势板 {t('title')}
<Badge <Badge
variant='outline' variant='outline'
className='ml-1 text-[10px] font-normal text-muted-foreground' className='ml-1 text-[10px] font-normal text-muted-foreground'
@@ -253,17 +264,17 @@ export function WorldStage({
</Badge> </Badge>
</CardTitle> </CardTitle>
<CardDescription className='mt-1 text-xs'> <CardDescription className='mt-1 text-xs'>
汇总节点分布、来源国家热度与最近窗口运行状态。 {t('description')}
</CardDescription> </CardDescription>
</div> </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'> <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) => ( {LEGEND_ITEMS.map((item) => (
<span <span
key={item.label} key={item.key}
className='inline-flex items-center gap-1 text-[10px] text-muted-foreground' className='inline-flex items-center gap-1 text-[10px] text-muted-foreground'
> >
<span className={cn('size-1.5 rounded-full', item.dot)} /> <span className={cn('size-1.5 rounded-full', item.dot)} />
{item.label} {t(item.key)}
</span> </span>
))} ))}
</div> </div>
@@ -291,8 +302,8 @@ export function WorldStage({
) : ( ) : (
<div className='flex h-full items-center justify-center px-4'> <div className='flex h-full items-center justify-center px-4'>
<EmptyState <EmptyState
title='地图准备中' title={t('mapPreparing')}
description='进入可视区域后加载' description={t('mapPreparingDesc')}
iconSize='sm' iconSize='sm'
/> />
</div> </div>
@@ -301,7 +312,7 @@ export function WorldStage({
{shouldRenderMap && nodes.length === 0 ? ( {shouldRenderMap && nodes.length === 0 ? (
<div className='pointer-events-none absolute inset-x-3 bottom-2 z-10'> <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'> <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> </p>
</div> </div>
) : null} ) : null}
@@ -3,6 +3,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -19,11 +20,13 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
const cleanupSchema = z.object({ function buildCleanupSchema(t: (key: string) => string) {
keepCount: z.number().int().min(3, '最少保留 3 个历史快照'), 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 { interface CleanupDialogProps {
open: boolean; open: boolean;
@@ -38,8 +41,10 @@ export function CleanupDialog({
onConfirm, onConfirm,
loading, loading,
}: CleanupDialogProps) { }: CleanupDialogProps) {
const t = useTranslations('configVersions');
const tc = useTranslations('common');
const form = useForm<CleanupFormValues>({ const form = useForm<CleanupFormValues>({
resolver: zodResolver(cleanupSchema), resolver: zodResolver(buildCleanupSchema(t)),
defaultValues: { keepCount: 10 }, defaultValues: { keepCount: 10 },
}); });
@@ -57,14 +62,12 @@ export function CleanupDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>清理历史快照</AlertDialogTitle> <AlertDialogTitle>{t('cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>{t('cleanupDesc')}</AlertDialogDescription>
删除旧的历史快照配置,系统将始终保护当前已激活的版本不被删除。
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className='space-y-2'> <div className='space-y-2'>
<Label htmlFor='keepCount'>保留最近快照个数</Label> <Label htmlFor='keepCount'>{t('keepCount')}</Label>
<Input <Input
id='keepCount' id='keepCount'
type='number' type='number'
@@ -73,7 +76,7 @@ export function CleanupDialog({
{...form.register('keepCount', { valueAsNumber: true })} {...form.register('keepCount', { valueAsNumber: true })}
/> />
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
默认为 10 个,最少需保留 3 个。 {t('keepHint')}
</p> </p>
{form.formState.errors.keepCount ? ( {form.formState.errors.keepCount ? (
<p className='text-xs text-destructive'> <p className='text-xs text-destructive'>
@@ -83,7 +86,7 @@ export function CleanupDialog({
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel> <AlertDialogCancel disabled={loading}>{tc('cancel')}</AlertDialogCancel>
<Button <Button
variant='destructive' variant='destructive'
onClick={() => void handleConfirm()} onClick={() => void handleConfirm()}
@@ -92,10 +95,10 @@ export function CleanupDialog({
{loading ? ( {loading ? (
<> <>
<Loader2 className='size-4 animate-spin mr-1' /> <Loader2 className='size-4 animate-spin mr-1' />
清理中... {t('cleaning')}
</> </>
) : ( ) : (
'确认清理' t('confirmCleanup')
)} )}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -29,12 +30,13 @@ interface DiffDialogProps {
} }
function DiffChipList({ title, items }: { title: string; items: string[] }) { function DiffChipList({ title, items }: { title: string; items: string[] }) {
const t = useTranslations('configVersions');
return ( return (
<div className='space-y-2'> <div className='space-y-2'>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<p className='text-xs font-semibold'>{title}</p> <p className='text-xs font-semibold'>{title}</p>
<Badge variant='outline' className='text-[10px] rounded-full'> <Badge variant='outline' className='text-[10px] rounded-full'>
{items.length} 项 {t('itemCount', { count: items.length })}
</Badge> </Badge>
</div> </div>
{items.length > 0 ? ( {items.length > 0 ? (
@@ -50,16 +52,12 @@ function DiffChipList({ title, items }: { title: string; items: string[] }) {
))} ))}
</div> </div>
) : ( ) : (
<p className='text-xs text-muted-foreground'>无变更</p> <p className='text-xs text-muted-foreground'>{t('noChange')}</p>
)} )}
</div> </div>
); );
} }
function renderOptionValue(value: string) {
return value === '' ? '空' : value;
}
export function DiffDialog({ export function DiffDialog({
open, open,
onOpenChange, onOpenChange,
@@ -67,20 +65,19 @@ export function DiffDialog({
loading, loading,
error, error,
}: DiffDialogProps) { }: DiffDialogProps) {
const t = useTranslations('configVersions');
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl max-h-[85vh] overflow-y-auto'> <DialogContent className='sm:max-w-3xl max-h-[85vh] overflow-y-auto'>
<DialogHeader> <DialogHeader>
<DialogTitle>配置差异</DialogTitle> <DialogTitle>{t('diffTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('diffDesc')}</DialogDescription>
对比当前待发布配置与已激活版本之间的差异。
</DialogDescription>
</DialogHeader> </DialogHeader>
{loading ? ( {loading ? (
<div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'> <div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
正在加载差异数据... {t('loadingDiff')}
</div> </div>
) : error ? ( ) : error ? (
<p className='text-sm text-destructive'>{error}</p> <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='grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
激活版本 {t('activeVersion')}
</p> </p>
<p className='mt-1 text-sm font-medium'> <p className='mt-1 text-sm font-medium'>
{diff.active_version || '无'} {diff.active_version || t('none')}
</p> </p>
</div> </div>
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
新增域名 {t('addedDomains')}
</p> </p>
<p className='mt-1 text-lg font-semibold text-emerald-600'> <p className='mt-1 text-lg font-semibold text-emerald-600'>
{diff.added_domains.length} {diff.added_domains.length}
@@ -105,7 +102,7 @@ export function DiffDialog({
</div> </div>
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
删除域名 {t('removedDomains')}
</p> </p>
<p className='mt-1 text-lg font-semibold text-amber-600'> <p className='mt-1 text-lg font-semibold text-amber-600'>
{diff.removed_domains.length} {diff.removed_domains.length}
@@ -113,7 +110,7 @@ export function DiffDialog({
</div> </div>
<div className='rounded-lg border border-dashed p-3'> <div className='rounded-lg border border-dashed p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
修改域名 {t('modifiedDomains')}
</p> </p>
<p className='mt-1 text-lg font-semibold text-blue-600'> <p className='mt-1 text-lg font-semibold text-blue-600'>
{diff.modified_domains.length} {diff.modified_domains.length}
@@ -122,25 +119,38 @@ export function DiffDialog({
</div> </div>
<div className='grid gap-4 md:grid-cols-3'> <div className='grid gap-4 md:grid-cols-3'>
<DiffChipList title='新增域名' items={diff.added_domains} /> <DiffChipList
<DiffChipList title='删除域名' items={diff.removed_domains} /> title={t('addedDomains')}
<DiffChipList title='修改域名' items={diff.modified_domains} /> items={diff.added_domains}
/>
<DiffChipList
title={t('removedDomains')}
items={diff.removed_domains}
/>
<DiffChipList
title={t('modifiedDomains')}
items={diff.modified_domains}
/>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Badge <Badge
variant={diff.main_config_changed ? 'destructive' : 'outline'} variant={diff.main_config_changed ? 'destructive' : 'outline'}
> >
主配置 {diff.main_config_changed ? '已变化' : '无变化'} {diff.main_config_changed
? t('mainChanged')
: t('mainUnchanged')}
</Badge> </Badge>
<Badge <Badge
variant={diff.waf_config_changed ? 'destructive' : 'outline'} variant={diff.waf_config_changed ? 'destructive' : 'outline'}
> >
WAF {diff.waf_config_changed ? '已变化' : '无变化'} {diff.waf_config_changed ? t('wafChanged') : t('wafUnchanged')}
</Badge> </Badge>
<Badge variant='outline'> <Badge variant='outline'>
网站数 {diff.active_website_count} →{' '} {t('websiteDelta', {
{diff.current_website_count} from: diff.active_website_count,
to: diff.current_website_count,
})}
</Badge> </Badge>
</div> </div>
@@ -150,13 +160,13 @@ export function DiffDialog({
<TableHeader className='bg-muted/40'> <TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'> <TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs font-semibold'> <TableHead className='text-xs font-semibold'>
参数 {t('optionKey')}
</TableHead> </TableHead>
<TableHead className='text-xs font-semibold'> <TableHead className='text-xs font-semibold'>
激活值 {t('activeValue')}
</TableHead> </TableHead>
<TableHead className='text-xs font-semibold'> <TableHead className='text-xs font-semibold'>
待发布值 {t('pendingValue')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -167,10 +177,14 @@ export function DiffDialog({
{item.key} {item.key}
</TableCell> </TableCell>
<TableCell className='text-xs font-mono text-muted-foreground'> <TableCell className='text-xs font-mono text-muted-foreground'>
{renderOptionValue(item.previous_value)} {item.previous_value === ''
? t('emptyValue')
: item.previous_value}
</TableCell> </TableCell>
<TableCell className='text-xs font-mono text-muted-foreground'> <TableCell className='text-xs font-mono text-muted-foreground'>
{renderOptionValue(item.current_value)} {item.current_value === ''
? t('emptyValue')
: item.current_value}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -179,7 +193,7 @@ export function DiffDialog({
</div> </div>
) : ( ) : (
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
当前无 OpenResty 参数变化。 {t('noOptionChanges')}
</p> </p>
)} )}
</div> </div>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -36,21 +37,20 @@ export function PreviewSheet({
canPublish, canPublish,
onPublish, onPublish,
}: PreviewSheetProps) { }: PreviewSheetProps) {
const t = useTranslations('configVersions');
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'> <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'> <SheetHeader className='px-5 py-4 border-b'>
<SheetTitle>发布预览</SheetTitle> <SheetTitle>{t('previewTitle')}</SheetTitle>
<SheetDescription> <SheetDescription>{t('previewDesc')}</SheetDescription>
查看待发布配置的渲染结果与支持文件。
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'> <div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
{loading ? ( {loading ? (
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'> <div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
正在加载预览... {t('loadingPreview')}
</div> </div>
) : error ? ( ) : error ? (
<p className='text-sm text-destructive'>{error}</p> <p className='text-sm text-destructive'>{error}</p>
@@ -58,10 +58,10 @@ export function PreviewSheet({
<div className='space-y-5 pb-4'> <div className='space-y-5 pb-4'>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Badge variant='outline' className='rounded-full text-[10px]'> <Badge variant='outline' className='rounded-full text-[10px]'>
规则 {preview.route_count} 条 {t('routeCount', { count: preview.route_count })}
</Badge> </Badge>
<Badge variant='outline' className='rounded-full text-[10px]'> <Badge variant='outline' className='rounded-full text-[10px]'>
网站 {preview.website_count} 个 {t('websiteCount', { count: preview.website_count })}
</Badge> </Badge>
<Badge <Badge
variant='outline' variant='outline'
@@ -72,7 +72,7 @@ export function PreviewSheet({
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-xs font-semibold'>主配置</p> <p className='text-xs font-semibold'>{t('mainConfig')}</p>
<CodeBlock <CodeBlock
code={preview.main_config} code={preview.main_config}
language='nginx' language='nginx'
@@ -81,7 +81,7 @@ export function PreviewSheet({
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-xs font-semibold'>路由配置</p> <p className='text-xs font-semibold'>{t('routeConfig')}</p>
<CodeBlock <CodeBlock
code={preview.rendered_config} code={preview.rendered_config}
language='nginx' language='nginx'
@@ -92,7 +92,9 @@ export function PreviewSheet({
{preview.support_files.length > 0 ? ( {preview.support_files.length > 0 ? (
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-xs font-semibold'> <p className='text-xs font-semibold'>
支持文件 ({preview.support_files.length}) {t('supportFiles', {
count: preview.support_files.length,
})}
</p> </p>
{preview.support_files.map((file) => ( {preview.support_files.map((file) => (
<details <details
@@ -112,13 +114,13 @@ export function PreviewSheet({
</div> </div>
) : ( ) : (
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
当前发布不需要额外支持文件。 {t('noSupportFiles')}
</p> </p>
)} )}
{!canPublish ? ( {!canPublish ? (
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
当前规则与已激活版本一致,无法重复发布。 {t('noChanges')}
</p> </p>
) : null} ) : null}
</div> </div>
@@ -131,7 +133,7 @@ export function PreviewSheet({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={publishing} disabled={publishing}
> >
关闭 {t('close')}
</Button> </Button>
<Button <Button
onClick={onPublish} onClick={onPublish}
@@ -140,10 +142,10 @@ export function PreviewSheet({
{publishing ? ( {publishing ? (
<> <>
<Loader2 className='size-4 animate-spin mr-1' /> <Loader2 className='size-4 animate-spin mr-1' />
发布中... {t('publishing')}
</> </>
) : ( ) : (
'确认发布' t('confirmPublish')
)} )}
</Button> </Button>
</SheetFooter> </SheetFooter>
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -33,6 +34,7 @@ export function VersionSnapshotSheet({
open, open,
onOpenChange, onOpenChange,
}: VersionSnapshotSheetProps) { }: VersionSnapshotSheetProps) {
const t = useTranslations('configVersions');
const [detail, setDetail] = useState<ConfigVersionDetail | null>(null); const [detail, setDetail] = useState<ConfigVersionDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -51,8 +53,9 @@ export function VersionSnapshotSheet({
}) })
.catch((err) => { .catch((err) => {
if (!cancelled) { if (!cancelled) {
toast.error('加载版本快照失败', { toast.error(t('snapshotLoadFailed'), {
description: err instanceof Error ? err.message : '未知错误', description:
err instanceof Error ? err.message : t('unknownError'),
}); });
} }
}) })
@@ -63,32 +66,36 @@ export function VersionSnapshotSheet({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, version]); }, [open, version, t]);
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'> <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'> <SheetHeader className='px-5 py-4 border-b'>
<SheetTitle> <SheetTitle>
{version ? `版本 ${version.version}` : '版本快照'} {version
? t('snapshotVersion', { version: version.version })
: t('snapshotTitle')}
</SheetTitle> </SheetTitle>
<SheetDescription>查看历史配置版本的完整快照内容。</SheetDescription> <SheetDescription>{t('snapshotDesc')}</SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'> <div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
{loading ? ( {loading ? (
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'> <div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
正在加载快照... {t('loadingSnapshot')}
</div> </div>
) : detail ? ( ) : detail ? (
<div className='space-y-5 pb-4'> <div className='space-y-5 pb-4'>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{detail.is_active ? ( {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]'> <Badge variant='outline' className='rounded-full text-[10px]'>
历史版本 {t('history')}
</Badge> </Badge>
)} )}
<Badge <Badge
@@ -101,11 +108,13 @@ export function VersionSnapshotSheet({
<div className='grid gap-3 sm:grid-cols-2 text-xs'> <div className='grid gap-3 sm:grid-cols-2 text-xs'>
<div> <div>
<p className='text-muted-foreground'>创建人</p> <p className='text-muted-foreground'>{t('createdBy')}</p>
<p className='font-medium'>{detail.created_by || '系统'}</p> <p className='font-medium'>
{detail.created_by || t('system')}
</p>
</div> </div>
<div> <div>
<p className='text-muted-foreground'>创建时间</p> <p className='text-muted-foreground'>{t('createdAt')}</p>
<p className='font-medium'> <p className='font-medium'>
{formatDateTime(detail.created_at)} {formatDateTime(detail.created_at)}
</p> </p>
@@ -113,7 +122,7 @@ export function VersionSnapshotSheet({
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-xs font-semibold'>主配置</p> <p className='text-xs font-semibold'>{t('mainConfig')}</p>
<CodeBlock <CodeBlock
code={detail.main_config} code={detail.main_config}
language='nginx' language='nginx'
@@ -122,7 +131,7 @@ export function VersionSnapshotSheet({
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-xs font-semibold'>路由配置</p> <p className='text-xs font-semibold'>{t('routeConfig')}</p>
<CodeBlock <CodeBlock
code={detail.rendered_config} code={detail.rendered_config}
language='nginx' language='nginx'
@@ -135,7 +144,7 @@ export function VersionSnapshotSheet({
<SheetFooter className='px-5 py-4 border-t'> <SheetFooter className='px-5 py-4 border-t'>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
关闭 {t('close')}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
+67 -48
View File
@@ -10,6 +10,7 @@ import {
RefreshCw, RefreshCw,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -68,6 +69,8 @@ function hasConfigDiff(diff: ConfigDiffResult) {
} }
export default function ConfigVersionsPage() { export default function ConfigVersionsPage() {
const t = useTranslations('configVersions');
const tc = useTranslations('common');
const [versions, setVersions] = useState<ConfigVersionSummary[]>([]); const [versions, setVersions] = useState<ConfigVersionSummary[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -121,11 +124,11 @@ export default function ConfigVersionsPage() {
const data = await ConfigVersionService.list(); const data = await ConfigVersionService.list();
setVersions(data); setVersions(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '加载失败'); setError(err instanceof Error ? err.message : t('loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [t]);
useEffect(() => { useEffect(() => {
void fetchVersions(); void fetchVersions();
@@ -145,7 +148,7 @@ export default function ConfigVersionsPage() {
setPreview(previewData); setPreview(previewData);
setDiff(diffData); setDiff(diffData);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : '加载预览失败'; const message = err instanceof Error ? err.message : t('previewLoadFailed');
setPreviewError(message); setPreviewError(message);
setDiffError(message); setDiffError(message);
} finally { } finally {
@@ -170,7 +173,7 @@ export default function ConfigVersionsPage() {
const diffData = await ConfigVersionService.diff(); const diffData = await ConfigVersionService.diff();
setDiff(diffData); setDiff(diffData);
} catch (err) { } catch (err) {
setDiffError(err instanceof Error ? err.message : '加载差异失败'); setDiffError(err instanceof Error ? err.message : t('diffLoadFailed'));
} finally { } finally {
setDiffLoading(false); setDiffLoading(false);
} }
@@ -181,7 +184,9 @@ export default function ConfigVersionsPage() {
setPublishing(true); setPublishing(true);
try { try {
const version = await ConfigVersionService.publish(force); const version = await ConfigVersionService.publish(force);
toast.success('发布成功', { description: `版本 ${version.version}` }); toast.success(t('publishSuccess'), {
description: t('publishSuccessDesc', { version: version.version }),
});
setPreviewOpen(false); setPreviewOpen(false);
setPublishConfirmOpen(false); setPublishConfirmOpen(false);
setForcePublishConfirmOpen(false); setForcePublishConfirmOpen(false);
@@ -189,8 +194,8 @@ export default function ConfigVersionsPage() {
setDiff(null); setDiff(null);
await fetchVersions(); await fetchVersions();
} catch (err) { } catch (err) {
toast.error('发布失败', { toast.error(t('publishFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setPublishing(false); setPublishing(false);
@@ -203,12 +208,14 @@ export default function ConfigVersionsPage() {
setActivating(true); setActivating(true);
try { try {
const version = await ConfigVersionService.activate(activateTarget.id); const version = await ConfigVersionService.activate(activateTarget.id);
toast.success('激活成功', { description: `版本 ${version.version}` }); toast.success(t('activateSuccess'), {
description: t('activateSuccessDesc', { version: version.version }),
});
setActivateTarget(null); setActivateTarget(null);
await fetchVersions(); await fetchVersions();
} catch (err) { } catch (err) {
toast.error('激活失败', { toast.error(t('activateFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setActivating(false); setActivating(false);
@@ -221,14 +228,14 @@ export default function ConfigVersionsPage() {
const result = await ConfigVersionService.cleanup({ const result = await ConfigVersionService.cleanup({
keep_count: keepCount, keep_count: keepCount,
}); });
toast.success('清理完成', { toast.success(t('cleanupDone'), {
description: `已删除 ${result.deleted_count} 个历史快照`, description: t('cleanupDoneDesc', { count: result.deleted_count }),
}); });
setCleanupOpen(false); setCleanupOpen(false);
await fetchVersions(); await fetchVersions();
} catch (err) { } catch (err) {
toast.error('清理失败', { toast.error(t('cleanupFailed'), {
description: err instanceof Error ? err.message : '未知错误', description: err instanceof Error ? err.message : t('unknownError'),
}); });
} finally { } finally {
setCleaning(false); setCleaning(false);
@@ -241,10 +248,10 @@ export default function ConfigVersionsPage() {
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<History className='size-5 text-primary' /> <History className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>配置版本</h1> <h1 className='text-2xl font-semibold tracking-tight'>
<p className='text-sm text-muted-foreground'> {t('title')}
查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。 </h1>
</p> <p className='text-sm text-muted-foreground'>{t('description')}</p>
</div> </div>
</div> </div>
@@ -258,7 +265,7 @@ export default function ConfigVersionsPage() {
<RefreshCw <RefreshCw
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
/> />
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -266,7 +273,7 @@ export default function ConfigVersionsPage() {
onClick={() => setCleanupOpen(true)} onClick={() => setCleanupOpen(true)}
> >
<Trash2 className='size-3.5 mr-1' /> <Trash2 className='size-3.5 mr-1' />
清理旧版本 {t('cleanupOld')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -274,18 +281,18 @@ export default function ConfigVersionsPage() {
onClick={() => void handleOpenDiff()} onClick={() => void handleOpenDiff()}
> >
<GitCompare className='size-3.5 mr-1' /> <GitCompare className='size-3.5 mr-1' />
查看差异 {t('viewDiff')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
size='sm' size='sm'
onClick={() => setForcePublishConfirmOpen(true)} onClick={() => setForcePublishConfirmOpen(true)}
> >
强制发布 {t('forcePublish')}
</Button> </Button>
<Button size='sm' onClick={() => void handleOpenPreview()}> <Button size='sm' onClick={() => void handleOpenPreview()}>
<Eye className='size-3.5 mr-1' /> <Eye className='size-3.5 mr-1' />
预览并发布 {t('previewPublish')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -299,24 +306,30 @@ export default function ConfigVersionsPage() {
<LoadingStateWithBorder /> <LoadingStateWithBorder />
) : sortedVersions.length === 0 ? ( ) : sortedVersions.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
title='暂无历史版本' title={t('emptyTitle')}
description='当前还没有可查看的发布记录,请先触发一次配置发布。' description={t('emptyDesc')}
/> />
) : ( ) : (
<Table> <Table>
<TableHeader className='bg-muted/40'> <TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'> <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'> <TableHead className='text-xs font-semibold'>
Checksum {t('colVersion')}
</TableHead> </TableHead>
<TableHead className='text-xs font-semibold'> <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>
<TableHead className='text-xs font-semibold text-right'> <TableHead className='text-xs font-semibold text-right'>
操作 {t('colActions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </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' 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' /> <span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
当前激活 {t('active')}
</Badge> </Badge>
) : ( ) : (
<Badge <Badge
variant='outline' variant='outline'
className='text-[10px] rounded-full py-0 px-2' className='text-[10px] rounded-full py-0 px-2'
> >
历史版本 {t('history')}
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell className='text-xs text-muted-foreground'> <TableCell className='text-xs text-muted-foreground'>
{version.created_by || '系统'} {version.created_by || t('system')}
</TableCell> </TableCell>
<TableCell <TableCell
className='text-xs font-mono text-muted-foreground' className='text-xs font-mono text-muted-foreground'
@@ -371,7 +384,7 @@ export default function ConfigVersionsPage() {
}} }}
> >
<Eye className='size-3 mr-1' /> <Eye className='size-3 mr-1' />
快照 {t('snapshot')}
</Button> </Button>
{!version.is_active ? ( {!version.is_active ? (
<Button <Button
@@ -381,7 +394,7 @@ export default function ConfigVersionsPage() {
onClick={() => setActivateTarget(version)} onClick={() => setActivateTarget(version)}
> >
<Play className='size-3 mr-1' /> <Play className='size-3 mr-1' />
激活 {t('activate')}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -431,13 +444,15 @@ export default function ConfigVersionsPage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认发布配置</AlertDialogTitle> <AlertDialogTitle>{t('confirmPublishTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。 {t('confirmPublishDesc')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel> <AlertDialogCancel disabled={publishing}>
{tc('cancel')}
</AlertDialogCancel>
<Button <Button
onClick={() => void handlePublish(false)} onClick={() => void handlePublish(false)}
disabled={publishing} disabled={publishing}
@@ -445,7 +460,7 @@ export default function ConfigVersionsPage() {
{publishing ? ( {publishing ? (
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
) : ( ) : (
'确认发布' t('confirmPublish')
)} )}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
@@ -458,13 +473,15 @@ export default function ConfigVersionsPage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认强制发布</AlertDialogTitle> <AlertDialogTitle>{t('confirmForceTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
将忽略配置变化检查并立即生成一个新版本,请确认你确实需要重新发布。 {t('confirmForceDesc')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel> <AlertDialogCancel disabled={publishing}>
{tc('cancel')}
</AlertDialogCancel>
<Button <Button
variant='destructive' variant='destructive'
onClick={() => void handlePublish(true)} onClick={() => void handlePublish(true)}
@@ -473,7 +490,7 @@ export default function ConfigVersionsPage() {
{publishing ? ( {publishing ? (
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
) : ( ) : (
'强制发布' t('forcePublish')
)} )}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
@@ -488,20 +505,22 @@ export default function ConfigVersionsPage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认激活版本</AlertDialogTitle> <AlertDialogTitle>{t('confirmActivateTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{activateTarget {activateTarget
? `确认将版本 ${activateTarget.version} 设为当前激活版本吗?` ? t('confirmActivateDesc', { version: activateTarget.version })
: ''} : ''}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={activating}>取消</AlertDialogCancel> <AlertDialogCancel disabled={activating}>
{tc('cancel')}
</AlertDialogCancel>
<Button onClick={() => void handleActivate()} disabled={activating}> <Button onClick={() => void handleActivate()} disabled={activating}>
{activating ? ( {activating ? (
<Loader2 className='size-4 animate-spin' /> <Loader2 className='size-4 animate-spin' />
) : ( ) : (
'确认激活' t('confirmActivate')
)} )}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
+24 -17
View File
@@ -30,12 +30,16 @@ import type { DnsAccountItem } from '@/lib/services/openflare';
import { DnsAccountService } from '@/lib/services/openflare'; import { DnsAccountService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { DnsAccountCreateDialog } from '../websites/components/dns-account-create-dialog'; import { DnsAccountCreateDialog } from '../websites/components/dns-account-create-dialog';
import { getErrorMessage } from '../websites/components/website-utils'; import { getErrorMessage } from '../websites/components/website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts']; const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
export default function DnsAccountsPage() { export default function DnsAccountsPage() {
const t = useTranslations('dnsAccounts');
const tc = useTranslations('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DnsAccountItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<DnsAccountItem | null>(null);
@@ -48,11 +52,11 @@ export default function DnsAccountsPage() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => DnsAccountService.deleteById(id), mutationFn: (id: number) => DnsAccountService.deleteById(id),
onSuccess: async () => { onSuccess: async () => {
toast.success('DNS 账号已删除'); toast.success(t('deleted'));
setDeleteTarget(null); setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: dnsAccountsQueryKey }); await queryClient.invalidateQueries({ queryKey: dnsAccountsQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const accounts = useMemo( 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 justify-between gap-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Cloud className='size-5 text-primary' /> <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>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button <Button
@@ -74,7 +78,7 @@ export default function DnsAccountsPage() {
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
> >
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
添加账号 {t('addAccount')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -82,22 +86,23 @@ export default function DnsAccountsPage() {
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'> <CardHeader className='pb-3'>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
DNS 账号列表 {t('listTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('listDesc')}</CardDescription>
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{dnsAccountsQuery.isLoading ? ( {dnsAccountsQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Cloud} icon={Cloud}
description='加载 DNS 账号中...' description={t('loadingList')}
/> />
) : dnsAccountsQuery.isError ? ( ) : dnsAccountsQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'> <div className='p-8 border border-dashed rounded-lg'>
<ErrorInline <ErrorInline
message={getErrorMessage(dnsAccountsQuery.error)} message={getErrorMessage(
dnsAccountsQuery.error,
t('requestFailed'),
)}
onRetry={() => void dnsAccountsQuery.refetch()} onRetry={() => void dnsAccountsQuery.refetch()}
className='justify-center' className='justify-center'
/> />
@@ -105,7 +110,7 @@ export default function DnsAccountsPage() {
) : accounts.length === 0 ? ( ) : accounts.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Cloud} icon={Cloud}
description='暂无 DNS 账号,点击右上角「添加账号」开始录入。' description={t('emptyList')}
/> />
) : ( ) : (
<div className='space-y-3'> <div className='space-y-3'>
@@ -122,7 +127,9 @@ export default function DnsAccountsPage() {
</span> </span>
</p> </p>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
创建于:{formatDateTime(account.created_at)} {t('createdAt', {
date: formatDateTime(account.created_at),
})}
</p> </p>
</div> </div>
<Button <Button
@@ -143,7 +150,7 @@ export default function DnsAccountsPage() {
<DnsAccountCreateDialog <DnsAccountCreateDialog
open={createOpen} open={createOpen}
onOpenChange={setCreateOpen} onOpenChange={setCreateOpen}
onCreated={() => toast.success('DNS 账号已添加')} onCreated={() => toast.success(t('created'))}
/> />
<AlertDialog <AlertDialog
@@ -152,20 +159,20 @@ export default function DnsAccountsPage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除 DNS 账号</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除 DNS 账号 {deleteTarget?.name} 吗? {t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90' className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id) deleteTarget && deleteMutation.mutate(deleteTarget.id)
} }
> >
删除 {tc('delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+3 -1
View File
@@ -6,8 +6,10 @@ import { motion } from 'motion/react';
import { RequireAuth } from '@/components/auth/require-auth'; import { RequireAuth } from '@/components/auth/require-auth';
import { UserFileManager } from '@/components/common/user/file-manager'; import { UserFileManager } from '@/components/common/user/file-manager';
import { FolderOpen } from 'lucide-react'; import { FolderOpen } from 'lucide-react';
import { useTranslations } from 'next-intl';
export default function UserFilesPage() { export default function UserFilesPage() {
const t = useTranslations('files');
return ( return (
<RequireAuth> <RequireAuth>
<motion.div <motion.div
@@ -22,7 +24,7 @@ export default function UserFilesPage() {
<FolderOpen className='size-5 text-primary' /> <FolderOpen className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'> <h1 className='text-2xl font-semibold tracking-tight'>
我的文件 {t('myFiles')}
</h1> </h1>
</div> </div>
</div> </div>
+27 -25
View File
@@ -2,6 +2,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Network, Plus, RefreshCw } from 'lucide-react'; import { Loader2, Network, Plus, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -46,6 +47,8 @@ import { IPGroupsTable } from '../waf/components/ip-groups-table';
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups']; const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
export default function WafIPGroupsPage() { export default function WafIPGroupsPage() {
const t = useTranslations('ipGroups');
const tCommon = useTranslations('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<WAFIPGroup | null>(null); const [editingGroup, setEditingGroup] = useState<WAFIPGroup | null>(null);
@@ -90,25 +93,25 @@ export default function WafIPGroupsPage() {
return WafService.createIPGroup(payload); return WafService.createIPGroup(payload);
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success(editingGroup ? 'IP 组已更新' : 'IP 组已创建'); toast.success(editingGroup ? t('updated') : t('created'));
setEditingGroup(null); setEditingGroup(null);
setEditorOpen(false); setEditorOpen(false);
await invalidate(); await invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error)); toast.error(getErrorMessage(error, t('operationFailed')));
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => WafService.deleteIPGroup(id), mutationFn: (id: number) => WafService.deleteIPGroup(id),
onSuccess: async () => { onSuccess: async () => {
toast.success('IP 组已删除'); toast.success(t('deleted'));
setDeleteTarget(null); setDeleteTarget(null);
await invalidate(); await invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error)); toast.error(getErrorMessage(error, t('operationFailed')));
}, },
}); });
@@ -118,11 +121,11 @@ export default function WafIPGroupsPage() {
setSyncingId(id); setSyncingId(id);
}, },
onSuccess: async (result) => { onSuccess: async (result) => {
toast.success(result.message || '同步完成'); toast.success(result.message || t('synced'));
await invalidate(); await invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error)); toast.error(getErrorMessage(error, t('operationFailed')));
}, },
onSettled: () => { onSettled: () => {
setSyncingId(null); setSyncingId(null);
@@ -147,12 +150,12 @@ export default function WafIPGroupsPage() {
setRemovingIp(ip); setRemovingIp(ip);
}, },
onSuccess: async (updatedGroup) => { onSuccess: async (updatedGroup) => {
toast.success('IP 已移除'); toast.success(t('ipRemoved'));
setViewingGroup(updatedGroup); setViewingGroup(updatedGroup);
await invalidate(); await invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error)); toast.error(getErrorMessage(error, t('operationFailed')));
}, },
onSettled: () => { onSettled: () => {
setRemovingIp(null); setRemovingIp(null);
@@ -164,19 +167,20 @@ export default function WafIPGroupsPage() {
WafService.testIPGroup({ WafService.testIPGroup({
auto_config: parseAutomaticConfig( auto_config: parseAutomaticConfig(
JSON.stringify(group.auto_config ?? {}, null, 2), JSON.stringify(group.auto_config ?? {}, null, 2),
t('autoConfigMustBeObject'),
), ),
}), }),
onSuccess: (result) => { onSuccess: (result) => {
setTestResult(result); setTestResult(result);
toast.success( toast.success(
result.matched_count > 0 result.matched_count > 0
? `规则测试完成,命中 ${result.matched_count} 个 IP` ? t('testHit', { count: result.matched_count })
: '规则测试完成,当前未命中任何 IP', : t('testMiss'),
); );
}, },
onError: (error) => { onError: (error) => {
setTestResult(null); 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 justify-between gap-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Network className='size-5 text-primary' /> <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>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button <Button
@@ -229,7 +233,7 @@ export default function WafIPGroupsPage() {
onClick={handleCreate} onClick={handleCreate}
> >
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
新建 IP 组 {t('create')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -239,11 +243,9 @@ export default function WafIPGroupsPage() {
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<div> <div>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
IP 组列表 {t('listTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('listDesc')}</CardDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</CardDescription>
</div> </div>
<Button <Button
variant='outline' variant='outline'
@@ -257,7 +259,7 @@ export default function WafIPGroupsPage() {
) : ( ) : (
<RefreshCw className='size-3.5 mr-1' /> <RefreshCw className='size-3.5 mr-1' />
)} )}
刷新 {t('refresh')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -265,12 +267,12 @@ export default function WafIPGroupsPage() {
{groupsQuery.isLoading ? ( {groupsQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Network} icon={Network}
description='加载 IP 组中...' description={t('loading')}
/> />
) : groupsQuery.isError ? ( ) : groupsQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'> <div className='p-8 border border-dashed rounded-lg'>
<ErrorInline <ErrorInline
message={getErrorMessage(groupsQuery.error)} message={getErrorMessage(groupsQuery.error, t('operationFailed'))}
onRetry={handleRefresh} onRetry={handleRefresh}
className='justify-center' className='justify-center'
/> />
@@ -278,7 +280,7 @@ export default function WafIPGroupsPage() {
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Network} icon={Network}
description='暂无 IP 组,请先创建一个。' description={t('empty')}
/> />
) : ( ) : (
<IPGroupsTable <IPGroupsTable
@@ -334,14 +336,14 @@ export default function WafIPGroupsPage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除 IP 组</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除 IP 组「{deleteTarget?.name}」吗?删除后无法恢复。 {t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}> <AlertDialogCancel disabled={deleteMutation.isPending}>
取消 {tCommon('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90' className='bg-destructive text-white hover:bg-destructive/90'
@@ -350,7 +352,7 @@ export default function WafIPGroupsPage() {
deleteTarget && deleteMutation.mutate(deleteTarget.id) deleteTarget && deleteMutation.mutate(deleteTarget.id)
} }
> >
{deleteMutation.isPending ? '删除中...' : '确认删除'} {deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ExternalLink, Loader2 } from 'lucide-react'; import { ExternalLink, Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -40,6 +41,7 @@ export function AgentUpdateDialog({
channel: ReleaseChannel, channel: ReleaseChannel,
) => Promise<void>; ) => Promise<void>;
}) { }) {
const t = useTranslations('nodes');
const [channel, setChannel] = useState<ReleaseChannel>('stable'); const [channel, setChannel] = useState<ReleaseChannel>('stable');
const releaseQuery = useQuery({ const releaseQuery = useQuery({
@@ -54,36 +56,42 @@ export function AgentUpdateDialog({
<Dialog open={open} onOpenChange={(next) => !next && onClose()}> <Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-xl'> <DialogContent className='sm:max-w-xl'>
<DialogHeader> <DialogHeader>
<DialogTitle>Agent 升级</DialogTitle> <DialogTitle>{t('update.title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('update.description')}</DialogDescription>
检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className='space-y-4'> <div className='space-y-4'>
<div className='grid gap-3 sm:grid-cols-3'> <div className='grid gap-3 sm:grid-cols-3'>
<div className='rounded-lg border p-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'> <p className='mt-1 text-sm font-medium'>
{node.version || 'unknown'} {node.version || 'unknown'}
</p> </p>
</div> </div>
<div className='rounded-lg border p-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.channel')}
</p>
<p className='mt-1 text-sm font-medium'> <p className='mt-1 text-sm font-medium'>
{channel === 'preview' ? '预览版' : '正式版'} {channel === 'preview'
? t('channel.preview')
: t('channel.stable')}
</p> </p>
</div> </div>
<div className='rounded-lg border p-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.updateStatus')}
</p>
<div className='mt-1'> <div className='mt-1'>
<NodeStatusBadge <NodeStatusBadge
label={ label={
node.update_requested node.update_requested
? node.update_channel === 'preview' ? node.update_channel === 'preview'
? '等待预览更新' ? t('update.waitingPreview')
: '等待更新' : t('update.waitingUpdate')
: '未下发' : t('update.notRequested')
} }
tone={node.update_requested ? 'warning' : 'info'} tone={node.update_requested ? 'warning' : 'info'}
/> />
@@ -94,45 +102,57 @@ export function AgentUpdateDialog({
{releaseQuery.isFetching ? ( {releaseQuery.isFetching ? (
<div className='flex items-center justify-center py-8 text-sm text-muted-foreground'> <div className='flex items-center justify-center py-8 text-sm text-muted-foreground'>
<Loader2 className='size-4 mr-2 animate-spin' /> <Loader2 className='size-4 mr-2 animate-spin' />
检查发布版本中... {t('update.checking')}
</div> </div>
) : releaseQuery.isError ? ( ) : releaseQuery.isError ? (
<p className='text-sm text-destructive'> <p className='text-sm text-destructive'>
{getErrorMessage(releaseQuery.error)} {getErrorMessage(releaseQuery.error, t('requestFailed'))}
</p> </p>
) : release ? ( ) : release ? (
<div className='space-y-3 rounded-lg border p-4'> <div className='space-y-3 rounded-lg border p-4'>
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-wrap items-center gap-2'>
<NodeStatusBadge <NodeStatusBadge
label={ label={
release.has_update ? '发现可升级版本' : '当前已是最新版本' release.has_update
? t('update.hasUpdate')
: t('update.upToDate')
} }
tone={release.has_update ? 'warning' : 'success'} tone={release.has_update ? 'warning' : 'success'}
/> />
{release.prerelease ? ( {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>
<div className='grid gap-3 sm:grid-cols-2 text-sm'> <div className='grid gap-3 sm:grid-cols-2 text-sm'>
<div> <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'> <p className='mt-1 font-medium'>
{release.tag_name || '未找到'} {release.tag_name || t('update.notFound')}
</p> </p>
</div> </div>
<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'> <p className='mt-1'>
{release.published_at {release.published_at
? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}` ? `${formatRelativeTime(release.published_at, t)} · ${formatDateTime(release.published_at)}`
: '—'} : '—'}
</p> </p>
</div> </div>
</div> </div>
<p className='text-sm text-muted-foreground whitespace-pre-wrap'> <p className='text-sm text-muted-foreground whitespace-pre-wrap'>
{release.body || '暂无更新说明'} {release.body || t('update.noNotes')}
</p> </p>
{release.html_url ? ( {release.html_url ? (
<a <a
@@ -141,7 +161,7 @@ export function AgentUpdateDialog({
rel='noreferrer' rel='noreferrer'
className='inline-flex items-center text-sm text-primary hover:underline' className='inline-flex items-center text-sm text-primary hover:underline'
> >
查看发布详情 {t('update.viewRelease')}
<ExternalLink className='size-3 ml-1' /> <ExternalLink className='size-3 ml-1' />
</a> </a>
) : null} ) : null}
@@ -156,7 +176,7 @@ export function AgentUpdateDialog({
disabled={submitting || releaseQuery.isFetching} disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('stable')} onClick={() => setChannel('stable')}
> >
检查正式版 {t('update.checkStable')}
</Button> </Button>
<Button <Button
type='button' type='button'
@@ -164,7 +184,7 @@ export function AgentUpdateDialog({
disabled={submitting || releaseQuery.isFetching} disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('preview')} onClick={() => setChannel('preview')}
> >
检查预览版 {t('update.checkPreview')}
</Button> </Button>
<Button <Button
type='button' type='button'
@@ -177,10 +197,10 @@ export function AgentUpdateDialog({
onClick={() => void onConfirm(release, channel)} onClick={() => void onConfirm(release, channel)}
> >
{submitting {submitting
? '下发中...' ? t('dispatching')
: channel === 'preview' : channel === 'preview'
? '升级到预览版' ? t('update.upgradePreview')
: '升级到正式版'} : t('update.upgradeStable')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart'; import { TrendChart } from '@/components/data/trend-chart';
import { import {
Card, Card,
@@ -26,18 +28,23 @@ function formatDiskRate(bytesPerSecond: number) {
export function DiskIOTrendChart({ export function DiskIOTrendChart({
points, points,
title = '24 小时磁盘 IO 趋势', title,
description = '按小时展示磁盘读写速率(B/s),辅助判断日志放大、缓存抖动或磁盘压力。', description,
}: { }: {
points: DiskIOTrendPoint[]; points: DiskIOTrendPoint[];
title?: string; title?: string;
description?: string; description?: string;
}) { }) {
const t = useTranslations('nodes.diskIo');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle> <CardTitle className='text-sm font-semibold'>
<CardDescription className='text-xs'>{description}</CardDescription> {title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TrendChart <TrendChart
@@ -45,11 +52,11 @@ export function DiskIOTrendChart({
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
)} )}
summaryScope='average' summaryScope='average'
summaryHint='近 24 小时 · 平均速率' summaryHint={t('summaryHint')}
yAxisValueFormatter={formatDiskRate} yAxisValueFormatter={formatDiskRate}
series={[ series={[
{ {
label: '磁盘读', label: t('read'),
color: '#a78bfa', color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)', fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area', variant: 'area',
@@ -59,7 +66,7 @@ export function DiskIOTrendChart({
valueFormatter: formatDiskRate, valueFormatter: formatDiskRate,
}, },
{ {
label: '磁盘写', label: t('write'),
color: '#fb7185', color: '#fb7185',
values: points.map((point) => values: points.map((point) =>
diskBytesToRate(point.disk_write_bytes), diskBytesToRate(point.disk_write_bytes),
@@ -1,16 +1,20 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { formatCompactNumber } from '../../components/dashboard/dashboard-utils'; import { formatCompactNumber } from '../../components/dashboard/dashboard-utils';
export function DistributionList({ export function DistributionList({
items, items,
emptyMessage = '暂无分布数据', emptyMessage,
}: { }: {
items: Array<{ label: string; value: number }>; items: Array<{ label: string; value: number }>;
emptyMessage?: string; emptyMessage?: string;
}) { }) {
const t = useTranslations('nodes.obs');
const resolvedEmpty = emptyMessage ?? t('distEmpty');
const sortedItems = [...items] const sortedItems = [...items]
.sort((left, right) => right.value - left.value) .sort((left, right) => right.value - left.value)
.slice(0, 8); .slice(0, 8);
@@ -19,7 +23,7 @@ export function DistributionList({
if (sortedItems.length === 0) { if (sortedItems.length === 0) {
return ( return (
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'> <div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
{emptyMessage} {resolvedEmpty}
</div> </div>
); );
} }
@@ -32,7 +36,7 @@ export function DistributionList({
<div key={item.label} className='space-y-1.5'> <div key={item.label} className='space-y-1.5'>
<div className='flex items-center justify-between gap-3 text-xs'> <div className='flex items-center justify-between gap-3 text-xs'>
<span className='truncate font-medium'> <span className='truncate font-medium'>
{item.label || '未知'} {item.label || t('unknownLabel')}
</span> </span>
<span className='shrink-0 font-mono tabular-nums text-muted-foreground'> <span className='shrink-0 font-mono tabular-nums text-muted-foreground'>
{formatCompactNumber(item.value)} {formatCompactNumber(item.value)}
@@ -15,6 +15,7 @@ import {
Trash2, Trash2,
Upload, Upload,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -62,6 +63,8 @@ import {
const nodesQueryKey = ['openflare', 'nodes']; const nodesQueryKey = ['openflare', 'nodes'];
export function EdgeNodeDetail({ node }: { node: NodeItem }) { export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
@@ -72,29 +75,29 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) => mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload), NodeService.updateNode(node.id, payload),
onSuccess: async () => { onSuccess: async () => {
toast.success('节点已更新'); toast.success(t('edge.toastUpdated'));
setEditorOpen(false); setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const forceSyncMutation = useMutation({ const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id), mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => { onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发强制同步指令`); toast.success(t('edge.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const restartMutation = useMutation({ const restartMutation = useMutation({
mutationFn: () => NodeService.requestOpenrestyRestart(node.id), mutationFn: () => NodeService.requestOpenrestyRestart(node.id),
onSuccess: async (updated) => { onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发 OpenResty 重启指令`); toast.success(t('edge.toastRestart', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const upgradeMutation = useMutation({ const upgradeMutation = useMutation({
@@ -114,22 +117,28 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
}), }),
onSuccess: async (updated) => { onSuccess: async (updated) => {
toast.success( 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); setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id), mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => { onSuccess: async () => {
toast.success('节点已删除'); toast.success(t('edge.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes'); router.push('/nodes');
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const handleRefresh = () => { const handleRefresh = () => {
@@ -149,7 +158,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
className='h-8' className='h-8'
onClick={() => setEditorOpen(true)} onClick={() => setEditorOpen(true)}
> >
编辑 {t('edit')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -158,7 +167,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh} onClick={handleRefresh}
> >
<RefreshCw className='size-3.5 mr-1.5' /> <RefreshCw className='size-3.5 mr-1.5' />
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -168,7 +177,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()} onClick={() => forceSyncMutation.mutate()}
> >
<RotateCcw className='size-3.5 mr-1.5' /> <RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'} {forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -177,7 +186,9 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
disabled={restartMutation.isPending} disabled={restartMutation.isPending}
onClick={() => restartMutation.mutate()} onClick={() => restartMutation.mutate()}
> >
{restartMutation.isPending ? '下发中...' : '重启 OpenResty'} {restartMutation.isPending
? t('dispatching')
: t('edge.restartOpenresty')}
</Button> </Button>
<Button <Button
variant='secondary' variant='secondary'
@@ -186,7 +197,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)} onClick={() => setUpgradeOpen(true)}
> >
<Upload className='size-3.5 mr-1.5' /> <Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 Agent'} {node.update_requested ? t('viewUpgrade') : t('edge.upgrade')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -195,7 +206,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
> >
<Trash2 className='size-3.5 mr-1.5' /> <Trash2 className='size-3.5 mr-1.5' />
删除 {t('delete')}
</Button> </Button>
</> </>
); );
@@ -206,74 +217,74 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard <NodeSectionCard
title='运行状态' title={t('edge.runStatus')}
description='节点在线情况与 OpenResty 健康' description={t('edge.runStatusDesc')}
> >
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='运行状态'> <NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge <NodeStatusBadge
label={getNodeStatusLabel(node.status)} label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)} tone={getNodeStatusTone(node.status)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='OpenResty 健康'> <NodeInfoRow label={t('edge.openrestyHealth')}>
<NodeStatusBadge <NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)} label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)} tone={getOpenrestyStatusTone(node.openresty_status)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近心跳'> <NodeInfoRow label={t('edge.lastSeen')}>
{isMeaningfulTime(node.last_seen_at) {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>
<NodeInfoRow label='IP 地址'> <NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'} {node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''} {node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='地图点位'> <NodeInfoRow label={t('edge.geo')}>
{node.geo_name || '未配置'} {node.geo_name || t('notConfigured')}
</NodeInfoRow> </NodeInfoRow>
</div> </div>
</NodeSectionCard> </NodeSectionCard>
<NodeSectionCard <NodeSectionCard
title='配置同步' title={t('edge.syncTitle')}
description='版本追平与应用结果摘要' description={t('edge.syncDesc')}
action={ action={
<Button variant='outline' size='sm' className='h-8' asChild> <Button variant='outline' size='sm' className='h-8' asChild>
<Link <Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`} href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
> >
<FileText className='size-3.5 mr-1.5' /> <FileText className='size-3.5 mr-1.5' />
应用记录 {t('applyLogs')}
</Link> </Link>
</Button> </Button>
} }
> >
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='当前配置版本'> <NodeInfoRow label={t('edge.currentVersion')}>
{node.current_version || '未应用'} {node.current_version || t('notApplied')}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近应用'> <NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge <NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)} label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)} tone={getApplyTone(node.latest_apply_result)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近应用时间'> <NodeInfoRow label={t('edge.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at) {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>
<NodeInfoRow label='Agent 版本'> <NodeInfoRow label={t('edge.agentVersion')}>
{node.version || 'unknown'} {node.version || 'unknown'}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='Nginx 版本'> <NodeInfoRow label={t('edge.nginxVersion')}>
{node.ext_version || 'unknown'} {node.ext_version || 'unknown'}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='自动更新'> <NodeInfoRow label={t('edge.autoUpdate')}>
{node.auto_update_enabled ? '已启用' : '手动'} {node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow> </NodeInfoRow>
</div> </div>
</NodeSectionCard> </NodeSectionCard>
@@ -282,22 +293,22 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='edge' /> <InstallCommand node={node} variant='edge' />
<NodeSectionCard <NodeSectionCard
title='运行消息' title={t('edge.runtimeMessages')}
description='OpenResty 与 Agent 上报的状态说明' description={t('edge.runtimeMessagesDesc')}
> >
<div className='space-y-4 text-sm'> <div className='space-y-4 text-sm'>
<div> <div>
<p className='mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground'> <p className='mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground'>
OpenResty 状态消息 {t('edge.openrestyMessage')}
</p> </p>
<p className='rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap'> <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> </p>
</div> </div>
{node.restart_openresty_requested ? ( {node.restart_openresty_requested ? (
<div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'> <div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'>
<Activity className='size-4' /> <Activity className='size-4' />
已等待节点在下一次心跳后执行 OpenResty 重启。 {t('edge.restartPending')}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -313,28 +324,32 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
typeTone='info' typeTone='info'
statusBadges={[ statusBadges={[
{ {
label: getNodeStatusLabel(node.status), label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status), tone: getNodeStatusTone(node.status),
}, },
{ {
label: getOpenrestyStatusLabel(node.openresty_status), label: getOpenrestyStatusLabel(node.openresty_status, t),
tone: getOpenrestyStatusTone(node.openresty_status), tone: getOpenrestyStatusTone(node.openresty_status),
}, },
]} ]}
actions={headerActions} actions={headerActions}
kpis={[ kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint }, { label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{ label: 'Agent 版本', value: node.version || 'unknown', icon: Cpu },
{ {
label: '当前配置', label: t('edge.kpiAgent'),
value: node.current_version || '未应用', value: node.version || 'unknown',
icon: Cpu,
},
{
label: t('kpiCurrentConfig'),
value: node.current_version || t('notApplied'),
icon: Package, icon: Package,
}, },
{ {
label: '最近心跳', label: t('kpiLastSeen'),
value: isMeaningfulTime(node.last_seen_at) value: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at) ? formatRelativeTime(node.last_seen_at, t)
: '暂无', : t('na'),
icon: Activity, icon: Activity,
}, },
]} ]}
@@ -343,7 +358,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
nodeId={node.id} nodeId={node.id}
node={node} node={node}
variant='edge' variant='edge'
connectionHint='OpenResty 当前连接' connectionHint={t('edge.connectionHint')}
/> />
} }
manage={manageTab} manage={manageTab}
@@ -373,22 +388,21 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除节点「{node.name} {t('deleteDesc', { name: node.name })}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}> <AlertDialogCancel disabled={deleteMutation.isPending}>
取消 {tc('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90' className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()} onClick={() => deleteMutation.mutate()}
> >
{deleteMutation.isPending ? '删除中...' : '确认删除'} {deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Copy } from 'lucide-react'; import { Copy } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -28,7 +29,17 @@ import {
type InstallVariant = 'edge' | 'relay' | 'tunnel'; type InstallVariant = 'edge' | 'relay' | 'tunnel';
const variantMeta: Record< export function InstallCommand({
node,
variant,
}: {
node: NodeItem;
variant: InstallVariant;
}) {
const t = useTranslations('nodes.install');
const tn = useTranslations('nodes');
const [serverUrl, setServerUrl] = useState('');
const variantMeta: Record<
InstallVariant, InstallVariant,
{ {
title: string; title: string;
@@ -37,37 +48,28 @@ const variantMeta: Record<
scriptLabel?: string; scriptLabel?: string;
dockerLabel: string; dockerLabel: string;
} }
> = { > = {
edge: { edge: {
title: '边缘节点部署', title: t('edgeTitle'),
description: '使用 Agent Token 将边缘节点接入控制端。', description: t('edgeDesc'),
tokenLabel: 'Agent Token', tokenLabel: t('agentToken'),
dockerLabel: 'Docker 容器部署', dockerLabel: t('dockerLabel'),
}, },
relay: { relay: {
title: '中继部署命令', title: t('relayTitle'),
description: '使用 Discovery Token 将 frps 中继节点接入控制端。', description: t('relayDesc'),
tokenLabel: 'Discovery Token', tokenLabel: t('discoveryToken'),
scriptLabel: '一键脚本部署 (Linux / macOS)', scriptLabel: t('scriptLabel'),
dockerLabel: 'Docker 容器部署', dockerLabel: t('dockerLabel'),
}, },
tunnel: { tunnel: {
title: '隧道部署命令', title: t('tunnelTitle'),
description: '使用 Tunnel Token 将 openflared 客户端接入控制端。', description: t('tunnelDesc'),
tokenLabel: 'Tunnel Token', tokenLabel: t('tunnelToken'),
scriptLabel: '一键脚本部署 (Linux / macOS)', scriptLabel: t('scriptLabel'),
dockerLabel: 'Docker 容器部署', dockerLabel: t('dockerLabel'),
}, },
}; };
export function InstallCommand({
node,
variant,
}: {
node: NodeItem;
variant: InstallVariant;
}) {
const [serverUrl, setServerUrl] = useState('');
const meta = variantMeta[variant]; const meta = variantMeta[variant];
const statusQuery = useQuery({ const statusQuery = useQuery({
@@ -123,7 +125,7 @@ export function InstallCommand({
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
toast.success(message); toast.success(message);
} catch { } catch {
toast.error('复制失败,请手动选择命令文本。'); toast.error(t('copyFailed'));
} }
}; };
@@ -143,11 +145,11 @@ export function InstallCommand({
size='sm' size='sm'
className='h-7 text-xs' className='h-7 text-xs'
onClick={() => onClick={() =>
void handleCopy(scriptCommand, '部署脚本命令已复制') void handleCopy(scriptCommand, t('copiedScript'))
} }
> >
<Copy className='size-3.5 mr-1' /> <Copy className='size-3.5 mr-1' />
复制脚本 {t('copyScript')}
</Button> </Button>
) : null} ) : null}
{dockerCommand ? ( {dockerCommand ? (
@@ -156,11 +158,11 @@ export function InstallCommand({
size='sm' size='sm'
className='h-7 text-xs' className='h-7 text-xs'
onClick={() => onClick={() =>
void handleCopy(dockerCommand, 'Docker 部署命令已复制') void handleCopy(dockerCommand, t('copiedDocker'))
} }
> >
<Copy className='size-3.5 mr-1' /> <Copy className='size-3.5 mr-1' />
复制 Docker {t('copyDocker')}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -168,20 +170,20 @@ export function InstallCommand({
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'> <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> <p className='mt-1 text-sm break-all font-medium'>{node.node_id}</p>
</div> </div>
<div className='rounded-lg border px-3 py-3'> <div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground'>{meta.tokenLabel}</p> <p className='text-xs text-muted-foreground'>{meta.tokenLabel}</p>
<p className='mt-1 text-sm break-all font-medium'> <p className='mt-1 text-sm break-all font-medium'>
{node.access_token || '暂无'} {node.access_token || tn('na')}
</p> </p>
</div> </div>
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<Label htmlFor={`server-url-${variant}`}> <Label htmlFor={`server-url-${variant}`}>
Server URL(控制端地址) {t('serverUrl')}
</Label> </Label>
<Input <Input
id={`server-url-${variant}`} id={`server-url-${variant}`}
@@ -190,17 +192,17 @@ export function InstallCommand({
placeholder='https://openflare.example.com' placeholder='https://openflare.example.com'
/> />
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
部署脚本和容器将使用此地址连接 OpenFlare 控制端。 {t('serverUrlHint')}
</p> </p>
</div> </div>
{!node.access_token ? ( {!node.access_token ? (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
节点尚未生成接入 Token,请稍后刷新。 {t('noToken')}
</p> </p>
) : !normalizedServerUrl ? ( ) : !normalizedServerUrl ? (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
请填写有效的 Server URL 以生成部署命令。 {t('needServerUrl')}
</p> </p>
) : ( ) : (
<> <>
@@ -1,5 +1,7 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart'; import { TrendChart } from '@/components/data/trend-chart';
import { import {
Card, Card,
@@ -17,18 +19,23 @@ import {
export function NetworkTrendChart({ export function NetworkTrendChart({
points, points,
title = '24 小时网络趋势', title,
description = '按小时展示已提供/接收数据(访问日志);摘要为近 24 小时总量。', description,
}: { }: {
points: NetworkTrendPoint[]; points: NetworkTrendPoint[];
title?: string; title?: string;
description?: string; description?: string;
}) { }) {
const t = useTranslations('nodes.networkTrend');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle> <CardTitle className='text-sm font-semibold'>
<CardDescription className='text-xs'>{description}</CardDescription> {title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TrendChart <TrendChart
@@ -36,11 +43,11 @@ export function NetworkTrendChart({
formatTrendHour(point.bucket_started_at), formatTrendHour(point.bucket_started_at),
)} )}
summaryScope='total' summaryScope='total'
summaryHint='近 24 小时' summaryHint={t('summaryHint')}
yAxisValueFormatter={formatBytes} yAxisValueFormatter={formatBytes}
series={[ series={[
{ {
label: '接收数据', label: t('received'),
color: '#22c55e', color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)', fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area', variant: 'area',
@@ -48,7 +55,7 @@ export function NetworkTrendChart({
valueFormatter: formatBytes, valueFormatter: formatBytes,
}, },
{ {
label: '已提供数据', label: t('provided'),
color: '#38bdf8', color: '#38bdf8',
values: points.map((point) => point.bytes_provided), values: points.map((point) => point.bytes_provided),
valueFormatter: formatBytes, valueFormatter: formatBytes,
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { type ReactNode, useCallback, useMemo } from 'react'; import { type ReactNode, useCallback, useMemo } from 'react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -28,16 +29,7 @@ export type NodeDetailKpi = {
icon?: LucideIcon; icon?: LucideIcon;
}; };
const TAB_CONFIGS: NodeDetailTabConfig[] = [ const TAB_IDS: NodeDetailTabId[] = ['overview', 'manage'];
{
id: 'overview',
label: '概览',
},
{
id: 'manage',
label: '状态与部署',
},
];
function resolveTab(value: string | null): NodeDetailTabId | null { function resolveTab(value: string | null): NodeDetailTabId | null {
if (value === 'overview' || value === 'dashboard') { if (value === 'overview' || value === 'dashboard') {
@@ -70,6 +62,7 @@ export function NodeDetailShell({
manage: ReactNode; manage: ReactNode;
defaultTab?: NodeDetailTabId; defaultTab?: NodeDetailTabId;
}) { }) {
const t = useTranslations('nodes');
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -110,7 +103,7 @@ export function NodeDetailShell({
className='mt-0.5 h-8 w-8 shrink-0 p-0' className='mt-0.5 h-8 w-8 shrink-0 p-0'
asChild asChild
> >
<Link href='/nodes' aria-label='返回节点列表'> <Link href='/nodes' aria-label={t('backToList')}>
<ArrowLeft className='size-4' /> <ArrowLeft className='size-4' />
</Link> </Link>
</Button> </Button>
@@ -162,16 +155,16 @@ export function NodeDetailShell({
variant='line' variant='line'
className='h-auto w-full justify-start gap-6 bg-transparent p-0' className='h-auto w-full justify-start gap-6 bg-transparent p-0'
> >
{TAB_CONFIGS.map((tab) => ( {TAB_IDS.map((tabId) => (
<TabsTrigger <TabsTrigger
key={tab.id} key={tabId}
value={tab.id} value={tabId}
className={cn( className={cn(
'h-auto flex-none rounded-none px-0 pb-3 pt-1 text-sm font-semibold', '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', 'data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground',
)} )}
> >
{tab.label} {tabId === 'overview' ? t('tabOverview') : t('tabManage')}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
@@ -2,6 +2,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -30,10 +31,11 @@ import type {
NodeType, NodeType,
} from '@/lib/services/openflare'; } from '@/lib/services/openflare';
const nodeSchema = z function buildNodeSchema(t: (key: string) => string) {
return z
.object({ .object({
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']), node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z.string().trim().min(1, '请输入节点名称').max(255), name: z.string().trim().min(1, t('editor.errName')).max(255),
ip: z.string(), ip: z.string(),
ip_manual_override: z.boolean(), ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(), auto_update_enabled: z.boolean(),
@@ -49,7 +51,7 @@ const nodeSchema = z
context.addIssue({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['ip'], path: ['ip'],
message: '锁定 IP 时必须填写节点 IP', message: t('editor.errLockIp'),
}); });
} }
if (value.node_type === 'tunnel_relay') { if (value.node_type === 'tunnel_relay') {
@@ -59,20 +61,21 @@ const nodeSchema = z
context.addIssue({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['relay_bind_port'], path: ['relay_bind_port'],
message: '请输入有效的绑定端口', message: t('editor.errBindPort'),
}); });
} }
if (!Number.isFinite(vhostPort) || vhostPort <= 0) { if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
context.addIssue({ context.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'], path: ['relay_vhost_http_port'],
message: '请输入有效的 VHost 端口', message: t('editor.errVhostPort'),
}); });
} }
} }
}); });
}
type NodeFormValues = z.infer<typeof nodeSchema>; type NodeFormValues = z.infer<ReturnType<typeof buildNodeSchema>>;
const defaultForm: NodeFormValues = { const defaultForm: NodeFormValues = {
node_type: 'edge_node', node_type: 'edge_node',
@@ -156,8 +159,10 @@ export function NodeEditorDialog({
onClose: () => void; onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => Promise<void>; onSubmit: (payload: NodeMutationPayload) => Promise<void>;
}) { }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const form = useForm<NodeFormValues>({ const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema), resolver: zodResolver(buildNodeSchema(t)),
defaultValues: defaultForm, defaultValues: defaultForm,
}); });
@@ -179,10 +184,10 @@ export function NodeEditorDialog({
<Dialog open={open} onOpenChange={(next) => !next && onClose()}> <Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-lg'> <DialogContent className='sm:max-w-lg'>
<DialogHeader> <DialogHeader>
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle> <DialogTitle>
<DialogDescription> {node ? t('editor.editTitle') : t('editor.createTitle')}
预创建节点后可在详情页查看专属 Token 与部署命令。 </DialogTitle>
</DialogDescription> <DialogDescription>{t('editor.description')}</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
@@ -190,7 +195,7 @@ export function NodeEditorDialog({
className='space-y-4' className='space-y-4'
> >
<div className='space-y-2'> <div className='space-y-2'>
<Label>节点类型</Label> <Label>{t('editor.nodeType')}</Label>
<Select <Select
value={nodeType} value={nodeType}
disabled={Boolean(node)} disabled={Boolean(node)}
@@ -202,18 +207,22 @@ export function NodeEditorDialog({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='edge_node'>Edge 节点</SelectItem> <SelectItem value='edge_node'>{t('editor.typeEdge')}</SelectItem>
<SelectItem value='tunnel_relay'>Relay 节点</SelectItem> <SelectItem value='tunnel_relay'>
<SelectItem value='tunnel_client'>Tunnel 节点</SelectItem> {t('editor.typeRelay')}
</SelectItem>
<SelectItem value='tunnel_client'>
{t('editor.typeTunnel')}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<Label htmlFor='node-name'>节点名称</Label> <Label htmlFor='node-name'>{t('editor.name')}</Label>
<Input <Input
id='node-name' id='node-name'
placeholder='例如 edge-hk-01' placeholder={t('editor.namePlaceholder')}
{...form.register('name')} {...form.register('name')}
/> />
{form.formState.errors.name ? ( {form.formState.errors.name ? (
@@ -224,10 +233,10 @@ export function NodeEditorDialog({
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<Label htmlFor='node-ip'>节点 IP</Label> <Label htmlFor='node-ip'>{t('editor.ip')}</Label>
<Input <Input
id='node-ip' id='node-ip'
placeholder='可选,接入后自动上报' placeholder={t('editor.ipPlaceholder')}
{...form.register('ip')} {...form.register('ip')}
/> />
{form.formState.errors.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 className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div> <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'> <p className='text-xs text-muted-foreground'>
启用后管理端不再自动覆盖 IP {t('editor.lockIpDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -254,9 +263,9 @@ export function NodeEditorDialog({
<div className='flex items-center justify-between rounded-lg border px-3 py-2'> <div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div> <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'> <p className='text-xs text-muted-foreground'>
启用后节点将自动拉取正式版更新 {t('editor.autoUpdateDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -270,7 +279,7 @@ export function NodeEditorDialog({
{nodeType === 'tunnel_relay' ? ( {nodeType === 'tunnel_relay' ? (
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-2'> <div className='space-y-2'>
<Label htmlFor='relay-bind-port'>绑定端口</Label> <Label htmlFor='relay-bind-port'>{t('editor.bindPort')}</Label>
<Input <Input
id='relay-bind-port' id='relay-bind-port'
{...form.register('relay_bind_port')} {...form.register('relay_bind_port')}
@@ -282,7 +291,9 @@ export function NodeEditorDialog({
) : null} ) : null}
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<Label htmlFor='relay-vhost-port'>VHost 端口</Label> <Label htmlFor='relay-vhost-port'>
{t('editor.vhostPort')}
</Label>
<Input <Input
id='relay-vhost-port' id='relay-vhost-port'
{...form.register('relay_vhost_http_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 className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div> <div>
<p className='text-sm font-medium'>手动地图点位</p> <p className='text-sm font-medium'>{t('editor.manualGeo')}</p>
<p className='text-xs text-muted-foreground'>用于总览地图展示</p> <p className='text-xs text-muted-foreground'>
{t('editor.manualGeoDesc')}
</p>
</div> </div>
<Switch <Switch
checked={geoManualOverride} checked={geoManualOverride}
@@ -312,15 +325,15 @@ export function NodeEditorDialog({
{geoManualOverride ? ( {geoManualOverride ? (
<div className='grid gap-3 sm:grid-cols-3'> <div className='grid gap-3 sm:grid-cols-3'>
<div className='space-y-2 sm:col-span-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')} /> <Input id='geo-name' {...form.register('geo_name')} />
</div> </div>
<div className='space-y-2'> <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')} /> <Input id='geo-lat' {...form.register('geo_latitude')} />
</div> </div>
<div className='space-y-2 sm:col-span-2'> <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')} /> <Input id='geo-lng' {...form.register('geo_longitude')} />
</div> </div>
</div> </div>
@@ -333,10 +346,14 @@ export function NodeEditorDialog({
onClick={onClose} onClick={onClose}
disabled={submitting} disabled={submitting}
> >
取消 {tc('cancel')}
</Button> </Button>
<Button type='submit' disabled={submitting}> <Button type='submit' disabled={submitting}>
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'} {submitting
? t('editor.saving')
: node
? t('editor.saveEdit')
: t('editor.createAction')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -3,6 +3,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react'; import { Loader2, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -120,18 +121,19 @@ function SystemProfileCard({
profile: NodeSystemProfile; profile: NodeSystemProfile;
nodeName: string; nodeName: string;
}) { }) {
const t = useTranslations('nodes');
return ( return (
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-4 rounded-lg border px-4 py-4'> <div className='space-y-4 rounded-lg border px-4 py-4'>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
主机名 {t('obs.hostname')}
</p> </p>
<p className='mt-2 text-sm'>{profile.hostname || nodeName}</p> <p className='mt-2 text-sm'>{profile.hostname || nodeName}</p>
</div> </div>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
操作系统 {t('obs.os')}
</p> </p>
<p className='mt-2 text-sm'> <p className='mt-2 text-sm'>
{profile.os_name || 'unknown'} {profile.os_name || 'unknown'}
@@ -140,7 +142,7 @@ function SystemProfileCard({
</div> </div>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
内核 / 架构 {t('obs.kernelArch')}
</p> </p>
<p className='mt-2 text-sm'> <p className='mt-2 text-sm'>
{profile.kernel_version || 'unknown'} ·{' '} {profile.kernel_version || 'unknown'} ·{' '}
@@ -149,25 +151,27 @@ function SystemProfileCard({
</div> </div>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <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>
<p className='mt-2 text-sm'>{formatUptime(profile.uptime_seconds)}</p>
</div> </div>
</div> </div>
<div className='space-y-4 rounded-lg border px-4 py-4'> <div className='space-y-4 rounded-lg border px-4 py-4'>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
CPU {t('obs.cpu')}
</p> </p>
<p className='mt-2 text-sm'>{profile.cpu_model || 'unknown'}</p> <p className='mt-2 text-sm'>{profile.cpu_model || 'unknown'}</p>
<p className='mt-1 text-xs text-muted-foreground'> <p className='mt-1 text-xs text-muted-foreground'>
{profile.cpu_cores || 0} 核 {t('obs.cpuCores', { count: profile.cpu_cores || 0 })}
</p> </p>
</div> </div>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
总内存 {t('obs.totalMemory')}
</p> </p>
<p className='mt-2 text-sm'> <p className='mt-2 text-sm'>
{formatBytes(profile.total_memory_bytes)} {formatBytes(profile.total_memory_bytes)}
@@ -175,7 +179,7 @@ function SystemProfileCard({
</div> </div>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
总存储 {t('obs.totalStorage')}
</p> </p>
<p className='mt-2 text-sm'> <p className='mt-2 text-sm'>
{formatBytes(profile.total_disk_bytes)} {formatBytes(profile.total_disk_bytes)}
@@ -183,7 +187,7 @@ function SystemProfileCard({
</div> </div>
<div> <div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
上报时间 {t('obs.reportedAt')}
</p> </p>
<p className='mt-2 text-sm'> <p className='mt-2 text-sm'>
{isMeaningfulTime(profile.reported_at) {isMeaningfulTime(profile.reported_at)
@@ -211,16 +215,15 @@ function HealthEventTimeline({
onCleanup: () => void; onCleanup: () => void;
cleanupPending: boolean; cleanupPending: boolean;
}) { }) {
const t = useTranslations('nodes');
return ( return (
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader className='flex-row items-center justify-between space-y-0'> <CardHeader className='flex-row items-center justify-between space-y-0'>
<div> <div>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
健康事件时间线 {t('obs.eventsTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('obs.eventsDesc')}</CardDescription>
保留活动与已恢复事件,帮助判断运行状态。
</CardDescription>
</div> </div>
<Button <Button
variant='outline' variant='outline'
@@ -230,7 +233,7 @@ function HealthEventTimeline({
onClick={onCleanup} onClick={onCleanup}
> >
<Trash2 className='size-3.5 mr-1' /> <Trash2 className='size-3.5 mr-1' />
清理日志 {t('obs.cleanupLogs')}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
@@ -239,9 +242,9 @@ function HealthEventTimeline({
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{( {(
[ [
['all', '全部事件'], ['all', t('obs.filterAll')],
['active', '活动中'], ['active', t('obs.filterActive')],
['resolved', '已恢复'], ['resolved', t('obs.filterResolved')],
] as const ] as const
).map(([filter, label]) => ( ).map(([filter, label]) => (
<Button <Button
@@ -261,7 +264,7 @@ function HealthEventTimeline({
{events.length === 0 ? ( {events.length === 0 ? (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
当前筛选下没有健康事件。 {t('obs.filterEmpty')}
</p> </p>
) : null} ) : null}
@@ -276,28 +279,32 @@ function HealthEventTimeline({
tone={getHealthEventTone(event)} tone={getHealthEventTone(event)}
/> />
<NodeStatusBadge <NodeStatusBadge
label={event.status === 'active' ? '活动中' : '已恢复'} label={
event.status === 'active'
? t('obs.filterActive')
: t('obs.filterResolved')
}
tone={event.status === 'active' ? 'warning' : 'success'} tone={event.status === 'active' ? 'warning' : 'success'}
/> />
</div> </div>
<p className='mt-2 text-sm text-muted-foreground'> <p className='mt-2 text-sm text-muted-foreground'>
{event.message || '暂无详细消息'} {event.message || t('obs.noMessage')}
</p> </p>
<div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'> <div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'>
<p> <p>
首次触发: {t('obs.firstTriggered')}
{isMeaningfulTime(event.first_triggered_at) {isMeaningfulTime(event.first_triggered_at)
? ` ${formatDateTime(event.first_triggered_at)}` ? ` ${formatDateTime(event.first_triggered_at)}`
: ' —'} : ' —'}
</p> </p>
<p> <p>
最近触发: {t('obs.lastTriggered')}
{isMeaningfulTime(event.last_triggered_at) {isMeaningfulTime(event.last_triggered_at)
? ` ${formatDateTime(event.last_triggered_at)}` ? ` ${formatDateTime(event.last_triggered_at)}`
: ' —'} : ' —'}
</p> </p>
<p> <p>
恢复时间: {t('obs.resolvedAt')}
{isMeaningfulTime(event.resolved_at) && event.resolved_at {isMeaningfulTime(event.resolved_at) && event.resolved_at
? ` ${formatDateTime(event.resolved_at)}` ? ` ${formatDateTime(event.resolved_at)}`
: ' —'} : ' —'}
@@ -308,7 +315,7 @@ function HealthEventTimeline({
</> </>
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
节点当前还没有上报健康事件记录。 {t('obs.noEvents')}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -320,13 +327,16 @@ export function NodeObservability({
nodeId, nodeId,
node, node,
variant = 'edge', variant = 'edge',
connectionHint = 'OpenResty 当前连接', connectionHint,
}: { }: {
nodeId: number; nodeId: number;
node?: NodeItem; node?: NodeItem;
variant?: NodeObservabilityVariant; variant?: NodeObservabilityVariant;
connectionHint?: string; connectionHint?: string;
}) { }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const resolvedConnectionHint = connectionHint ?? t('edge.connectionHint');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [healthEventFilter, setHealthEventFilter] = const [healthEventFilter, setHealthEventFilter] =
useState<HealthEventFilter>('all'); useState<HealthEventFilter>('all');
@@ -344,8 +354,8 @@ export function NodeObservability({
onSuccess: async (result) => { onSuccess: async (result) => {
toast.success( toast.success(
result.deleted_count > 0 result.deleted_count > 0
? `已清理 ${result.deleted_count} 条健康事件日志` ? t('obs.cleaned', { count: result.deleted_count })
: '当前没有可清理的健康事件日志', : t('obs.nothingToClean'),
); );
setCleanupOpen(false); setCleanupOpen(false);
await Promise.all([ 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; const observability = observabilityQuery.data ?? null;
@@ -432,7 +442,7 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardContent className='flex items-center justify-center py-10 text-sm text-muted-foreground'> <CardContent className='flex items-center justify-center py-10 text-sm text-muted-foreground'>
<Loader2 className='size-4 mr-2 animate-spin' /> <Loader2 className='size-4 mr-2 animate-spin' />
加载运行观测数据中... {t('obs.loading')}
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -443,7 +453,7 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardContent className='py-6'> <CardContent className='py-6'>
<p className='text-sm text-destructive'> <p className='text-sm text-destructive'>
{getErrorMessage(observabilityQuery.error)} {getErrorMessage(observabilityQuery.error, t('requestFailed'))}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -455,20 +465,20 @@ export function NodeObservability({
<div className='space-y-6'> <div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-3'> <div className='grid gap-4 md:grid-cols-3'>
<SummaryStat <SummaryStat
label='运行诊断' label={t('obs.diag')}
value={ value={
activeHealthEvents.length activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常` ? t('obs.activeIssues', { count: activeHealthEvents.length })
: '运行稳定' : t('obs.stable')
} }
hint={ hint={
latestHealthEvent latestHealthEvent
? getHealthEventLabel(latestHealthEvent) ? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件' : t('obs.noActiveEvents')
} }
/> />
<SummaryStat <SummaryStat
label='系统核心' label={t('obs.systemCore')}
value={profile?.hostname || '—'} value={profile?.hostname || '—'}
hint={ hint={
profile profile
@@ -477,9 +487,9 @@ export function NodeObservability({
} }
/> />
<SummaryStat <SummaryStat
label='在线时长' label={t('obs.uptime')}
value={formatUptime(profile?.uptime_seconds)} value={formatUptime(profile?.uptime_seconds, t)}
hint='来自节点系统画像上报' hint={t('obs.uptimeFromProfile')}
/> />
</div> </div>
@@ -487,16 +497,16 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
系统画像 {t('obs.profileTitle')}
</CardTitle> </CardTitle>
<CardDescription>节点上报的主机与硬件信息</CardDescription> <CardDescription>{t('obs.profileDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{profile ? ( {profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} /> <SystemProfileCard profile={profile} nodeName={nodeName} />
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
节点已接入,但尚未上报完整系统画像。 {t('obs.noProfile')}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -505,45 +515,50 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
实时资源快照 {t('obs.snapshotTitle')}
</CardTitle> </CardTitle>
<CardDescription>最近一次 metrics 上报</CardDescription> <CardDescription>{t('obs.snapshotDesc')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{latestMetric ? ( {latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<MetricBar <MetricBar
label='CPU' label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)} value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent} progress={latestMetric.cpu_usage_percent}
hint={ hint={
isMeaningfulTime(latestMetric.captured_at) isMeaningfulTime(latestMetric.captured_at)
? `快照 ${formatRelativeTime(latestMetric.captured_at)}` ? t('obs.snapshotAgo', {
time: formatRelativeTime(
latestMetric.captured_at,
t,
),
})
: undefined : undefined
} }
/> />
<MetricBar <MetricBar
label='内存' label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`} value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio} progress={memoryUsageRatio}
/> />
<MetricBar <MetricBar
label='存储' label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`} value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio} progress={storageUsageRatio}
/> />
<MetricBar <MetricBar
label='连接数' label={t('obs.connections')}
value={formatMetricCount( value={formatMetricCount(
latestMetric.openresty_connections, latestMetric.openresty_connections,
)} )}
progress={null} progress={null}
hint={connectionHint} hint={resolvedConnectionHint}
/> />
</div> </div>
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
节点已接入,但尚未上报资源快照。 {t('obs.noSnapshot')}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -562,21 +577,23 @@ export function NodeObservability({
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}> <AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle> <AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。 {t('obs.cleanupDesc')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}> <AlertDialogCancel disabled={cleanupMutation.isPending}>
取消 {tc('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90' className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending} disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()} onClick={() => cleanupMutation.mutate()}
> >
{cleanupMutation.isPending ? '清理中...' : '确认清理'} {cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -589,43 +606,57 @@ export function NodeObservability({
<div className='space-y-6'> <div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'> <div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<SummaryStat <SummaryStat
label='运行诊断' label={t('obs.diag')}
value={ value={
activeHealthEvents.length activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常` ? t('obs.activeIssues', { count: activeHealthEvents.length })
: '运行稳定' : t('obs.stable')
} }
hint={ hint={
latestHealthEvent latestHealthEvent
? getHealthEventLabel(latestHealthEvent) ? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件' : t('obs.noActiveEvents')
} }
/> />
<SummaryStat <SummaryStat
label='查询窗口请求' label={t('obs.windowRequests')}
value={formatMetricCount(trafficSummary?.request_count)} value={formatMetricCount(trafficSummary?.request_count)}
hint={ hint={
trafficSummary 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 <SummaryStat
label='容量压力' label={t('obs.capacityPressure')}
value={healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'} value={
healthSummary?.has_capacity_risk
? t('obs.needsAttention')
: t('obs.normalRange')
}
hint={ hint={
latestMetric 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 <SummaryStat
label='来源信号' label={t('obs.sourceSignal')}
value={topSourceCountry?.key ?? '—'} value={topSourceCountry?.key ?? '—'}
hint={ hint={
topSourceCountry topSourceCountry
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求` ? t('obs.requestTimes', {
: '当前没有来源分布数据' count: topSourceCountry.value.toLocaleString('zh-CN'),
})
: t('obs.noSourceDist')
} }
/> />
</div> </div>
@@ -633,14 +664,16 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-3'> <div className='grid gap-6 xl:grid-cols-3'>
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base font-semibold'>系统信息</CardTitle> <CardTitle className='text-base font-semibold'>
{t('obs.systemInfo')}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{profile ? ( {profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} /> <SystemProfileCard profile={profile} nodeName={nodeName} />
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报完整系统画像。 {t('obs.noProfileLong')}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -648,41 +681,45 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base font-semibold'>实时资源</CardTitle> <CardTitle className='text-base font-semibold'>
{t('obs.realtimeResources')}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{latestMetric ? ( {latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<MetricBar <MetricBar
label='CPU' label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)} value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent} progress={latestMetric.cpu_usage_percent}
hint={ hint={
isMeaningfulTime(latestMetric.captured_at) isMeaningfulTime(latestMetric.captured_at)
? `快照 ${formatRelativeTime(latestMetric.captured_at)}` ? t('obs.snapshotAgo', {
time: formatRelativeTime(latestMetric.captured_at, t),
})
: undefined : undefined
} }
/> />
<MetricBar <MetricBar
label='内存' label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`} value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio} progress={memoryUsageRatio}
/> />
<MetricBar <MetricBar
label='存储' label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`} value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio} progress={storageUsageRatio}
/> />
<MetricBar <MetricBar
label='连接数' label={t('obs.connections')}
value={formatMetricCount(latestMetric.openresty_connections)} value={formatMetricCount(latestMetric.openresty_connections)}
progress={null} progress={null}
hint={connectionHint} hint={resolvedConnectionHint}
/> />
</div> </div>
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报资源快照。 {t('obs.noSnapshotLong')}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -690,7 +727,9 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base font-semibold'>网络流量</CardTitle> <CardTitle className='text-base font-semibold'>
{t('obs.networkTraffic')}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className='space-y-4'> <CardContent className='space-y-4'>
{latestMetric ? ( {latestMetric ? (
@@ -698,18 +737,20 @@ export function NodeObservability({
{node ? ( {node ? (
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<NodeStatusBadge <NodeStatusBadge
label={getNodeStatusLabel(node.status)} label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)} tone={getNodeStatusTone(node.status)}
/> />
<NodeStatusBadge <NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)} label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)} tone={getOpenrestyStatusTone(node.openresty_status)}
/> />
<NodeStatusBadge <NodeStatusBadge
label={ label={
activeHealthEvents.length activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常` ? t('obs.activeIssues', {
: '无活动异常' count: activeHealthEvents.length,
})
: t('obs.noActiveIssue')
} }
tone={activeHealthEvents.length ? 'warning' : 'success'} tone={activeHealthEvents.length ? 'warning' : 'success'}
/> />
@@ -719,16 +760,14 @@ export function NodeObservability({
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3 sm:col-span-2'> <div className='rounded-lg border px-3 py-3 sm:col-span-2'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
OpenResty 连接 {t('obs.openrestyConn')}
</p> </p>
<div className='mt-3 space-y-2 text-sm text-muted-foreground'> <div className='mt-3 space-y-2 text-sm text-muted-foreground'>
<p> <p>
当前连接: {t('obs.currentConn')}
{latestMetric.openresty_connections ?? 0} {latestMetric.openresty_connections ?? 0}
</p> </p>
<p className='text-xs'> <p className='text-xs'>{t('obs.throughputHint')}</p>
业务吞吐请看访问日志趋势(已提供/接收数据)
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -736,35 +775,42 @@ export function NodeObservability({
<div className='grid gap-3 sm:grid-cols-2'> <div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3'> <div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
查询窗口独立访客 {t('obs.windowVisitors')}
</p> </p>
<p className='mt-3 text-2xl font-semibold'> <p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.unique_visitor_count)} {formatMetricCount(trafficSummary?.unique_visitor_count)}
</p> </p>
<p className='mt-2 text-sm text-muted-foreground'> <p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary {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> </p>
</div> </div>
<div className='rounded-lg border px-3 py-3'> <div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
查询窗口错误 {t('obs.windowErrors')}
</p> </p>
<p className='mt-3 text-2xl font-semibold'> <p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.error_count)} {formatMetricCount(trafficSummary?.error_count)}
</p> </p>
<p className='mt-2 text-sm text-muted-foreground'> <p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary {trafficSummary
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%` ? t('obs.errorRate', {
: '暂无错误率摘要'} rate: trafficSummary.error_rate_percent.toFixed(1),
})
: t('obs.noErrorSummary')}
</p> </p>
</div> </div>
</div> </div>
</> </>
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报网络流量相关快照。 {t('obs.noNetworkSnapshot')}
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -774,11 +820,11 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<TrafficTrendChart <TrafficTrendChart
points={trends?.traffic_24h ?? []} points={trends?.traffic_24h ?? []}
description='按小时聚合该节点的请求量和错误量。' description={t('obs.trafficTrendDesc')}
/> />
<CapacityTrendChart <CapacityTrendChart
points={trends?.capacity_24h ?? []} points={trends?.capacity_24h ?? []}
description='观察该节点 CPU 与内存使用率在 24 小时内的变化。' description={t('obs.capacityTrendDesc')}
/> />
</div> </div>
@@ -791,50 +837,47 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'> <Card className='border-dashed shadow-none'>
<CardHeader> <CardHeader>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
请求结构分布 {t('obs.structureTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{t('obs.structureDesc')}</CardDescription>
聚合最近 24
小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className='mb-6 grid gap-4 md:grid-cols-3'> <div className='mb-6 grid gap-4 md:grid-cols-3'>
<div className='rounded-lg border px-4 py-4'> <div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
主状态码 {t('obs.mainStatus')}
</p> </p>
<p className='mt-3 text-2xl font-semibold'> <p className='mt-3 text-2xl font-semibold'>
{dominantStatusCode?.label ?? '—'} {dominantStatusCode?.label ?? '—'}
</p> </p>
<p className='mt-2 text-sm text-muted-foreground'> <p className='mt-2 text-sm text-muted-foreground'>
{dominantStatusCode {dominantStatusCode
? `${dominantStatusCode.value} 次` ? t('obs.times', { count: dominantStatusCode.value })
: '暂无状态码分布'} : t('obs.noStatusDist')}
</p> </p>
</div> </div>
<div className='rounded-lg border px-4 py-4'> <div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
Top Domain {t('obs.topDomain')}
</p> </p>
<p className='mt-3 truncate text-2xl font-semibold'> <p className='mt-3 truncate text-2xl font-semibold'>
{dominantDomain?.label ?? '—'} {dominantDomain?.label ?? '—'}
</p> </p>
<p className='mt-2 text-sm text-muted-foreground'> <p className='mt-2 text-sm text-muted-foreground'>
{dominantDomain {dominantDomain
? `${dominantDomain.value} 次` ? t('obs.times', { count: dominantDomain.value })
: '暂无域名分布'} : t('obs.noDomainDist')}
</p> </p>
</div> </div>
<div className='rounded-lg border px-4 py-4'> <div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'> <p className='text-xs text-muted-foreground uppercase tracking-wide'>
已恢复事件 {t('obs.resolvedEvents')}
</p> </p>
<p className='mt-3 text-2xl font-semibold'> <p className='mt-3 text-2xl font-semibold'>
{resolvedHealthEvents.length} {resolvedHealthEvents.length}
</p> </p>
<p className='mt-2 text-sm text-muted-foreground'> <p className='mt-2 text-sm text-muted-foreground'>
最近 24 小时已恢复健康事件 {t('obs.resolvedHint')}
</p> </p>
</div> </div>
</div> </div>
@@ -842,20 +885,20 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<div> <div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'> <p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
状态码分布 {t('obs.statusDist')}
</p> </p>
<DistributionList <DistributionList
items={statusCodeDistribution} items={statusCodeDistribution}
emptyMessage='暂无状态码分布' emptyMessage={t('obs.noStatusDist')}
/> />
</div> </div>
<div> <div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'> <p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
Top Domain {t('obs.topDomain')}
</p> </p>
<DistributionList <DistributionList
items={topDomains} items={topDomains}
emptyMessage='暂无域名分布' emptyMessage={t('obs.noDomainDist')}
/> />
</div> </div>
</div> </div>
@@ -875,21 +918,23 @@ export function NodeObservability({
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}> <AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle> <AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。 {t('obs.cleanupDesc')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}> <AlertDialogCancel disabled={cleanupMutation.isPending}>
取消 {tc('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90' className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending} disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()} onClick={() => cleanupMutation.mutate()}
> >
{cleanupMutation.isPending ? '清理中...' : '确认清理'} {cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -2,16 +2,17 @@
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel'; export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
const filters: Array<{ key: NodeFilter; label: string; href: string }> = [ const filters: Array<{ key: NodeFilter; href: string }> = [
{ key: 'all', label: '全部节点', href: '/nodes' }, { key: 'all', href: '/nodes' },
{ key: 'edge', label: 'Edge', href: '/nodes?filter=edge' }, { key: 'edge', href: '/nodes?filter=edge' },
{ key: 'relay', label: 'Relay', href: '/nodes?filter=relay' }, { key: 'relay', href: '/nodes?filter=relay' },
{ key: 'tunnel', label: 'Tunnel', href: '/nodes?filter=tunnel' }, { key: 'tunnel', href: '/nodes?filter=tunnel' },
]; ];
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter { 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) { switch (filter) {
case 'relay': case 'relay':
return '当前仅展示 Relay 节点。'; return t('filter.descRelay');
case 'tunnel': case 'tunnel':
return '当前仅展示 Tunnel 节点。'; return t('filter.descTunnel');
case 'edge': case 'edge':
return '当前仅展示 Edge 节点。'; return t('filter.descEdge');
case 'all': case 'all':
default: default:
return '当前展示全部节点。'; return t('filter.descAll');
} }
} }
export function NodeTypeFilter() { export function NodeTypeFilter() {
const t = useTranslations('nodes');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const activeFilter = getNodeFilter(searchParams); const activeFilter = getNodeFilter(searchParams);
const filterLabels: Record<NodeFilter, string> = {
all: t('filter.all'),
edge: 'Edge',
relay: 'Relay',
tunnel: 'Tunnel',
};
return ( return (
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
@@ -75,7 +86,7 @@ export function NodeTypeFilter() {
: 'border-border text-muted-foreground hover:bg-muted/50', : 'border-border text-muted-foreground hover:bg-muted/50',
)} )}
> >
{item.label} {filterLabels[item.key]}
</Link> </Link>
))} ))}
</div> </div>
@@ -11,6 +11,11 @@ export const FLARED_WS_CONNECTED_LAST_SEEN =
export type StatusTone = 'success' | 'warning' | 'danger' | 'info'; 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) { export function isWSConnectedLastSeen(value: string | null | undefined) {
return ( return (
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN 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); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return value; return value;
@@ -35,16 +40,16 @@ export function formatRelativeTime(value: string) {
const diffMs = Date.now() - date.getTime(); const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000); const diffMinutes = Math.floor(diffMs / 60_000);
if (diffMinutes < 1) return '刚刚'; if (diffMinutes < 1) return t('relative.justNow');
if (diffMinutes < 60) return `${diffMinutes} 分钟前`; if (diffMinutes < 60) return t('relative.minutesAgo', { count: diffMinutes });
const diffHours = Math.floor(diffMinutes / 60); 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); 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 { export function getNodeStatusTone(status: NodeStatus): StatusTone {
@@ -53,10 +58,10 @@ export function getNodeStatusTone(status: NodeStatus): StatusTone {
return 'danger'; return 'danger';
} }
export function getNodeStatusLabel(status: NodeStatus) { export function getNodeStatusLabel(status: NodeStatus, t: NodeMessageT) {
if (status === 'online') return '在线'; if (status === 'online') return t('status.online');
if (status === 'pending') return '待接入'; if (status === 'pending') return t('status.pending');
return '离线'; return t('status.offline');
} }
export function getApplyTone(result: ApplyResult): StatusTone { export function getApplyTone(result: ApplyResult): StatusTone {
@@ -66,11 +71,11 @@ export function getApplyTone(result: ApplyResult): StatusTone {
return 'warning'; return 'warning';
} }
export function getApplyLabel(result: ApplyResult) { export function getApplyLabel(result: ApplyResult, t: NodeMessageT) {
if (result === 'success') return '成功'; if (result === 'success') return t('apply.success');
if (result === 'warning') return '警告'; if (result === 'warning') return t('apply.warning');
if (result === 'failed') return '失败'; if (result === 'failed') return t('apply.failed');
return '暂无'; return t('apply.none');
} }
export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone { export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
@@ -79,10 +84,13 @@ export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
return 'warning'; return 'warning';
} }
export function getOpenrestyStatusLabel(status: OpenrestyStatus) { export function getOpenrestyStatusLabel(
if (status === 'healthy') return '健康'; status: OpenrestyStatus,
if (status === 'unhealthy') return '异常'; t: NodeMessageT,
return '未知'; ) {
if (status === 'healthy') return t('health.healthy');
if (status === 'unhealthy') return t('health.unhealthy');
return t('health.unknown');
} }
export function getRelayStatusTone( export function getRelayStatusTone(
@@ -93,10 +101,13 @@ export function getRelayStatusTone(
return 'warning'; return 'warning';
} }
export function getRelayStatusLabel(status: string | null | undefined) { export function getRelayStatusLabel(
if (status === 'healthy') return '健康'; status: string | null | undefined,
if (status === 'unhealthy') return '异常'; t: NodeMessageT,
return '未知'; ) {
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']) { export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
@@ -105,8 +116,8 @@ export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
return 'Edge'; return 'Edge';
} }
export function getErrorMessage(error: unknown) { export function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; return error instanceof Error ? error.message : fallback;
} }
export function getServerUrl(value: string) { 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)); 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) { if (!seconds || seconds <= 0) {
return '—'; return '—';
} }
@@ -274,12 +288,12 @@ export function formatUptime(seconds?: number | null) {
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) { if (days > 0) {
return `${days} 天 ${hours} 小时`; return t('uptime.daysHours', { days, hours });
} }
if (hours > 0) { if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`; return t('uptime.hoursMinutes', { hours, minutes });
} }
return `${minutes} 分钟`; return t('uptime.minutes', { minutes });
} }
export function getHealthEventTone(event: { export function getHealthEventTone(event: {
@@ -302,17 +316,17 @@ export function getHealthEventLabel(event: { event_type: string }) {
return event.event_type.replaceAll('_', ' '); return event.event_type.replaceAll('_', ' ');
} }
export function getFlaredStatusLabel(node: NodeItem) { export function getFlaredStatusLabel(node: NodeItem, t: NodeMessageT) {
if (isWSConnectedLastSeen(node.last_seen_at)) { if (isWSConnectedLastSeen(node.last_seen_at)) {
return 'WS 已连接'; return t('status.wsConnected');
} }
if (node.status === 'online') { if (node.status === 'online') {
return '运行中'; return t('status.running');
} }
if (node.status === 'pending') { if (node.status === 'pending') {
return '待接入'; return t('status.pending');
} }
return '离线'; return t('status.offline');
} }
export function getFlaredStatusTone(node: NodeItem): StatusTone { export function getFlaredStatusTone(node: NodeItem): StatusTone {
@@ -2,6 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { Pencil, Trash2 } from 'lucide-react'; import { Pencil, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -42,19 +43,24 @@ export function NodesTable({
onEdit: (node: NodeItem) => void; onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void; onDelete: (node: NodeItem) => void;
}) { }) {
const t = useTranslations('nodes');
return ( return (
<div className='border border-dashed rounded-lg overflow-hidden'> <div className='border border-dashed rounded-lg overflow-hidden'>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className='border-dashed hover:bg-transparent'> <TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>节点</TableHead> <TableHead className='py-2 h-8'>{t('table.node')}</TableHead>
<TableHead className='py-2 h-8'>状态</TableHead> <TableHead className='py-2 h-8'>{t('table.status')}</TableHead>
<TableHead className='py-2 h-8'>Version</TableHead> <TableHead className='py-2 h-8'>{t('table.version')}</TableHead>
<TableHead className='py-2 h-8'>运行健康</TableHead> <TableHead className='py-2 h-8'>{t('table.runHealth')}</TableHead>
<TableHead className='py-2 h-8'>当前版本</TableHead> <TableHead className='py-2 h-8'>
<TableHead className='py-2 h-8'>最近应用</TableHead> {t('table.currentVersion')}
<TableHead className='py-2 h-8'>最近心跳</TableHead> </TableHead>
<TableHead className='py-2 h-8 text-right'>操作</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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -70,17 +76,19 @@ export function NodesTable({
/> />
</div> </div>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
IP:{node.ip || '—'} {t('ipLine', { ip: node.ip || '—' })}
{node.ip_manual_override ? '(已锁定)' : ''} {node.ip_manual_override ? t('lockedSuffix') : ''}
</p> </p>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
位置:{node.geo_name || '未配置地图点位'} {t('locationLine', {
location: node.geo_name || t('noGeo'),
})}
</p> </p>
</div> </div>
</TableCell> </TableCell>
<TableCell className='py-3'> <TableCell className='py-3'>
<NodeStatusBadge <NodeStatusBadge
label={getNodeStatusLabel(node.status)} label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)} tone={getNodeStatusTone(node.status)}
/> />
</TableCell> </TableCell>
@@ -90,41 +98,45 @@ export function NodesTable({
<TableCell className='py-3'> <TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? ( {node.node_type === 'tunnel_relay' ? (
<NodeStatusBadge <NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)} label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)} tone={getRelayStatusTone(node.relay_status)}
/> />
) : node.node_type === 'tunnel_client' ? ( ) : node.node_type === 'tunnel_client' ? (
<NodeStatusBadge <NodeStatusBadge
label={node.status === 'online' ? '运行中' : '未知'} label={
node.status === 'online' ? t('running') : t('unknown')
}
tone={node.status === 'online' ? 'success' : 'warning'} tone={node.status === 'online' ? 'success' : 'warning'}
/> />
) : ( ) : (
<NodeStatusBadge <NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)} label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)} tone={getOpenrestyStatusTone(node.openresty_status)}
/> />
)} )}
</TableCell> </TableCell>
<TableCell className='py-3 text-muted-foreground'> <TableCell className='py-3 text-muted-foreground'>
{node.current_version || {node.current_version ||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')} (node.node_type === 'tunnel_relay'
? t('liveConfig')
: t('notApplied'))}
</TableCell> </TableCell>
<TableCell className='py-3'> <TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? ( {node.node_type === 'tunnel_relay' ? (
<span className='text-sm text-muted-foreground'>—</span> <span className='text-sm text-muted-foreground'>—</span>
) : ( ) : (
<NodeStatusBadge <NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)} label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)} tone={getApplyTone(node.latest_apply_result)}
/> />
)} )}
</TableCell> </TableCell>
<TableCell className='py-3 text-muted-foreground'> <TableCell className='py-3 text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at) {isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接' ? t('wsConnected')
: isMeaningfulTime(node.last_seen_at) : 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>
<TableCell className='py-3'> <TableCell className='py-3'>
<div className='flex flex-wrap justify-end gap-1'> <div className='flex flex-wrap justify-end gap-1'>
@@ -134,7 +146,9 @@ export function NodesTable({
className='h-7 text-xs' className='h-7 text-xs'
asChild asChild
> >
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link> <Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -143,7 +157,7 @@ export function NodesTable({
onClick={() => onEdit(node)} onClick={() => onEdit(node)}
> >
<Pencil className='size-3 mr-1' /> <Pencil className='size-3 mr-1' />
编辑 {t('edit')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -153,7 +167,7 @@ export function NodesTable({
onClick={() => onDelete(node)} onClick={() => onDelete(node)}
> >
<Trash2 className='size-3 mr-1' /> <Trash2 className='size-3 mr-1' />
删除 {t('delete')}
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -18,6 +18,7 @@ import {
Trash2, Trash2,
Upload, Upload,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -78,6 +79,8 @@ function systemConfigMap(configs: SystemConfig[]) {
} }
export function RelayNodeDetail({ node }: { node: NodeItem }) { export function RelayNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
@@ -106,7 +109,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
mutationFn: async (port: string) => { mutationFn: async (port: string) => {
const portNum = parseInt(port, 10); const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) { 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']; const portCfg = configs['relay_frps_web_ui_port'];
await services.adminSystemConfig.updateSystemConfig( await services.adminSystemConfig.updateSystemConfig(
@@ -121,21 +124,23 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'], queryKey: ['admin', 'system-configs'],
}); });
toast.success('FRPS WebUI 端口配置已更新'); toast.success(t('relay.toastPortSaved'));
}, },
onError: (error) => onError: (error) =>
toast.error(error instanceof Error ? error.message : '更新端口失败'), toast.error(
error instanceof Error ? error.message : t('relay.portSaveFailed'),
),
}); });
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) => mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload), NodeService.updateNode(node.id, payload),
onSuccess: async () => { onSuccess: async () => {
toast.success('中继节点已更新'); toast.success(t('relay.toastUpdated'));
setEditorOpen(false); setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const webServerMutation = useMutation({ const webServerMutation = useMutation({
@@ -155,19 +160,19 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
relay_web_server_enabled: enabled, relay_web_server_enabled: enabled,
}), }),
onSuccess: async () => { onSuccess: async () => {
toast.success('FRPS WebUI 设置已更新'); toast.success(t('relay.toastWebui'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const forceSyncMutation = useMutation({ const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id), mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => { onSuccess: async (updated) => {
toast.success(`已向中继节点 ${updated.name} 下发强制同步指令`); toast.success(t('relay.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const upgradeMutation = useMutation({ const upgradeMutation = useMutation({
@@ -187,22 +192,28 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
}), }),
onSuccess: async (updated) => { onSuccess: async (updated) => {
toast.success( 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); setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id), mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => { onSuccess: async () => {
toast.success('中继节点已删除'); toast.success(t('relay.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes'); router.push('/nodes');
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const handleRefresh = () => { const handleRefresh = () => {
@@ -236,7 +247,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
className='h-8' className='h-8'
onClick={() => setEditorOpen(true)} onClick={() => setEditorOpen(true)}
> >
编辑 {t('edit')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -245,7 +256,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh} onClick={handleRefresh}
> >
<RefreshCw className='size-3.5 mr-1.5' /> <RefreshCw className='size-3.5 mr-1.5' />
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -255,7 +266,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()} onClick={() => forceSyncMutation.mutate()}
> >
<RotateCcw className='size-3.5 mr-1.5' /> <RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'} {forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button> </Button>
<Button <Button
variant='secondary' variant='secondary'
@@ -264,7 +275,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)} onClick={() => setUpgradeOpen(true)}
> >
<Upload className='size-3.5 mr-1.5' /> <Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 Relay'} {node.update_requested ? t('viewUpgrade') : t('relay.upgrade')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -273,7 +284,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
> >
<Trash2 className='size-3.5 mr-1.5' /> <Trash2 className='size-3.5 mr-1.5' />
删除 {t('delete')}
</Button> </Button>
</> </>
); );
@@ -284,76 +295,76 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard <NodeSectionCard
title='中继运行状态' title={t('relay.runStatus')}
description='frps 在线情况与负载摘要' description={t('relay.runStatusDesc')}
> >
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='运行状态'> <NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge <NodeStatusBadge
label={getNodeStatusLabel(node.status)} label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)} tone={getNodeStatusTone(node.status)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='中继健康'> <NodeInfoRow label={t('relay.health')}>
<NodeStatusBadge <NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)} label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)} tone={getRelayStatusTone(node.relay_status)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近心跳'> <NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at) {isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接' ? t('wsConnected')
: isMeaningfulTime(node.last_seen_at) : 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>
<NodeInfoRow label='活动连接 / 代理数'> <NodeInfoRow label={t('relay.connections')}>
{node.relay_frps_connections ?? '—'} /{' '} {node.relay_frps_connections ?? '—'} /{' '}
{node.relay_frps_proxy_count ?? '—'} {node.relay_frps_proxy_count ?? '—'}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='IP 地址'> <NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'} {node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''} {node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow> </NodeInfoRow>
</div> </div>
</NodeSectionCard> </NodeSectionCard>
<NodeSectionCard <NodeSectionCard
title='网络端口' title={t('relay.portsTitle')}
description='frps 控制面与 HTTP 虚拟主机配置' description={t('relay.portsDesc')}
action={ action={
<Button variant='outline' size='sm' className='h-8' asChild> <Button variant='outline' size='sm' className='h-8' asChild>
<Link <Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`} href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
> >
<FileText className='size-3.5 mr-1.5' /> <FileText className='size-3.5 mr-1.5' />
应用记录 {t('applyLogs')}
</Link> </Link>
</Button> </Button>
} }
> >
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='绑定控制端口'> <NodeInfoRow label={t('relay.bindPort')}>
{node.relay_bind_port || '—'} {node.relay_bind_port || '—'}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='VHost HTTP 端口'> <NodeInfoRow label={t('relay.vhostPort')}>
{node.relay_vhost_http_port || '—'} {node.relay_vhost_http_port || '—'}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='Agent 接入地址'> <NodeInfoRow label={t('relay.agentAddr')}>
<span className='break-all'> <span className='break-all'>
{node.relay_agent_access_addr || '—'} {node.relay_agent_access_addr || '—'}
</span> </span>
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近应用'> <NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge <NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)} label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)} tone={getApplyTone(node.latest_apply_result)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='Relay 版本'> <NodeInfoRow label={t('relay.relayVersion')}>
{node.version || 'unknown'} {node.version || 'unknown'}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='frps 核心版本'> <NodeInfoRow label={t('relay.frpsVersion')}>
{node.ext_version || 'unknown'} {node.ext_version || 'unknown'}
</NodeInfoRow> </NodeInfoRow>
</div> </div>
@@ -361,15 +372,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</div> </div>
<NodeSectionCard <NodeSectionCard
title='FRPS WebUI' title={t('relay.webuiTitle')}
description='控制 frps 内置 Web 管理界面是否启用及其监听端口' description={t('relay.webuiDesc')}
> >
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex items-center justify-between rounded-xl border px-4 py-4'> <div className='flex items-center justify-between rounded-xl border px-4 py-4'>
<div className='space-y-1'> <div className='space-y-1'>
<Label>启用 Web 管理界面</Label> <Label>{t('relay.enableWebui')}</Label>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
开启后,节点将启动 frps 的内置管理服务。 {t('relay.enableWebuiHint')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -381,7 +392,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<div className='rounded-xl border p-4 space-y-3'> <div className='rounded-xl border p-4 space-y-3'>
<Label htmlFor='web_server_port' className='text-xs font-semibold'> <Label htmlFor='web_server_port' className='text-xs font-semibold'>
Web 管理界面端口 {t('relay.webuiPort')}
</Label> </Label>
<div className='flex max-w-sm items-center gap-2'> <div className='flex max-w-sm items-center gap-2'>
<Input <Input
@@ -391,7 +402,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
max={65535} max={65535}
value={webServerPort} value={webServerPort}
onChange={(e) => setWebServerPort(e.target.value)} onChange={(e) => setWebServerPort(e.target.value)}
placeholder='例如: 17500' placeholder={t('relay.webuiPortPlaceholder')}
className='bg-card text-xs h-9' className='bg-card text-xs h-9'
disabled={ disabled={
savePortMutation.isPending || systemConfigsQuery.isLoading savePortMutation.isPending || systemConfigsQuery.isLoading
@@ -408,13 +419,12 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
{savePortMutation.isPending ? ( {savePortMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' /> <Loader2 className='size-3.5 animate-spin' />
) : ( ) : (
'保存' tc('save')
)} )}
</Button> </Button>
</div> </div>
<p className='text-[11px] text-muted-foreground'> <p className='text-[11px] text-muted-foreground'>
当前实际监听端口:{resolvedPort}{' '} {t('relay.actualPort', { port: resolvedPort })}
(修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
</p> </p>
</div> </div>
@@ -426,7 +436,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
rel='noopener noreferrer' rel='noopener noreferrer'
className='inline-flex items-center font-medium text-primary hover:underline' className='inline-flex items-center font-medium text-primary hover:underline'
> >
打开 FRPS WebUI {t('relay.openWebui')}
<ExternalLink className='size-3.5 ml-1.5' /> <ExternalLink className='size-3.5 ml-1.5' />
</a> </a>
<span className='text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border'> <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' className='size-7 text-muted-foreground hover:text-foreground'
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(webUiUrl); void navigator.clipboard.writeText(webUiUrl);
toast.success('链接已复制到剪贴板'); toast.success(t('relay.copiedLink'));
}} }}
title='复制链接' title={t('relay.copyLink')}
> >
<Copy className='size-3.5' /> <Copy className='size-3.5' />
</Button> </Button>
</div> </div>
) : ( ) : (
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
WebUI 已禁用或未配置绑定端口。 {t('relay.webuiDisabled')}
</p> </p>
)} )}
</div> </div>
@@ -455,15 +465,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='relay' /> <InstallCommand node={node} variant='relay' />
<NodeSectionCard title='节点元数据'> <NodeSectionCard title={t('relay.metadata')}>
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='节点 ID'> <NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span> <span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='创建时间'> <NodeInfoRow label={t('relay.createdAt')}>
{formatDateTime(node.created_at)} {formatDateTime(node.created_at)}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='更新时间'> <NodeInfoRow label={t('relay.updatedAt')}>
{formatDateTime(node.updated_at)} {formatDateTime(node.updated_at)}
</NodeInfoRow> </NodeInfoRow>
</div> </div>
@@ -479,34 +489,34 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
typeTone='warning' typeTone='warning'
statusBadges={[ statusBadges={[
{ {
label: getNodeStatusLabel(node.status), label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status), tone: getNodeStatusTone(node.status),
}, },
{ {
label: getRelayStatusLabel(node.relay_status), label: getRelayStatusLabel(node.relay_status, t),
tone: getRelayStatusTone(node.relay_status), tone: getRelayStatusTone(node.relay_status),
}, },
]} ]}
actions={headerActions} actions={headerActions}
kpis={[ 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', value: node.version || 'unknown',
icon: Server, icon: Server,
}, },
{ {
label: 'frps 核心', label: t('relay.kpiFrps'),
value: node.ext_version || 'unknown', value: node.ext_version || 'unknown',
icon: Network, icon: Network,
}, },
{ {
label: '最近心跳', label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at) value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接' ? t('wsConnected')
: isMeaningfulTime(node.last_seen_at) : isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at) ? formatRelativeTime(node.last_seen_at, t)
: '暂无', : t('na'),
icon: Activity, icon: Activity,
}, },
]} ]}
@@ -514,7 +524,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<NodeObservability <NodeObservability
nodeId={node.id} nodeId={node.id}
variant='compact' variant='compact'
connectionHint='中继承载活动连接数' connectionHint={t('relay.connectionHint')}
/> />
} }
manage={manageTab} manage={manageTab}
@@ -544,22 +554,21 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle> <AlertDialogTitle>{t('relay.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除中继节点「{node.name} {t('relay.deleteDesc', { name: node.name })}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}> <AlertDialogCancel disabled={deleteMutation.isPending}>
取消 {tc('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90' className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()} onClick={() => deleteMutation.mutate()}
> >
{deleteMutation.isPending ? '删除中...' : '确认删除'} {deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -15,6 +15,7 @@ import {
Trash2, Trash2,
Upload, Upload,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -63,6 +64,8 @@ import {
const nodesQueryKey = ['openflare', 'nodes']; const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) { export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
@@ -73,20 +76,20 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) => mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload), NodeService.updateNode(node.id, payload),
onSuccess: async () => { onSuccess: async () => {
toast.success('隧道节点已更新'); toast.success(t('tunnel.toastUpdated'));
setEditorOpen(false); setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const forceSyncMutation = useMutation({ const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id), mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => { onSuccess: async (updated) => {
toast.success(`已向隧道节点 ${updated.name} 下发强制同步指令`); toast.success(t('tunnel.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const upgradeMutation = useMutation({ const upgradeMutation = useMutation({
@@ -106,22 +109,28 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
}), }),
onSuccess: async (updated) => { onSuccess: async (updated) => {
toast.success( 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); setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id), mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => { onSuccess: async () => {
toast.success('隧道节点已删除'); toast.success(t('tunnel.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes'); router.push('/nodes');
}, },
onError: (error) => toast.error(getErrorMessage(error)), onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
}); });
const handleRefresh = () => { const handleRefresh = () => {
@@ -141,7 +150,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
className='h-8' className='h-8'
onClick={() => setEditorOpen(true)} onClick={() => setEditorOpen(true)}
> >
编辑 {t('edit')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -150,7 +159,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh} onClick={handleRefresh}
> >
<RefreshCw className='size-3.5 mr-1.5' /> <RefreshCw className='size-3.5 mr-1.5' />
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -160,7 +169,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()} onClick={() => forceSyncMutation.mutate()}
> >
<RotateCcw className='size-3.5 mr-1.5' /> <RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'} {forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button> </Button>
<Button <Button
variant='secondary' variant='secondary'
@@ -169,7 +178,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)} onClick={() => setUpgradeOpen(true)}
> >
<Upload className='size-3.5 mr-1.5' /> <Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 openflared'} {node.update_requested ? t('viewUpgrade') : t('tunnel.upgrade')}
</Button> </Button>
<Button <Button
variant='outline' variant='outline'
@@ -178,7 +187,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
> >
<Trash2 className='size-3.5 mr-1.5' /> <Trash2 className='size-3.5 mr-1.5' />
删除 {t('delete')}
</Button> </Button>
</> </>
); );
@@ -189,81 +198,83 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard <NodeSectionCard
title='隧道运行状态' title={t('tunnel.runStatus')}
description='openflared 连接与在线情况' description={t('tunnel.runStatusDesc')}
> >
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='运行状态'> <NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge <NodeStatusBadge
label={getNodeStatusLabel(node.status)} label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)} tone={getNodeStatusTone(node.status)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='flared 状态'> <NodeInfoRow label={t('tunnel.flaredStatus')}>
<NodeStatusBadge <NodeStatusBadge
label={getFlaredStatusLabel(node)} label={getFlaredStatusLabel(node, t)}
tone={getFlaredStatusTone(node)} tone={getFlaredStatusTone(node)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近心跳'> <NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at) {isWSConnectedLastSeen(node.last_seen_at)
? 'WebSocket 长连接已建立' ? t('wsEstablished')
: isMeaningfulTime(node.last_seen_at) : 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>
<NodeInfoRow label='IP 地址'> <NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'} {node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''} {node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='自动更新'> <NodeInfoRow label={t('tunnel.autoUpdate')}>
{node.auto_update_enabled ? '已启用' : '手动'} {node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow> </NodeInfoRow>
</div> </div>
</NodeSectionCard> </NodeSectionCard>
<NodeSectionCard <NodeSectionCard
title='配置同步' title={t('tunnel.syncTitle')}
description='隧道客户端配置追平状态' description={t('tunnel.syncDesc')}
action={ action={
<Button variant='outline' size='sm' className='h-8' asChild> <Button variant='outline' size='sm' className='h-8' asChild>
<Link <Link
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`} href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
> >
<FileText className='size-3.5 mr-1.5' /> <FileText className='size-3.5 mr-1.5' />
应用记录 {t('applyLogs')}
</Link> </Link>
</Button> </Button>
} }
> >
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='当前配置版本'> <NodeInfoRow label={t('tunnel.currentVersion')}>
{node.current_version || '未应用'} {node.current_version || t('notApplied')}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近应用'> <NodeInfoRow label={t('tunnel.latestApply')}>
<NodeStatusBadge <NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)} label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)} tone={getApplyTone(node.latest_apply_result)}
/> />
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='最近应用时间'> <NodeInfoRow label={t('tunnel.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at) {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>
{node.latest_apply_checksum ? ( {node.latest_apply_checksum ? (
<NodeInfoRow label='同步文件数'> <NodeInfoRow label={t('tunnel.fileCount')}>
{node.latest_support_file_count} 个 {t('tunnel.fileCountValue', {
count: node.latest_support_file_count,
})}
</NodeInfoRow> </NodeInfoRow>
) : null} ) : null}
<NodeInfoRow label='升级状态'> <NodeInfoRow label={t('tunnel.upgradeStatus')}>
<NodeStatusBadge <NodeStatusBadge
label={ label={
node.update_requested node.update_requested
? node.update_channel === 'preview' ? node.update_channel === 'preview'
? '等待预览更新' ? t('update.waitingPreview')
: '等待更新' : t('update.waitingUpdate')
: '未下发' : t('update.notRequested')
} }
tone={node.update_requested ? 'warning' : 'info'} tone={node.update_requested ? 'warning' : 'info'}
/> />
@@ -274,20 +285,23 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='tunnel' /> <InstallCommand node={node} variant='tunnel' />
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'> <NodeSectionCard
title={t('tunnel.credentials')}
description={t('tunnel.credentialsDesc')}
>
<div className='divide-y'> <div className='divide-y'>
<NodeInfoRow label='Tunnel Token'> <NodeInfoRow label={t('tunnel.tunnelToken')}>
<span className='font-mono text-xs break-all'> <span className='font-mono text-xs break-all'>
{node.access_token || '暂无'} {node.access_token || t('na')}
</span> </span>
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='节点 ID'> <NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span> <span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='创建时间'> <NodeInfoRow label={t('tunnel.createdAt')}>
{formatDateTime(node.created_at)} {formatDateTime(node.created_at)}
</NodeInfoRow> </NodeInfoRow>
<NodeInfoRow label='更新时间'> <NodeInfoRow label={t('tunnel.updatedAt')}>
{formatDateTime(node.updated_at)} {formatDateTime(node.updated_at)}
</NodeInfoRow> </NodeInfoRow>
</div> </div>
@@ -303,34 +317,34 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
typeTone='info' typeTone='info'
statusBadges={[ statusBadges={[
{ {
label: getNodeStatusLabel(node.status), label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status), tone: getNodeStatusTone(node.status),
}, },
{ {
label: getFlaredStatusLabel(node), label: getFlaredStatusLabel(node, t),
tone: getFlaredStatusTone(node), tone: getFlaredStatusTone(node),
}, },
]} ]}
actions={headerActions} actions={headerActions}
kpis={[ 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', value: node.version || 'unknown',
icon: Package, icon: Package,
}, },
{ {
label: '当前配置', label: t('kpiCurrentConfig'),
value: node.current_version || '未应用', value: node.current_version || t('notApplied'),
icon: KeyRound, icon: KeyRound,
}, },
{ {
label: '最近心跳', label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at) value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接' ? t('wsConnected')
: isMeaningfulTime(node.last_seen_at) : isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at) ? formatRelativeTime(node.last_seen_at, t)
: '暂无', : t('na'),
icon: Activity, icon: Activity,
}, },
]} ]}
@@ -338,7 +352,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<NodeObservability <NodeObservability
nodeId={node.id} nodeId={node.id}
variant='compact' variant='compact'
connectionHint='隧道并发连接数' connectionHint={t('tunnel.connectionHint')}
/> />
} }
manage={manageTab} manage={manageTab}
@@ -368,22 +382,21 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle> <AlertDialogTitle>{t('tunnel.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除隧道节点「{node.name} {t('tunnel.deleteDesc', { name: node.name })}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}> <AlertDialogCancel disabled={deleteMutation.isPending}>
取消 {tc('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90' className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()} onClick={() => deleteMutation.mutate()}
> >
{deleteMutation.isPending ? '删除中...' : '确认删除'} {deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Server } from 'lucide-react'; import { ArrowLeft, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -20,6 +21,7 @@ import { getErrorMessage } from '../components/node-utils';
const nodesQueryKey = ['openflare', 'nodes']; const nodesQueryKey = ['openflare', 'nodes'];
export function NodeDetailPageClient() { export function NodeDetailPageClient() {
const t = useTranslations('nodes');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? ''; const nodeId = searchParams.get('id')?.trim() ?? '';
@@ -41,7 +43,7 @@ export function NodeDetailPageClient() {
<div className='py-6 px-1'> <div className='py-6 px-1'>
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Server} icon={Server}
description='缺少节点 ID,请从节点列表进入详情页。' description={t('missingId')}
/> />
</div> </div>
); );
@@ -50,7 +52,10 @@ export function NodeDetailPageClient() {
if (nodesQuery.isLoading) { if (nodesQuery.isLoading) {
return ( return (
<div className='py-6 px-1'> <div className='py-6 px-1'>
<LoadingStateWithBorder icon={Server} description='加载节点详情中...' /> <LoadingStateWithBorder
icon={Server}
description={t('loadingDetail')}
/>
</div> </div>
); );
} }
@@ -60,7 +65,7 @@ export function NodeDetailPageClient() {
<div className='py-6 px-1'> <div className='py-6 px-1'>
<div className='p-8 border border-dashed rounded-lg'> <div className='p-8 border border-dashed rounded-lg'>
<ErrorInline <ErrorInline
message={getErrorMessage(nodesQuery.error)} message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
onRetry={() => void nodesQuery.refetch()} onRetry={() => void nodesQuery.refetch()}
className='justify-center' className='justify-center'
/> />
@@ -75,13 +80,10 @@ export function NodeDetailPageClient() {
<Button variant='ghost' size='sm' className='h-8 px-2' asChild> <Button variant='ghost' size='sm' className='h-8 px-2' asChild>
<Link href='/nodes'> <Link href='/nodes'>
<ArrowLeft className='size-4 mr-1' /> <ArrowLeft className='size-4 mr-1' />
返回节点列表 {t('backToList')}
</Link> </Link>
</Button> </Button>
<EmptyStateWithBorder <EmptyStateWithBorder icon={Server} description={t('notFound')} />
icon={Server}
description='节点不存在,可能已被删除或 ID 无效。'
/>
</div> </div>
); );
} }
+20 -20
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react'; import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -44,6 +45,8 @@ import { getErrorMessage } from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes']; const nodesQueryKey = ['openflare', 'nodes'];
export function NodesPageClient() { export function NodesPageClient() {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null); const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
@@ -75,25 +78,25 @@ export function NodesPageClient() {
return NodeService.createNode(payload); return NodeService.createNode(payload);
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success(editingNode ? '节点已更新' : '节点已创建'); toast.success(editingNode ? t('updated') : t('created'));
setEditingNode(null); setEditingNode(null);
setEditorOpen(false); setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => { onError: (error) => {
toast.error(getErrorMessage(error)); toast.error(getErrorMessage(error, t('requestFailed')));
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => NodeService.deleteNode(id), mutationFn: (id: number) => NodeService.deleteNode(id),
onSuccess: async () => { onSuccess: async () => {
toast.success('节点已删除'); toast.success(t('deleted'));
setDeleteTarget(null); setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
}, },
onError: (error) => { 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 justify-between gap-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Server className='size-5 text-primary' /> <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>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Button variant='outline' size='sm' className='h-7 text-xs' asChild> <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>
<Button <Button
variant='secondary' variant='secondary'
@@ -129,7 +132,7 @@ export function NodesPageClient() {
onClick={handleCreate} onClick={handleCreate}
> >
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
新增节点 {t('create')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -139,10 +142,10 @@ export function NodesPageClient() {
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
<div> <div>
<CardTitle className='text-base font-semibold'> <CardTitle className='text-base font-semibold'>
节点列表 {t('listTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{getFilterDescription(nodeFilter)} {getFilterDescription(nodeFilter, t)}
</CardDescription> </CardDescription>
</div> </div>
<Button <Button
@@ -157,7 +160,7 @@ export function NodesPageClient() {
) : ( ) : (
<RefreshCw className='size-3.5 mr-1' /> <RefreshCw className='size-3.5 mr-1' />
)} )}
立即刷新 {t('refreshNow')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -167,12 +170,12 @@ export function NodesPageClient() {
{nodesQuery.isLoading ? ( {nodesQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
icon={Server} icon={Server}
description='加载节点列表中...' description={t('loadingList')}
/> />
) : nodesQuery.isError ? ( ) : nodesQuery.isError ? (
<div className='p-8 border border-dashed rounded-lg'> <div className='p-8 border border-dashed rounded-lg'>
<ErrorInline <ErrorInline
message={getErrorMessage(nodesQuery.error)} message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
onRetry={handleRefresh} onRetry={handleRefresh}
className='justify-center' className='justify-center'
/> />
@@ -181,9 +184,7 @@ export function NodesPageClient() {
<EmptyStateWithBorder <EmptyStateWithBorder
icon={Server} icon={Server}
description={ description={
nodes.length === 0 nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
? '暂无节点,请先创建一个节点。'
: '当前筛选无结果'
} }
/> />
) : ( ) : (
@@ -218,15 +219,14 @@ export function NodesPageClient() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除节点「{deleteTarget?.name} {t('deleteDesc', { name: deleteTarget?.name ?? '' })}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}> <AlertDialogCancel disabled={deleteMutation.isPending}>
取消 {tc('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90' className='bg-destructive text-white hover:bg-destructive/90'
@@ -235,7 +235,7 @@ export function NodesPageClient() {
deleteTarget && deleteMutation.mutate(deleteTarget.id) deleteTarget && deleteMutation.mutate(deleteTarget.id)
} }
> >
{deleteMutation.isPending ? '删除中...' : '确认删除'} {deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -25,21 +25,13 @@ import {
type OriginMutationPayload, type OriginMutationPayload,
OriginService, OriginService,
} from '@/lib/services/openflare'; } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
const originSchema = z.object({ type OriginFormValues = {
name: z.string().max(255), name: string;
address: z address: string;
.string() remark: string;
.trim() };
.min(1, '请输入源站地址')
.refine(
(value) => !/[/?#]/.test(value) && !value.includes('://'),
'源站地址格式不合法',
),
remark: z.string().max(255),
});
type OriginFormValues = z.infer<typeof originSchema>;
const originsQueryKey = ['openflare', 'origins'] as const; const originsQueryKey = ['openflare', 'origins'] as const;
@@ -73,7 +65,21 @@ export function OriginEditorDialog({
origin, origin,
onSaved, onSaved,
}: OriginEditorDialogProps) { }: OriginEditorDialogProps) {
const t = useTranslations('origins');
const tc = useTranslations('common');
const queryClient = useQueryClient(); 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>({ const form = useForm<OriginFormValues>({
resolver: zodResolver(originSchema), resolver: zodResolver(originSchema),
defaultValues: toFormValues(origin), defaultValues: toFormValues(origin),
@@ -91,7 +97,7 @@ export function OriginEditorDialog({
: OriginService.create(payload); : OriginService.create(payload);
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success(origin ? '源站已更新' : '源站已创建'); toast.success(origin ? t('updated') : t('created'));
await queryClient.invalidateQueries({ queryKey: originsQueryKey }); await queryClient.invalidateQueries({ queryKey: originsQueryKey });
if (origin) { if (origin) {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -102,7 +108,7 @@ export function OriginEditorDialog({
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => { 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}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{origin ? '编辑源站' : '新增源站'}</DialogTitle> <DialogTitle>{origin ? t('editOrigin') : t('create')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('editorDesc')}</DialogDescription>
源站作为规则里的可复用地址目录,协议和端口仍由规则决定。
</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
@@ -122,7 +126,7 @@ export function OriginEditorDialog({
onSubmit={form.handleSubmit((values) => mutation.mutate(values))} onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
> >
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='address'>源站地址</Label> <Label htmlFor='address'>{t('address')}</Label>
<Input <Input
id='address' id='address'
placeholder='origin.internal' placeholder='origin.internal'
@@ -135,22 +139,22 @@ export function OriginEditorDialog({
) : null} ) : null}
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='name'>源站名</Label> <Label htmlFor='name'>{t('name')}</Label>
<Input <Input
id='name' id='name'
placeholder='主站源站' placeholder={t('namePlaceholder')}
{...form.register('name')} {...form.register('name')}
/> />
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor='remark'>备注</Label> <Label htmlFor='remark'>{t('remark')}</Label>
<Textarea id='remark' rows={2} {...form.register('remark')} /> <Textarea id='remark' rows={2} {...form.register('remark')} />
</div> </div>
</form> </form>
<DialogFooter> <DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
取消 {tc('cancel')}
</Button> </Button>
<Button <Button
type='submit' type='submit'
@@ -160,12 +164,12 @@ export function OriginEditorDialog({
{mutation.isPending ? ( {mutation.isPending ? (
<> <>
<Loader2 className='size-4 animate-spin mr-1' /> <Loader2 className='size-4 animate-spin mr-1' />
保存中... {t('saving')}
</> </>
) : origin ? ( ) : origin ? (
'保存修改' t('saveChanges')
) : ( ) : (
'新增源站' t('create')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -33,9 +33,13 @@ import {
import { OriginService } from '@/lib/services/openflare'; import { OriginService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { OriginEditorDialog } from '../components/origin-editor-dialog'; import { OriginEditorDialog } from '../components/origin-editor-dialog';
export function OriginDetailPageClient() { export function OriginDetailPageClient() {
const t = useTranslations('origins');
const tc = useTranslations('common');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const originId = searchParams.get('id')?.trim() ?? ''; const originId = searchParams.get('id')?.trim() ?? '';
@@ -54,14 +58,14 @@ export function OriginDetailPageClient() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: () => OriginService.deleteById(parsedId), mutationFn: () => OriginService.deleteById(parsedId),
onSuccess: async () => { onSuccess: async () => {
toast.success('源站已删除'); toast.success(t('deleted'));
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ['openflare', 'origins'], queryKey: ['openflare', 'origins'],
}); });
window.location.href = '/origins'; window.location.href = '/origins';
}, },
onError: (error) => { 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) { if (!enabled) {
return ( return (
<div className='py-6 px-1'> <div className='py-6 px-1'>
<EmptyStateWithBorder description='缺少有效的源站 ID。' /> <EmptyStateWithBorder description={t('missingId')} />
</div> </div>
); );
} }
@@ -78,7 +82,7 @@ export function OriginDetailPageClient() {
if (originQuery.isLoading) { if (originQuery.isLoading) {
return ( return (
<div className='py-6 px-1'> <div className='py-6 px-1'>
<LoadingStateWithBorder icon={MapPin} description='加载源站详情...' /> <LoadingStateWithBorder icon={MapPin} description={t('loadingDetail')} />
</div> </div>
); );
} }
@@ -90,7 +94,7 @@ export function OriginDetailPageClient() {
message={ message={
originQuery.error instanceof Error originQuery.error instanceof Error
? originQuery.error.message ? originQuery.error.message
: '加载失败' : t('loadFailed')
} }
onRetry={() => void originQuery.refetch()} onRetry={() => void originQuery.refetch()}
/> />
@@ -104,10 +108,10 @@ export function OriginDetailPageClient() {
<Button variant='ghost' size='sm' asChild> <Button variant='ghost' size='sm' asChild>
<Link href='/origins'> <Link href='/origins'>
<ArrowLeft className='size-4 mr-1' /> <ArrowLeft className='size-4 mr-1' />
返回列表 {t('backToList')}
</Link> </Link>
</Button> </Button>
<EmptyStateWithBorder description='源站不存在或已被删除。' /> <EmptyStateWithBorder description={t('notFound')} />
</div> </div>
); );
} }
@@ -119,7 +123,7 @@ export function OriginDetailPageClient() {
<Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild> <Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild>
<Link href='/origins'> <Link href='/origins'>
<ArrowLeft className='size-4 mr-1' /> <ArrowLeft className='size-4 mr-1' />
返回列表 {t('backToList')}
</Link> </Link>
</Button> </Button>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
@@ -136,7 +140,7 @@ export function OriginDetailPageClient() {
size='sm' size='sm'
onClick={() => setEditorOpen(true)} onClick={() => setEditorOpen(true)}
> >
编辑源站 {t('editOrigin')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -144,7 +148,7 @@ export function OriginDetailPageClient() {
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
> >
<Trash2 className='size-3.5 mr-1' /> <Trash2 className='size-3.5 mr-1' />
删除源站 {t('deleteOrigin')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -152,57 +156,57 @@ export function OriginDetailPageClient() {
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'> <div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
<div className='rounded-lg border border-dashed px-4 py-3'> <div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
绑定规则 {t('boundRoutes')}
</p> </p>
<Badge variant='outline' className='mt-2 text-[10px]'> <Badge variant='outline' className='mt-2 text-[10px]'>
{origin.route_count} 条规则 {t('routeCount', { count: origin.route_count })}
</Badge> </Badge>
</div> </div>
<div className='rounded-lg border border-dashed px-4 py-3'> <div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
创建时间 {t('createdAt')}
</p> </p>
<p className='mt-2 text-sm'>{formatDateTime(origin.created_at)}</p> <p className='mt-2 text-sm'>{formatDateTime(origin.created_at)}</p>
</div> </div>
<div className='rounded-lg border border-dashed px-4 py-3'> <div className='rounded-lg border border-dashed px-4 py-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
更新时间 {t('updatedAt')}
</p> </p>
<p className='mt-2 text-sm'>{formatDateTime(origin.updated_at)}</p> <p className='mt-2 text-sm'>{formatDateTime(origin.updated_at)}</p>
</div> </div>
<div className='rounded-lg border border-dashed px-4 py-3 sm:col-span-2 xl:col-span-1'> <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'> <p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
备注 {t('remark')}
</p> </p>
<p className='mt-2 text-sm text-muted-foreground'> <p className='mt-2 text-sm text-muted-foreground'>
{origin.remark || '暂无备注'} {origin.remark || t('noRemark')}
</p> </p>
</div> </div>
</div> </div>
<div className='border border-dashed rounded-lg overflow-hidden bg-background'> <div className='border border-dashed rounded-lg overflow-hidden bg-background'>
<div className='px-4 py-3 border-b border-dashed'> <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'> <p className='text-xs text-muted-foreground mt-1'>
展示当前源站作为主源站绑定的规则。 {t('relatedRoutesDesc')}
</p> </p>
</div> </div>
{origin.routes.length === 0 ? ( {origin.routes.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
title='暂无关联规则' title={t('noRelatedRoutes')}
description='当前源站还没有被任何规则引用。' description={t('noRelatedRoutesDesc')}
/> />
) : ( ) : (
<Table> <Table>
<TableHeader className='bg-muted/40'> <TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'> <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'> <TableHead className='text-xs font-semibold'>
源站地址 {t('address')}
</TableHead> </TableHead>
<TableHead className='text-xs font-semibold'>状态</TableHead> <TableHead className='text-xs font-semibold'>{t('status')}</TableHead>
<TableHead className='text-xs font-semibold'> <TableHead className='text-xs font-semibold'>
更新时间 {t('updatedAt')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -217,7 +221,7 @@ export function OriginDetailPageClient() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant='outline' className='text-[10px]'> <Badge variant='outline' className='text-[10px]'>
{route.enabled ? '启用' : '停用'} {route.enabled ? t('enabled') : t('disabled')}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className='text-xs text-muted-foreground'> <TableCell className='text-xs text-muted-foreground'>
@@ -240,18 +244,18 @@ export function OriginDetailPageClient() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>删除源站</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除源站 {origin.name} 吗? {t('deleteDesc', { name: origin.name })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90' className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => deleteMutation.mutate()} onClick={() => deleteMutation.mutate()}
> >
确认删除 {t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+27 -21
View File
@@ -24,11 +24,15 @@ import { Button } from '@/components/ui/button';
import { type OriginItem, OriginService } from '@/lib/services/openflare'; import { type OriginItem, OriginService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils'; import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { OriginEditorDialog } from './components/origin-editor-dialog'; import { OriginEditorDialog } from './components/origin-editor-dialog';
const originsQueryKey = ['openflare', 'origins'] as const; const originsQueryKey = ['openflare', 'origins'] as const;
export default function OriginsPage() { export default function OriginsPage() {
const t = useTranslations('origins');
const tc = useTranslations('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null); const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
@@ -44,12 +48,12 @@ export default function OriginsPage() {
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => OriginService.deleteById(id), mutationFn: (id: number) => OriginService.deleteById(id),
onSuccess: async () => { onSuccess: async () => {
toast.success('源站已删除'); toast.success(t('deleted'));
setDeleteTarget(null); setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: originsQueryKey }); await queryClient.invalidateQueries({ queryKey: originsQueryKey });
}, },
onError: (error) => { 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'> <div className='flex items-center gap-2'>
<MapPin className='size-5 text-primary' /> <MapPin className='size-5 text-primary' />
<div> <div>
<h1 className='text-2xl font-semibold tracking-tight'>源站</h1> <h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
集中维护规则复用的源站地址,减少批量改地址时的重复操作。
</p>
</div> </div>
</div> </div>
<div className='flex gap-2'> <div className='flex gap-2'>
@@ -75,7 +77,7 @@ export default function OriginsPage() {
<RefreshCw <RefreshCw
className={`size-3.5 mr-1 ${originsQuery.isFetching ? 'animate-spin' : ''}`} className={`size-3.5 mr-1 ${originsQuery.isFetching ? 'animate-spin' : ''}`}
/> />
刷新 {t('refresh')}
</Button> </Button>
<Button <Button
size='sm' size='sm'
@@ -85,7 +87,7 @@ export default function OriginsPage() {
}} }}
> >
<Plus className='size-3.5 mr-1' /> <Plus className='size-3.5 mr-1' />
新增源站 {t('create')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -95,7 +97,7 @@ export default function OriginsPage() {
message={ message={
originsQuery.error instanceof Error originsQuery.error instanceof Error
? originsQuery.error.message ? originsQuery.error.message
: '加载失败' : t('loadFailed')
} }
onRetry={() => void originsQuery.refetch()} onRetry={() => void originsQuery.refetch()}
/> />
@@ -106,8 +108,8 @@ export default function OriginsPage() {
<LoadingStateWithBorder /> <LoadingStateWithBorder />
) : origins.length === 0 ? ( ) : origins.length === 0 ? (
<EmptyStateWithBorder <EmptyStateWithBorder
title='暂无源站' title={t('emptyTitle')}
description='点击右上角新增源站,后续规则可直接复用这些地址。' description={t('emptyDesc')}
/> />
) : ( ) : (
<div className='grid gap-0 md:grid-cols-2'> <div className='grid gap-0 md:grid-cols-2'>
@@ -128,20 +130,24 @@ export default function OriginsPage() {
: 'text-amber-600 border-amber-500/20' : 'text-amber-600 border-amber-500/20'
}`} }`}
> >
{origin.route_count} 条规则 {t('routeCount', { count: origin.route_count })}
</Badge> </Badge>
</div> </div>
<p className='text-sm'>{origin.address}</p> <p className='text-sm'>{origin.address}</p>
<p className='text-sm text-muted-foreground'> <p className='text-sm text-muted-foreground'>
{origin.remark || '暂无备注'} {origin.remark || t('noRemark')}
</p> </p>
<p className='text-xs text-muted-foreground'> <p className='text-xs text-muted-foreground'>
最后更新:{formatDateTime(origin.updated_at)} {t('lastUpdated', {
date: formatDateTime(origin.updated_at),
})}
</p> </p>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' asChild> <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>
<Button <Button
variant='outline' variant='outline'
@@ -151,7 +157,7 @@ export default function OriginsPage() {
setEditorOpen(true); setEditorOpen(true);
}} }}
> >
编辑 {t('edit')}
</Button> </Button>
<Button <Button
variant='destructive' variant='destructive'
@@ -159,7 +165,7 @@ export default function OriginsPage() {
onClick={() => setDeleteTarget(origin)} onClick={() => setDeleteTarget(origin)}
> >
<Trash2 className='size-3.5 mr-1' /> <Trash2 className='size-3.5 mr-1' />
删除 {tc('delete')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -183,20 +189,20 @@ export default function OriginsPage() {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>删除源站</AlertDialogTitle> <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
确认删除源站 {deleteTarget?.name} 吗? {t('deleteDesc', { name: deleteTarget?.name ?? '' })}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className='bg-destructive text-destructive-foreground hover:bg-destructive/90' className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => { onClick={() => {
if (deleteTarget) deleteMutation.mutate(deleteTarget.id); if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
}} }}
> >
确认删除 {t('confirmDelete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+14 -8
View File
@@ -2,6 +2,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { LayoutDashboard, RefreshCw } from 'lucide-react'; import { LayoutDashboard, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty'; import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error'; import { ErrorInline } from '@/components/layout/error';
@@ -24,6 +25,7 @@ import { getErrorMessage } from './nodes/components/node-utils';
const dashboardQueryKey = ['openflare', 'dashboard', 'overview']; const dashboardQueryKey = ['openflare', 'dashboard', 'overview'];
export default function OpenFlareDashboardPage() { export default function OpenFlareDashboardPage() {
const t = useTranslations('dashboard');
const overviewQuery = useQuery({ const overviewQuery = useQuery({
queryKey: dashboardQueryKey, queryKey: dashboardQueryKey,
queryFn: () => DashboardService.getOverview(), 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 flex-wrap items-center justify-between gap-3'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<LayoutDashboard className='size-5 text-primary' /> <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>
<div className='flex items-center gap-2 text-xs text-muted-foreground'> <div className='flex items-center gap-2 text-xs text-muted-foreground'>
{overview?.generated_at ? ( {overview?.generated_at ? (
<span>数据生成于 {formatDateTime(overview.generated_at)}</span> <span>
{t('generatedAt', { time: formatDateTime(overview.generated_at) })}
</span>
) : null} ) : null}
<Button <Button
variant='outline' variant='outline'
@@ -53,25 +57,27 @@ export default function OpenFlareDashboardPage() {
<RefreshCw <RefreshCw
className={`size-3.5 mr-1.5 ${overviewQuery.isFetching ? 'animate-spin' : ''}`} className={`size-3.5 mr-1.5 ${overviewQuery.isFetching ? 'animate-spin' : ''}`}
/> />
刷新 {t('refresh')}
</Button> </Button>
</div> </div>
</div> </div>
{overviewQuery.isLoading ? ( {overviewQuery.isLoading ? (
<LoadingStateWithBorder <LoadingStateWithBorder
title='加载总览数据' title={t('loadingTitle')}
description='正在聚合节点健康、流量与容量指标...' description={t('loadingDesc')}
/> />
) : overviewQuery.isError ? ( ) : overviewQuery.isError ? (
<ErrorInline <ErrorInline
message={`总览看板加载失败:${getErrorMessage(overviewQuery.error)}`} message={t('loadFailed', {
error: getErrorMessage(overviewQuery.error, t('requestFailed')),
})}
onRetry={() => overviewQuery.refetch()} onRetry={() => overviewQuery.refetch()}
/> />
) : !overview ? ( ) : !overview ? (
<EmptyStateWithBorder <EmptyStateWithBorder
title='暂无总览数据' title={t('emptyTitle')}
description='系统已经启动,但还没有可展示的总览聚合结果。' description={t('emptyDesc')}
/> />
) : ( ) : (
<> <>
@@ -3,6 +3,7 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { UploadCloud } from 'lucide-react'; import { UploadCloud } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -76,6 +77,8 @@ export function DeploymentUploadDialog({
rootDir, rootDir,
entryFile, entryFile,
}: DeploymentUploadDialogProps) { }: DeploymentUploadDialogProps) {
const t = useTranslations('pages.upload');
const tCommon = useTranslations('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@@ -96,14 +99,14 @@ export function DeploymentUploadDialog({
const uploadMutation = useMutation({ const uploadMutation = useMutation({
mutationFn: () => { mutationFn: () => {
if (!file) throw new Error('请选择部署包'); if (!file) throw new Error(t('selectFile'));
return PagesService.uploadDeployment(projectId, { return PagesService.uploadDeployment(projectId, {
file, file,
onProgress: setUploadProgress, onProgress: setUploadProgress,
}); });
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success('部署包上传成功'); toast.success(t('success'));
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(projectId), queryKey: deploymentsQueryKey(projectId),
@@ -114,7 +117,7 @@ export function DeploymentUploadDialog({
handleOpenChange(false); handleOpenChange(false);
}, },
onError: (error) => { onError: (error) => {
toast.error(error instanceof Error ? error.message : '上传失败'); toast.error(error instanceof Error ? error.message : t('failed'));
setUploadProgress(null); setUploadProgress(null);
}, },
}); });
@@ -122,7 +125,7 @@ export function DeploymentUploadDialog({
const handleFileSelect = (selected: File | null) => { const handleFileSelect = (selected: File | null) => {
if (!selected) return; if (!selected) return;
if (!isSupportedPagesPackage(selected.name)) { if (!isSupportedPagesPackage(selected.name)) {
toast.error('仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式的部署包'); toast.error(t('unsupported'));
return; return;
} }
setFile(selected); setFile(selected);
@@ -132,15 +135,13 @@ export function DeploymentUploadDialog({
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg'> <DialogContent className='sm:max-w-lg'>
<DialogHeader> <DialogHeader>
<DialogTitle>上传部署包</DialogTitle> <DialogTitle>{t('title')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('description')}</DialogDescription>
上传不可变的静态资源压缩包,完成后可在部署历史中激活。
</DialogDescription>
</DialogHeader> </DialogHeader>
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel htmlFor='pages-package'>本地部署包</FieldLabel> <FieldLabel htmlFor='pages-package'>{t('localPackage')}</FieldLabel>
<button <button
type='button' type='button'
className={cn( className={cn(
@@ -165,7 +166,7 @@ export function DeploymentUploadDialog({
> >
<UploadCloud className='size-8 text-muted-foreground' /> <UploadCloud className='size-8 text-muted-foreground' />
<span className='text-sm font-medium'> <span className='text-sm font-medium'>
拖拽部署包到此处,或点击选择文件 {t('dropHint')}
</span> </span>
<span className='text-xs text-muted-foreground'> <span className='text-xs text-muted-foreground'>
zip、tar.gz、tar.xz、tar.bz2、tar、7z zip、tar.gz、tar.xz、tar.bz2、tar、7z
@@ -183,20 +184,23 @@ export function DeploymentUploadDialog({
/> />
{file ? ( {file ? (
<FieldDescription> <FieldDescription>
已选择 {file.name}({formatBytes(file.size)}) {t('selected', {
name: file.name,
size: formatBytes(file.size),
})}
</FieldDescription> </FieldDescription>
) : ( ) : (
<FieldDescription>请选择一个受支持的压缩包。</FieldDescription> <FieldDescription>{t('chooseArchive')}</FieldDescription>
)} )}
</Field> </Field>
<Field> <Field>
<FieldLabel>部署入口</FieldLabel> <FieldLabel>{t('entry')}</FieldLabel>
<div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'> <div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'>
{pagesEntryPath(rootDir, entryFile)} {pagesEntryPath(rootDir, entryFile)}
</div> </div>
<FieldDescription> <FieldDescription>
入口来自项目设置;部署包上传不会覆盖该配置。 {t('entryHint')}
</FieldDescription> </FieldDescription>
</Field> </Field>
@@ -204,10 +208,10 @@ export function DeploymentUploadDialog({
<Field> <Field>
<div className='flex items-center justify-between text-xs text-muted-foreground'> <div className='flex items-center justify-between text-xs text-muted-foreground'>
<span> <span>
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'} {uploadProgress >= 100 ? t('processing') : t('progress')}
</span> </span>
<span> <span>
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`} {uploadProgress >= 100 ? t('pleaseWait') : `${uploadProgress}%`}
</span> </span>
</div> </div>
<Progress value={Math.min(uploadProgress, 100)} /> <Progress value={Math.min(uploadProgress, 100)} />
@@ -221,7 +225,7 @@ export function DeploymentUploadDialog({
variant='outline' variant='outline'
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
> >
取消 {tCommon('cancel')}
</Button> </Button>
<Button <Button
type='button' type='button'
@@ -233,7 +237,7 @@ export function DeploymentUploadDialog({
) : ( ) : (
<UploadCloud data-icon='inline-start' /> <UploadCloud data-icon='inline-start' />
)} )}
{uploadMutation.isPending ? '上传中...' : '上传并创建部署'} {uploadMutation.isPending ? t('uploading') : t('submit')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -3,6 +3,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -35,6 +36,8 @@ export function ProjectEditorDialog({
onOpenChange, onOpenChange,
project, project,
}: ProjectEditorDialogProps) { }: ProjectEditorDialogProps) {
const t = useTranslations('pages');
const tCommon = useTranslations('common');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const form = usePagesProjectForm(project); const form = usePagesProjectForm(project);
@@ -50,7 +53,7 @@ export function ProjectEditorDialog({
: PagesService.createProject(payload); : PagesService.createProject(payload);
}, },
onSuccess: async () => { onSuccess: async () => {
toast.success(project ? '项目已更新' : '项目已创建'); toast.success(project ? t('updated') : t('created'));
await queryClient.invalidateQueries({ queryKey: projectsQueryKey }); await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
if (project) { if (project) {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -60,7 +63,7 @@ export function ProjectEditorDialog({
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => { 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'> <DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{project ? '编辑 Pages 项目' : '新建 Pages 项目'} {project ? t('editTitle') : t('createTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{t('editorDesc')}</DialogDescription>
配置静态站点托管参数,上传部署包后在代理规则中选择 Pages 上游。
</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
@@ -86,7 +87,7 @@ export function ProjectEditorDialog({
<DialogFooter> <DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
取消 {tCommon('cancel')}
</Button> </Button>
<Button <Button
type='submit' type='submit'
@@ -96,12 +97,12 @@ export function ProjectEditorDialog({
{mutation.isPending ? ( {mutation.isPending ? (
<> <>
<Loader2 className='size-4 animate-spin mr-1' /> <Loader2 className='size-4 animate-spin mr-1' />
保存中... {t('saving')}
</> </>
) : project ? ( ) : project ? (
'保存修改' t('saveChanges')
) : ( ) : (
'创建项目' t('createProject')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -1,6 +1,8 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useMemo } from 'react';
import { useForm, type UseFormReturn } from 'react-hook-form'; import { useForm, type UseFormReturn } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -9,9 +11,10 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { type PagesProject } from '@/lib/services/openflare'; import { type PagesProject } from '@/lib/services/openflare';
export const pagesProjectSchema = z function createPagesProjectSchema(t: (key: string) => string) {
return z
.object({ .object({
name: z.string().trim().min(1, '请输入项目名称').max(255), name: z.string().trim().min(1, t('form.nameRequired')).max(255),
slug: z.string().trim().max(255).optional().or(z.literal('')), slug: z.string().trim().max(255).optional().or(z.literal('')),
spa_fallback_enabled: z.boolean(), spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(), spa_fallback_path: z.string().trim(),
@@ -20,14 +23,14 @@ export const pagesProjectSchema = z
api_proxy_pass: z.string().trim(), api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(), api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal('')), root_dir: z.string().trim().max(512).optional().or(z.literal('')),
entry_file: z.string().trim().min(1, '请输入入口文件').max(512), entry_file: z.string().trim().min(1, t('form.entryRequired')).max(512),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) { if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'], path: ['spa_fallback_path'],
message: '回退路径必须以 / 开头', message: t('form.fallbackPathPrefix'),
}); });
} }
if (data.api_proxy_enabled) { if (data.api_proxy_enabled) {
@@ -35,20 +38,23 @@ export const pagesProjectSchema = z
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['api_proxy_path'], path: ['api_proxy_path'],
message: '匹配路径必须以 / 开头', message: t('form.matchPathPrefix'),
}); });
} }
if (!/^https?:\/\//i.test(data.api_proxy_pass)) { if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'], path: ['api_proxy_pass'],
message: '后端地址必须以 http:// 或 https:// 开头', message: t('form.backendPrefix'),
}); });
} }
} }
}); });
}
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>; export type PagesProjectFormValues = z.infer<
ReturnType<typeof createPagesProjectSchema>
>;
export function toFormValues( export function toFormValues(
project?: PagesProject | null, project?: PagesProject | null,
@@ -110,8 +116,10 @@ export function buildProjectPayload(
} }
export function usePagesProjectForm(project?: PagesProject | null) { export function usePagesProjectForm(project?: PagesProject | null) {
const t = useTranslations('pages');
const schema = useMemo(() => createPagesProjectSchema((key) => t(key)), [t]);
return useForm<PagesProjectFormValues>({ return useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema), resolver: zodResolver(schema),
defaultValues: toFormValues(project), defaultValues: toFormValues(project),
}); });
} }
@@ -125,6 +133,7 @@ export function ProjectFormFields({
form, form,
idPrefix = '', idPrefix = '',
}: ProjectFormFieldsProps) { }: ProjectFormFieldsProps) {
const t = useTranslations('pages');
const spaEnabled = form.watch('spa_fallback_enabled'); const spaEnabled = form.watch('spa_fallback_enabled');
const apiEnabled = form.watch('api_proxy_enabled'); const apiEnabled = form.watch('api_proxy_enabled');
const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name); const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name);
@@ -133,7 +142,7 @@ export function ProjectFormFields({
<div className='space-y-4'> <div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'> <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')} /> <Input id={fieldId('name')} {...form.register('name')} />
{form.formState.errors.name ? ( {form.formState.errors.name ? (
<p className='text-xs text-destructive'> <p className='text-xs text-destructive'>
@@ -142,10 +151,10 @@ export function ProjectFormFields({
) : null} ) : null}
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor={fieldId('slug')}>项目标识</Label> <Label htmlFor={fieldId('slug')}>{t('form.slug')}</Label>
<Input <Input
id={fieldId('slug')} id={fieldId('slug')}
placeholder='留空自动生成' placeholder={t('form.slugPlaceholder')}
{...form.register('slug')} {...form.register('slug')}
/> />
</div> </div>
@@ -153,14 +162,14 @@ export function ProjectFormFields({
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'> <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')} /> <Input id={fieldId('entry_file')} {...form.register('entry_file')} />
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor={fieldId('root_dir')}>根目录</Label> <Label htmlFor={fieldId('root_dir')}>{t('form.rootDir')}</Label>
<Input <Input
id={fieldId('root_dir')} id={fieldId('root_dir')}
placeholder='可选' placeholder={t('form.optional')}
{...form.register('root_dir')} {...form.register('root_dir')}
/> />
</div> </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 className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div> <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'> <p className='text-xs text-muted-foreground'>
未命中静态文件时回退到指定路径 {t('form.spaFallbackDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -184,7 +193,9 @@ export function ProjectFormFields({
</div> </div>
{spaEnabled ? ( {spaEnabled ? (
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor={fieldId('spa_fallback_path')}>回退路径</Label> <Label htmlFor={fieldId('spa_fallback_path')}>
{t('form.fallbackPath')}
</Label>
<Input <Input
id={fieldId('spa_fallback_path')} id={fieldId('spa_fallback_path')}
{...form.register('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 className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div> <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'> <p className='text-xs text-muted-foreground'>
为静态站点附加 API 反代规则 {t('form.apiProxyDesc')}
</p> </p>
</div> </div>
<Switch <Switch
@@ -209,21 +220,27 @@ export function ProjectFormFields({
{apiEnabled ? ( {apiEnabled ? (
<div className='grid gap-4 md:grid-cols-2'> <div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_path')}>匹配路径</Label> <Label htmlFor={fieldId('api_proxy_path')}>
{t('form.matchPath')}
</Label>
<Input <Input
id={fieldId('api_proxy_path')} id={fieldId('api_proxy_path')}
{...form.register('api_proxy_path')} {...form.register('api_proxy_path')}
/> />
</div> </div>
<div className='space-y-1.5'> <div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_pass')}>后端地址</Label> <Label htmlFor={fieldId('api_proxy_pass')}>
{t('form.backend')}
</Label>
<Input <Input
id={fieldId('api_proxy_pass')} id={fieldId('api_proxy_pass')}
{...form.register('api_proxy_pass')} {...form.register('api_proxy_pass')}
/> />
</div> </div>
<div className='space-y-1.5 md:col-span-2'> <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 <Input
id={fieldId('api_proxy_rewrite')} id={fieldId('api_proxy_rewrite')}
{...form.register('api_proxy_rewrite')} {...form.register('api_proxy_rewrite')}

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