mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -228,3 +228,10 @@ frontend/lib/services/<name>/
|
||||
|
||||
- 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。
|
||||
- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。
|
||||
- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。
|
||||
- 文案放在 `frontend/messages/{locale}.json`。组件内用户可见文案必须 `useTranslations` / `getTranslations`,禁止新增中英硬编码 UI 字符串(后端 `error_msg`、日志除外)。
|
||||
- 新增或修改文案必须同步 `zh-CN.json` 与 `en.json`。设计见 `docs/superpowers/specs/2026-07-24-frontend-i18n-design.md`。
|
||||
|
||||
@@ -10,6 +10,9 @@ sidebar: false
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增
|
||||
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。
|
||||
|
||||
### 🛠 修复
|
||||
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
|
||||
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
|
||||
|
||||
@@ -33,6 +33,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
|
||||
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) |
|
||||
| **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) |
|
||||
| **日志存储** | 访问日志与可观测时序走可切换日志主库(随业务主库或 ClickHouse);关闭 ClickHouse 后仍可写可查 | [日志存储解耦](./logstore.md) |
|
||||
| **控制台双语** | 无 URL 前缀的 zh-CN / en,cookie `NEXT_LOCALE` 优先,兼容静态导出 | [前端 i18n 设计](../superpowers/specs/2026-07-24-frontend-i18n-design.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Frontend i18n Design
|
||||
|
||||
Date: 2026-07-24
|
||||
Status: Implemented in OpenFlare (ported from Wavelet 1625cfb, extended to product console)
|
||||
Scope: Frontend UI only
|
||||
|
||||
## 1. Goals
|
||||
|
||||
Add bilingual UI support for Wavelet frontend:
|
||||
|
||||
- Languages: `zh-CN` and `en`
|
||||
- Default locale: `zh-CN`
|
||||
- Locale resolution: explicit user choice → browser language → default
|
||||
- Phase 1: infrastructure + core paths only (layout / auth / settings)
|
||||
- Must remain compatible with `NEXT_STANDALONE_EXPORT` static export
|
||||
|
||||
### Non-goals (Phase 1)
|
||||
|
||||
- Backend API error / message localization
|
||||
- Email / push notification localization
|
||||
- URL locale prefixes (`/en/...`, `/zh-CN/...`) and SEO hreflang
|
||||
- Full translation of all admin business pages
|
||||
|
||||
## 2. Context
|
||||
|
||||
Current state:
|
||||
|
||||
- Root layout hardcodes `lang='zh-CN'`
|
||||
- UI copy is mostly Chinese string literals across many TSX files
|
||||
- Date formatting often hardcodes `zh-CN` / `date-fns/locale` `zhCN`
|
||||
- No i18n library is installed
|
||||
- Frontend supports both normal Next rewrites mode and static export embed mode
|
||||
|
||||
## 3. Approach
|
||||
|
||||
Use **next-intl in non-routing / provider mode**.
|
||||
|
||||
Why this approach:
|
||||
|
||||
- Mature App Router integration and clear `useTranslations` API
|
||||
- ICU message format ready when needed
|
||||
- Avoids locale-prefixed routing, which conflicts with static-export simplicity and current route structure
|
||||
- Cookie + browser detection matches product preference without SEO path requirements
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- Fully custom Context + JSON: lower dependency cost, but reimplements interpolation/plurals/type safety poorly
|
||||
- `i18next` + `react-i18next`: powerful, but heavier and less natural for this Next App Router setup
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
RootLayout
|
||||
html lang={locale}
|
||||
ThemeProvider
|
||||
CustomThemeProvider
|
||||
AppQueryProvider
|
||||
NextIntlClientProvider(locale, messages)
|
||||
existing User / Notification / Bell providers
|
||||
pages + components
|
||||
```
|
||||
|
||||
### Key files
|
||||
|
||||
| Path | Responsibility |
|
||||
| --- | --- |
|
||||
| `frontend/i18n/config.ts` | Supported locales, default locale, cookie name, normalize helpers |
|
||||
| `frontend/i18n/request.ts` | `getRequestConfig` for server-side locale/messages resolution when not in export mode |
|
||||
| `frontend/i18n/client.ts` | Client helpers to read/write locale preference |
|
||||
| `frontend/messages/zh-CN.json` | Chinese messages |
|
||||
| `frontend/messages/en.json` | English messages |
|
||||
| `frontend/components/common/language-switcher.tsx` (or under `layout/`) | Language switch UI |
|
||||
| `frontend/lib/i18n-format.ts` (optional location under `i18n/`) | Locale-aware date/number formatting helpers |
|
||||
|
||||
### Runtime flow
|
||||
|
||||
1. Resolve locale: cookie `NEXT_LOCALE` → browser languages → `zh-CN`
|
||||
2. Load `messages/{locale}.json`
|
||||
3. Provide locale + messages through `NextIntlClientProvider`
|
||||
4. Components call `useTranslations('<namespace>')`
|
||||
5. Language switcher writes cookie and refreshes locale/messages
|
||||
6. Update `document.documentElement.lang`
|
||||
|
||||
## 5. Locale Resolution
|
||||
|
||||
Supported locales: `zh-CN`, `en`
|
||||
|
||||
Normalization:
|
||||
|
||||
- `zh`, `zh-CN`, `zh-Hans*` → `zh-CN`
|
||||
- `en`, `en-US`, `en-GB`, other `en-*` → `en`
|
||||
- anything else → `zh-CN`
|
||||
|
||||
Priority:
|
||||
|
||||
1. User explicit choice stored in cookie `NEXT_LOCALE`
|
||||
2. Browser language (`Accept-Language` on server, `navigator.languages` on client)
|
||||
3. Default `zh-CN`
|
||||
|
||||
Invalid cookie values are normalized to a supported locale and may be rewritten to a valid value.
|
||||
|
||||
## 6. Static Export Compatibility
|
||||
|
||||
Constraints:
|
||||
|
||||
- No locale-segment routes
|
||||
- No middleware-based locale rewriting required for correctness
|
||||
- `build:embed` (`NEXT_STANDALONE_EXPORT=true`) must continue to work
|
||||
|
||||
Behavior:
|
||||
|
||||
- **Normal SSR/dev**: resolve locale on server when possible to reduce first-paint language flash
|
||||
- **Static export**: ship both message catalogs; resolve on client from cookie/browser; accept a brief default-language flash similar to theme hydration, using existing `suppressHydrationWarning` patterns where needed
|
||||
|
||||
## 7. Message Organization
|
||||
|
||||
Single catalog files with nested namespaces:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"loading": "加载中..."
|
||||
},
|
||||
"layout": {
|
||||
"nav": {
|
||||
"home": "首页",
|
||||
"myFiles": "我的文件"
|
||||
},
|
||||
"userMenu": {
|
||||
"settings": "设置",
|
||||
"logout": "退出登录"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "登录",
|
||||
"submit": "登录"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"appearance": {
|
||||
"language": "语言",
|
||||
"languageDesc": "选择界面显示语言"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
- Keys use camelCase and hierarchical grouping
|
||||
- Prefer complete phrases as values; avoid assembling sentences in components
|
||||
- Use ICU only when needed (`{name}`, plural forms)
|
||||
- Backend `error_msg` values are shown as-is in Phase 1
|
||||
- Frontend-owned toast / validation copy is translated
|
||||
|
||||
Both locale files must keep the same key tree. A key-alignment check script is recommended.
|
||||
|
||||
## 8. Language Switcher UX
|
||||
|
||||
Placement:
|
||||
|
||||
- Header toolbar near theme controls
|
||||
- Appearance settings page as an explicit preference row
|
||||
|
||||
UI labels for language options use native names and do not themselves translate:
|
||||
|
||||
- `中文`
|
||||
- `English`
|
||||
|
||||
On change:
|
||||
|
||||
1. Persist `NEXT_LOCALE`
|
||||
2. Apply new locale/messages (via refresh or controlled provider update)
|
||||
3. Sync `document.documentElement.lang`
|
||||
4. Preserve unrelated UI state where practical (theme, auth session, sidebar collapse)
|
||||
|
||||
## 9. Phase 1 Migration Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Install and wire `next-intl`
|
||||
- Message catalogs for core namespaces
|
||||
- Locale resolution + persistence
|
||||
- `LanguageSwitcher`
|
||||
- Translate:
|
||||
- layout shell: sidebar nav/user menu, header accessible labels / titles
|
||||
- auth: login / register / OTP labels, buttons, validation messages
|
||||
- settings: appearance (including language preference), profile, security, notifications, access-token visible copy
|
||||
- Replace date/number hardcoding only where touched by the above paths
|
||||
- Ensure `html lang` reflects active locale
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Remaining admin pages and deep business modules
|
||||
- Backend localization
|
||||
- Route prefixing / SEO alternate links
|
||||
|
||||
Unmigrated pages may remain Chinese hard-coded; mixed-language UI is acceptable during incremental rollout.
|
||||
|
||||
## 10. Formatting Helpers
|
||||
|
||||
Introduce locale-aware helpers for dates/numbers used by migrated surfaces, e.g.:
|
||||
|
||||
- `formatDateTime(value, locale)`
|
||||
- `formatNumber(value, locale)`
|
||||
|
||||
`date-fns` locale objects should follow active locale (`zhCN` / `enUS`) when a migrated component uses them.
|
||||
|
||||
## 11. Error Handling & Fallbacks
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Missing message key | Dev warning; do not crash; show key or fallback language value |
|
||||
| Unsupported cookie locale | Normalize to supported locale / default |
|
||||
| Partial migration | Keep hard-coded Chinese on unmigrated screens |
|
||||
| Backend error strings | Display raw `error_msg` |
|
||||
|
||||
## 12. Testing & Acceptance
|
||||
|
||||
Manual:
|
||||
|
||||
1. No cookie + browser Chinese → Chinese UI
|
||||
2. No cookie + browser English → English UI
|
||||
3. Manual switch to English survives refresh
|
||||
4. Manual switch back to Chinese survives refresh
|
||||
5. Core paths (layout/auth/settings) have no major residual hard-coded Chinese UI copy
|
||||
6. `pnpm build` and `pnpm build:embed` both succeed
|
||||
7. Language switch does not break theme, session, or sidebar state
|
||||
|
||||
Automated (recommended):
|
||||
|
||||
- Unit tests for `normalizeLocale` / resolution priority
|
||||
- Script or test asserting `zh-CN.json` and `en.json` key parity
|
||||
|
||||
## 13. Rollout Plan (high level)
|
||||
|
||||
1. Add i18n infrastructure and empty/core message files
|
||||
2. Mount provider and language switcher
|
||||
3. Migrate layout shell copy
|
||||
4. Migrate auth copy
|
||||
5. Migrate settings copy + appearance language control
|
||||
6. Verify SSR and static-export builds
|
||||
7. Document how later pages should adopt `useTranslations`
|
||||
|
||||
## 14. Open Implementation Notes
|
||||
|
||||
- Prefer cookie name `NEXT_LOCALE` unless an existing project cookie convention conflicts during implementation
|
||||
- Prefer minimal surface-area integration with next-intl; avoid introducing locale-based routing APIs that break static export
|
||||
- Keep `internal/util` and backend packages untouched
|
||||
- After implementation, follow repo frontend conventions and existing provider composition style
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -60,6 +61,7 @@ export function AccessLogDetailDialog({
|
||||
item: AccessLogItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
// Keep last selected item while the dialog closes to avoid empty-state flash.
|
||||
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
|
||||
if (item && item !== displayItem) {
|
||||
@@ -71,21 +73,23 @@ export function AccessLogDetailDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>访问日志详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('detail.title')}</DialogTitle>
|
||||
<DialogDescription>{t('detail.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{activeItem ? (
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<DetailField label='日志 ID' value={activeItem.id || '—'} mono />
|
||||
<DetailField
|
||||
label='请求时间'
|
||||
label={t('detail.logId')}
|
||||
value={activeItem.id || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label={t('detail.requestTime')}
|
||||
value={formatDateTime(activeItem.logged_at)}
|
||||
/>
|
||||
<DetailField
|
||||
label='入库时间'
|
||||
label={t('detail.storedAt')}
|
||||
value={
|
||||
activeItem.created_at
|
||||
? formatDateTime(activeItem.created_at)
|
||||
@@ -93,23 +97,29 @@ export function AccessLogDetailDialog({
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点'
|
||||
label={t('detail.node')}
|
||||
value={activeItem.node_name || activeItem.node_id || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点 ID'
|
||||
label={t('detail.nodeId')}
|
||||
value={activeItem.node_id || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label='客户端 IP'
|
||||
label={t('detail.clientIp')}
|
||||
value={activeItem.remote_addr || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField label='地区' value={activeItem.region || '—'} />
|
||||
<DetailField label='域名' value={activeItem.host || '—'} />
|
||||
<DetailField
|
||||
label='状态码'
|
||||
label={t('detail.region')}
|
||||
value={activeItem.region || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label={t('detail.host')}
|
||||
value={activeItem.host || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label={t('detail.status')}
|
||||
value={
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{activeItem.status_code}
|
||||
@@ -117,12 +127,13 @@ export function AccessLogDetailDialog({
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='缓存'
|
||||
label={t('detail.cache')}
|
||||
value={
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{cacheOutcomeLabel(
|
||||
resolveCacheOutcome(activeItem.cache_status),
|
||||
(key) => t(key),
|
||||
)}
|
||||
</Badge>
|
||||
<span className='font-mono text-xs text-muted-foreground'>
|
||||
@@ -132,19 +143,19 @@ export function AccessLogDetailDialog({
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='出站流量'
|
||||
label={t('detail.bytesSent')}
|
||||
value={formatBytes(activeItem.bytes_sent ?? 0)}
|
||||
/>
|
||||
<DetailField
|
||||
label='入站流量'
|
||||
label={t('detail.requestLength')}
|
||||
value={formatBytes(activeItem.request_length ?? 0)}
|
||||
/>
|
||||
<DetailField
|
||||
label='请求耗时'
|
||||
label={t('detail.duration')}
|
||||
value={formatRequestTimeMs(activeItem.request_time_ms)}
|
||||
/>
|
||||
<DetailField
|
||||
label='路径'
|
||||
label={t('detail.path')}
|
||||
value={activeItem.path || '—'}
|
||||
full
|
||||
mono
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, ChevronDown, Search } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
@@ -91,6 +92,7 @@ function DateTimePicker({
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.filters');
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = value ? new Date(value) : undefined;
|
||||
|
||||
@@ -121,7 +123,7 @@ function DateTimePicker({
|
||||
{current ? (
|
||||
format(current, 'yyyy-MM-dd HH:mm')
|
||||
) : (
|
||||
<span className='text-muted-foreground'>选择时间</span>
|
||||
<span className='text-muted-foreground'>{t('pickTime')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -153,12 +155,13 @@ export function AccessLogFilters({
|
||||
onSearch,
|
||||
onReset,
|
||||
}: AccessLogFiltersProps) {
|
||||
const t = useTranslations('accessLogs.filters');
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FilterField label='来源 IP'>
|
||||
<FilterField label={t('remoteAddr')}>
|
||||
<Input
|
||||
value={draft.remoteAddr}
|
||||
onChange={(e) =>
|
||||
@@ -167,22 +170,22 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按 IP 搜索'
|
||||
placeholder={t('remoteAddrPlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label='访问域名'>
|
||||
<FilterField label={t('host')}>
|
||||
<Input
|
||||
value={draft.host}
|
||||
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按域名搜索'
|
||||
placeholder={t('hostPlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label='状态码'>
|
||||
<FilterField label={t('statusCode')}>
|
||||
<Input
|
||||
value={draft.statusCode}
|
||||
onChange={(e) =>
|
||||
@@ -194,7 +197,7 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='输入状态码,如 404'
|
||||
placeholder={t('statusCodePlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
@@ -207,7 +210,7 @@ export function AccessLogFilters({
|
||||
size='sm'
|
||||
className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground'
|
||||
>
|
||||
更多筛选
|
||||
{t('more')}
|
||||
<ChevronDown
|
||||
className={`size-3.5 transition-transform ${
|
||||
moreOpen ? 'rotate-180' : ''
|
||||
@@ -217,7 +220,7 @@ export function AccessLogFilters({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='grid gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<FilterField label='节点 ID'>
|
||||
<FilterField label={t('nodeId')}>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
@@ -228,12 +231,12 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按 node_id 搜索'
|
||||
placeholder={t('nodeIdPlaceholder')}
|
||||
className='pl-8 h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</FilterField>
|
||||
<FilterField label='请求路径'>
|
||||
<FilterField label={t('path')}>
|
||||
<Input
|
||||
value={draft.path}
|
||||
onChange={(e) =>
|
||||
@@ -242,11 +245,11 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按路径搜索'
|
||||
placeholder={t('pathPlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label='时间范围'>
|
||||
<FilterField label={t('timeRange')}>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<DateTimePicker
|
||||
value={draft.since}
|
||||
@@ -268,7 +271,9 @@ export function AccessLogFilters({
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
{t('pageSize')}
|
||||
</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) =>
|
||||
@@ -281,7 +286,7 @@ export function AccessLogFilters({
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} 条
|
||||
{t('pageSizeItem', { count: option })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -289,10 +294,10 @@ export function AccessLogFilters({
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={onSearch}>
|
||||
筛选
|
||||
{t('apply')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={onReset}>
|
||||
清空
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,43 +19,43 @@ export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
||||
|
||||
export const OVERVIEW_RANGE_OPTIONS: {
|
||||
value: OverviewRangeHours;
|
||||
label: string;
|
||||
labelKey: `range.h${OverviewRangeHours}`;
|
||||
}[] = [
|
||||
{ value: 24, label: '24 小时' },
|
||||
{ value: 168, label: '7 天' },
|
||||
{ value: 360, label: '15 天' },
|
||||
{ value: 720, label: '30 天' },
|
||||
{ value: 24, labelKey: 'range.h24' },
|
||||
{ value: 168, labelKey: 'range.h168' },
|
||||
{ value: 360, labelKey: 'range.h360' },
|
||||
{ value: 720, labelKey: 'range.h720' },
|
||||
];
|
||||
|
||||
export const RATE_LIMIT_RANGE_OPTIONS: {
|
||||
value: RateLimitRangeHours;
|
||||
label: string;
|
||||
labelKey: 'range.h24' | 'range.h72';
|
||||
}[] = [
|
||||
{ value: 24, label: '24 小时' },
|
||||
{ value: 72, label: '3 天' },
|
||||
{ value: 24, labelKey: 'range.h24' },
|
||||
{ value: 72, labelKey: 'range.h72' },
|
||||
];
|
||||
|
||||
export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: 'logged_at:desc', label: '时间从新到旧' },
|
||||
{ value: 'logged_at:asc', label: '时间从旧到新' },
|
||||
{ value: 'status_code:desc', label: '状态码从高到低' },
|
||||
{ value: 'status_code:asc', label: '状态码从低到高' },
|
||||
{ value: 'remote_addr:asc', label: 'IP 正序' },
|
||||
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
||||
];
|
||||
{ value: 'logged_at:desc', labelKey: 'sort.logged_at:desc' },
|
||||
{ value: 'logged_at:asc', labelKey: 'sort.logged_at:asc' },
|
||||
{ value: 'status_code:desc', labelKey: 'sort.status_code:desc' },
|
||||
{ value: 'status_code:asc', labelKey: 'sort.status_code:asc' },
|
||||
{ value: 'remote_addr:asc', labelKey: 'sort.remote_addr:asc' },
|
||||
{ value: 'remote_addr:desc', labelKey: 'sort.remote_addr:desc' },
|
||||
] as const;
|
||||
|
||||
export const IP_SORT_OPTIONS = [
|
||||
{ value: 'total_requests:desc', label: '请求数从高到低' },
|
||||
{ value: 'total_requests:asc', label: '请求数从低到高' },
|
||||
{ value: 'request_length:desc', label: '入站从高到低' },
|
||||
{ value: 'request_length:asc', label: '入站从低到高' },
|
||||
{ value: 'bytes_sent:desc', label: '出站从高到低' },
|
||||
{ value: 'bytes_sent:asc', label: '出站从低到高' },
|
||||
{ value: 'success_ratio:desc', label: '2xx 比例从高到低' },
|
||||
{ value: 'success_ratio:asc', label: '2xx 比例从低到高' },
|
||||
{ value: 'last_seen_at:desc', label: '最后访问从新到旧' },
|
||||
{ value: 'last_seen_at:asc', label: '最后访问从旧到新' },
|
||||
];
|
||||
{ value: 'total_requests:desc', labelKey: 'sort.total_requests:desc' },
|
||||
{ value: 'total_requests:asc', labelKey: 'sort.total_requests:asc' },
|
||||
{ value: 'request_length:desc', labelKey: 'sort.request_length:desc' },
|
||||
{ value: 'request_length:asc', labelKey: 'sort.request_length:asc' },
|
||||
{ value: 'bytes_sent:desc', labelKey: 'sort.bytes_sent:desc' },
|
||||
{ value: 'bytes_sent:asc', labelKey: 'sort.bytes_sent:asc' },
|
||||
{ value: 'success_ratio:desc', labelKey: 'sort.success_ratio:desc' },
|
||||
{ value: 'success_ratio:asc', labelKey: 'sort.success_ratio:asc' },
|
||||
{ value: 'last_seen_at:desc', labelKey: 'sort.last_seen_at:desc' },
|
||||
{ value: 'last_seen_at:asc', labelKey: 'sort.last_seen_at:asc' },
|
||||
] as const;
|
||||
|
||||
export function parseSortValue(value: string) {
|
||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||
@@ -72,10 +72,16 @@ export function formatCompactNumber(value: number) {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatOverviewRangeHint(hours: number) {
|
||||
if (hours <= 24) return '近 24 小时';
|
||||
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
|
||||
return `近 ${hours} 小时`;
|
||||
export function formatOverviewRangeHint(
|
||||
hours: number,
|
||||
t: (
|
||||
key: 'rangeHint.hours24' | 'rangeHint.days' | 'rangeHint.hours',
|
||||
values?: { count: number },
|
||||
) => string,
|
||||
) {
|
||||
if (hours <= 24) return t('rangeHint.hours24');
|
||||
if (hours % 24 === 0) return t('rangeHint.days', { count: hours / 24 });
|
||||
return t('rangeHint.hours', { count: hours });
|
||||
}
|
||||
|
||||
export function formatOverviewTrendLabel(value: string, hours: number) {
|
||||
@@ -112,13 +118,9 @@ export function resolveCacheOutcome(
|
||||
return 'uncached';
|
||||
}
|
||||
|
||||
export function cacheOutcomeLabel(outcome: CacheOutcome) {
|
||||
switch (outcome) {
|
||||
case 'hit':
|
||||
return '命中';
|
||||
case 'origin':
|
||||
return '回源';
|
||||
default:
|
||||
return '未缓存';
|
||||
}
|
||||
export function cacheOutcomeLabel(
|
||||
outcome: CacheOutcome,
|
||||
t: (key: `cache.${CacheOutcome}`) => string,
|
||||
) {
|
||||
return t(`cache.${outcome}`);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -38,6 +39,8 @@ export function CleanupDialog({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const t = useTranslations('accessLogs.cleanupDialog');
|
||||
const tCommon = useTranslations('common');
|
||||
const [mode, setMode] = useState<string>('7');
|
||||
const [customDays, setCustomDays] = useState('14');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -49,7 +52,7 @@ export function CleanupDialog({
|
||||
: Number.parseInt(mode, 10);
|
||||
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
|
||||
setError('保留天数必须大于 0');
|
||||
setError(t('invalidDays'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,15 +64,13 @@ export function CleanupDialog({
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理访问日志</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除早于指定保留天数的访问日志记录,操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>保留策略</Label>
|
||||
<Label>{t('policy')}</Label>
|
||||
<Select value={mode} onValueChange={setMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -77,17 +78,17 @@ export function CleanupDialog({
|
||||
<SelectContent>
|
||||
{CLEANUP_PRESETS.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
保留最近 {days} 天
|
||||
{t('keepDays', { days })}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value='custom'>自定义天数</SelectItem>
|
||||
<SelectItem value='custom'>{t('custom')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === 'custom' ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='customDays'>自定义保留天数</Label>
|
||||
<Label htmlFor='customDays'>{t('customDays')}</Label>
|
||||
<Input
|
||||
id='customDays'
|
||||
type='number'
|
||||
@@ -103,7 +104,9 @@ export function CleanupDialog({
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleConfirm}
|
||||
@@ -112,10 +115,10 @@ export function CleanupDialog({
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
清理中...
|
||||
{t('cleaning')}
|
||||
</>
|
||||
) : (
|
||||
'确认清理'
|
||||
t('confirm')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -65,9 +66,12 @@ function PaginationBar({
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.detail');
|
||||
return (
|
||||
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
||||
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('page', { page: page + 1 })}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -75,7 +79,7 @@ function PaginationBar({
|
||||
disabled={loading || page <= 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
上一页
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -83,7 +87,7 @@ function PaginationBar({
|
||||
disabled={loading || !hasMore}
|
||||
onClick={onNext}
|
||||
>
|
||||
下一页
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +117,7 @@ export function DetailTab({
|
||||
onNextPage: () => void;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const [selected, setSelected] = useState<AccessLogItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
@@ -120,7 +125,7 @@ export function DetailTab({
|
||||
<>
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||
<p className='text-sm font-medium'>日志明细</p>
|
||||
<p className='text-sm font-medium'>{t('detail.listTitle')}</p>
|
||||
<Select value={detailSort} onValueChange={onDetailSortChange}>
|
||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||
<SelectValue />
|
||||
@@ -128,7 +133,7 @@ export function DetailTab({
|
||||
<SelectContent>
|
||||
{DETAIL_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -137,25 +142,25 @@ export function DetailTab({
|
||||
{error ? (
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={error.message || '加载失败'}
|
||||
message={error.message || t('loadFailed')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title='暂无访问日志' />
|
||||
<EmptyStateWithBorder title={t('detail.empty')} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>时间</TableHead>
|
||||
<TableHead className='text-xs'>节点</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.time')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.node')}</TableHead>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>域名</TableHead>
|
||||
<TableHead className='text-xs'>路径</TableHead>
|
||||
<TableHead className='text-xs'>缓存</TableHead>
|
||||
<TableHead className='text-xs'>状态码</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.host')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.path')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.cache')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.status')}</TableHead>
|
||||
<TableHead className='w-12 text-center text-xs' />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -190,7 +195,7 @@ export function DetailTab({
|
||||
className='text-[10px]'
|
||||
title={item.cache_status || undefined}
|
||||
>
|
||||
{cacheOutcomeLabel(outcome)}
|
||||
{cacheOutcomeLabel(outcome, (key) => t(key))}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -214,7 +219,7 @@ export function DetailTab({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看详情
|
||||
{t('detail.view')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
@@ -50,9 +51,12 @@ import {
|
||||
type OverviewRangeHours,
|
||||
} from './access-log-utils';
|
||||
|
||||
const trendChartConfig = {
|
||||
requests: { label: '请求数', color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
function useTrendChartConfig() {
|
||||
const t = useTranslations('accessLogs');
|
||||
return {
|
||||
requests: { label: t('ip.requests'), color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
}
|
||||
|
||||
function resolveBucketMinutes(hours: number) {
|
||||
if (hours <= 24) return 30;
|
||||
@@ -104,6 +108,7 @@ function MiniRankCard({
|
||||
items: { label: string; value: number }[];
|
||||
color: string;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.analysis');
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='mb-2 text-sm font-medium'>{title}</p>
|
||||
@@ -111,7 +116,7 @@ function MiniRankCard({
|
||||
items={items}
|
||||
color={color}
|
||||
className='!h-[220px]'
|
||||
emptyMessage={`暂无 ${title} 数据`}
|
||||
emptyMessage={t('emptySeries', { title })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -126,6 +131,7 @@ function AddToIPGroupPanel({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.analysis');
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
|
||||
|
||||
@@ -179,7 +185,7 @@ function AddToIPGroupPanel({
|
||||
const groupId = Number.parseInt(selectedGroupId, 10);
|
||||
const group = addableGroups.find((item) => item.id === groupId);
|
||||
if (!group) {
|
||||
toast.error('请选择要加入的 IP 组');
|
||||
toast.error(t('selectGroup'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -187,10 +193,10 @@ function AddToIPGroupPanel({
|
||||
group,
|
||||
nextList: [...(group.ip_list ?? []), ip.trim()],
|
||||
});
|
||||
toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`);
|
||||
toast.success(t('added', { ip, name: group.name }));
|
||||
setSelectedGroupId('');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '加入 IP 组失败');
|
||||
toast.error(error instanceof Error ? error.message : t('addFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,9 +208,9 @@ function AddToIPGroupPanel({
|
||||
(entry) => entry.trim() !== ip.trim(),
|
||||
),
|
||||
});
|
||||
toast.success(`已从 IP 组「${group.name}」移除 ${ip}`);
|
||||
toast.success(t('removed', { name: group.name, ip }));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '移除失败');
|
||||
toast.error(error instanceof Error ? error.message : t('removeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,21 +226,21 @@ function AddToIPGroupPanel({
|
||||
>
|
||||
<DialogContent className='max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
|
||||
<DialogTitle>{t('joinTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
目标 IP:
|
||||
{t('targetIp')}
|
||||
<span className='font-mono text-foreground'>{ip}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{groupsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder title='加载 IP 组' />
|
||||
<LoadingStateWithBorder title={t('loadingGroups')} />
|
||||
) : groupsQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
groupsQuery.error instanceof Error
|
||||
? groupsQuery.error.message
|
||||
: '加载 IP 组失败'
|
||||
: t('loadGroupsFailed')
|
||||
}
|
||||
onRetry={() => void groupsQuery.refetch()}
|
||||
/>
|
||||
@@ -243,10 +249,10 @@ function AddToIPGroupPanel({
|
||||
{matchedGroups.length > 0 ? (
|
||||
<div className='space-y-2 rounded-lg border border-dashed p-3'>
|
||||
<p className='text-sm font-medium text-foreground'>
|
||||
该 IP 已存在于以下 IP 组
|
||||
{t('alreadyIn')}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
可选择删除,或继续添加到其他 IP 组。
|
||||
{t('alreadyInHint')}
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{matchedGroups.map((group) => (
|
||||
@@ -259,7 +265,10 @@ function AddToIPGroupPanel({
|
||||
{group.name}
|
||||
</p>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
{group.type} · {group.ip_list?.length ?? 0} 条
|
||||
{t('entryCount', {
|
||||
type: group.type,
|
||||
count: group.ip_list?.length ?? 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{group.type === 'manual' ? (
|
||||
@@ -275,13 +284,13 @@ function AddToIPGroupPanel({
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className='mr-1 size-3.5' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
不可手动删除
|
||||
{t('cannotDelete')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -290,15 +299,15 @@ function AddToIPGroupPanel({
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
该 IP 尚未加入任何 IP 组。
|
||||
{t('notInAny')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>添加到其他 IP 组</p>
|
||||
<p className='text-sm font-medium'>{t('addToOther')}</p>
|
||||
{addableGroups.length === 0 ? (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
没有可写入的手动 IP 组(或已全部包含该 IP)。
|
||||
{t('noWritable')}
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
@@ -306,7 +315,7 @@ function AddToIPGroupPanel({
|
||||
onValueChange={setSelectedGroupId}
|
||||
>
|
||||
<SelectTrigger className='h-9 text-xs'>
|
||||
<SelectValue placeholder='选择手动 IP 组' />
|
||||
<SelectValue placeholder={t('selectManual')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{addableGroups.map((group) => (
|
||||
@@ -323,7 +332,7 @@ function AddToIPGroupPanel({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={onClose}>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAdd()}
|
||||
@@ -336,10 +345,10 @@ function AddToIPGroupPanel({
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
处理中...
|
||||
{t('processing')}
|
||||
</>
|
||||
) : (
|
||||
'加入所选 IP 组'
|
||||
t('joinSelected')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -361,6 +370,8 @@ export function IpAnalysisPanel({
|
||||
*/
|
||||
initialHours?: number;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const trendChartConfig = useTrendChartConfig();
|
||||
const [ipGroupOpen, setIpGroupOpen] = useState(false);
|
||||
const [rangeHours, setRangeHours] = useState(() =>
|
||||
clampAnalysisHours(initialHours),
|
||||
@@ -368,8 +379,8 @@ export function IpAnalysisPanel({
|
||||
|
||||
const bucketMinutes = resolveBucketMinutes(rangeHours);
|
||||
const rangeHint = isOverviewPreset(rangeHours)
|
||||
? formatOverviewRangeHint(rangeHours)
|
||||
: `近 ${rangeHours} 小时`;
|
||||
? formatOverviewRangeHint(rangeHours, (key, values) => t(key, values))
|
||||
: t('analysis.recentHours', { hours: rangeHours });
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: [
|
||||
@@ -411,7 +422,7 @@ export function IpAnalysisPanel({
|
||||
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
|
||||
|
||||
if (!ip) {
|
||||
return <EmptyStateWithBorder description='没有有效 IP。' />;
|
||||
return <EmptyStateWithBorder description={t('analysis.invalidIp')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -423,7 +434,8 @@ export function IpAnalysisPanel({
|
||||
variant='outline'
|
||||
onClick={() => setIpGroupOpen(true)}
|
||||
>
|
||||
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
|
||||
<ShieldPlus className='mr-1 size-3.5' />
|
||||
{t('analysis.addToGroup')}
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
@@ -441,14 +453,14 @@ export function IpAnalysisPanel({
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{isLoadingIP ? (
|
||||
<LoadingStateWithBorder title='加载 IP 分析' />
|
||||
<LoadingStateWithBorder title={t('analysis.loading')} />
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{analysisQuery.isError ? (
|
||||
@@ -456,34 +468,34 @@ export function IpAnalysisPanel({
|
||||
message={
|
||||
analysisQuery.error instanceof Error
|
||||
? analysisQuery.error.message
|
||||
: '加载 IP 分析失败'
|
||||
: t('analysis.loadFailed')
|
||||
}
|
||||
onRetry={() => void analysisQuery.refetch()}
|
||||
/>
|
||||
) : analysis ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
<MetricCard
|
||||
label='总请求'
|
||||
label={t('analysis.totalRequests')}
|
||||
value={formatCompactNumber(analysis.summary.total_requests)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='错误数'
|
||||
label={t('analysis.errors')}
|
||||
value={formatCompactNumber(analysis.summary.error_count)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='已提供带宽'
|
||||
label={t('analysis.bandwidth')}
|
||||
value={formatBytes(analysis.summary.bandwidth_served)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='接收数据'
|
||||
label={t('analysis.received')}
|
||||
value={formatBytes(analysis.summary.bytes_received)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立域名'
|
||||
label={t('analysis.uniqueHosts')}
|
||||
value={formatCompactNumber(analysis.summary.unique_hosts)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立路径'
|
||||
label={t('analysis.uniquePaths')}
|
||||
value={formatCompactNumber(analysis.summary.unique_paths)}
|
||||
/>
|
||||
</div>
|
||||
@@ -492,9 +504,13 @@ export function IpAnalysisPanel({
|
||||
<div className='space-y-3 rounded-lg border border-dashed p-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>IP 请求趋势</p>
|
||||
<p className='text-sm font-medium'>{t('analysis.trend')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
|
||||
{t('analysis.trendMeta', {
|
||||
ip,
|
||||
range: rangeHint,
|
||||
minutes: bucketMinutes,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -506,7 +522,7 @@ export function IpAnalysisPanel({
|
||||
void analysisQuery.refetch();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
{t('analysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -515,13 +531,13 @@ export function IpAnalysisPanel({
|
||||
message={
|
||||
trendQuery.error instanceof Error
|
||||
? trendQuery.error.message
|
||||
: '加载趋势失败'
|
||||
: t('analysis.trendFailed')
|
||||
}
|
||||
onRetry={() => void trendQuery.refetch()}
|
||||
/>
|
||||
) : trendChartData.every((point) => point.requests === 0) ? (
|
||||
<EmptyStateWithBorder
|
||||
description={`该 IP 在${rangeHint}内没有访问记录。`}
|
||||
description={t('analysis.trendEmpty', { range: rangeHint })}
|
||||
/>
|
||||
) : (
|
||||
<ChartContainer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -31,19 +32,20 @@ export function IpDetailDialog({
|
||||
setDisplayAddr(remoteAddr);
|
||||
setDisplayRegion(region);
|
||||
}
|
||||
const t = useTranslations('accessLogs.ip');
|
||||
const ip = remoteAddr ?? displayAddr ?? '';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>IP 详情</DialogTitle>
|
||||
<DialogTitle>{t('dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className='font-mono text-foreground'>{ip || '—'}</span>
|
||||
{displayRegion ? (
|
||||
<span className='text-muted-foreground'> · {displayRegion}</span>
|
||||
) : null}
|
||||
。查看该 IP 的访问趋势、分布与 WAF IP 组操作。
|
||||
{t('dialogDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<IpAnalysisPanel
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -72,20 +73,21 @@ const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
|
||||
export function validateCustomTimeRange(
|
||||
sinceLocal: string,
|
||||
untilLocal: string,
|
||||
t?: (key: 'ip.needRange' | 'ip.invalidTime' | 'ip.endAfterStart' | 'ip.maxRange') => string,
|
||||
): string | null {
|
||||
if (!sinceLocal.trim() || !untilLocal.trim()) {
|
||||
return '请填写开始与结束时间';
|
||||
return t ? t('ip.needRange') : 'needRange';
|
||||
}
|
||||
const sinceMs = new Date(sinceLocal).getTime();
|
||||
const untilMs = new Date(untilLocal).getTime();
|
||||
if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
|
||||
return '时间格式无效';
|
||||
return t ? t('ip.invalidTime') : 'invalidTime';
|
||||
}
|
||||
if (untilMs <= sinceMs) {
|
||||
return '结束时间必须晚于开始时间';
|
||||
return t ? t('ip.endAfterStart') : 'endAfterStart';
|
||||
}
|
||||
if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
|
||||
return '时间范围不能超过 30 天';
|
||||
return t ? t('ip.maxRange') : 'maxRange';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -128,11 +130,12 @@ function PaginationBar({
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.ip');
|
||||
return (
|
||||
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前第 {page + 1} 页
|
||||
{typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}
|
||||
{t('page', { page: page + 1 })}
|
||||
{typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
@@ -141,7 +144,7 @@ function PaginationBar({
|
||||
disabled={loading || page <= 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
上一页
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -149,7 +152,7 @@ function PaginationBar({
|
||||
disabled={loading || !hasMore}
|
||||
onClick={onNext}
|
||||
>
|
||||
下一页
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,6 +204,7 @@ export function IpTab({
|
||||
onNextPage: () => void;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [draftSince, setDraftSince] = useState(customSince);
|
||||
@@ -232,13 +236,15 @@ export function IpTab({
|
||||
|
||||
const rangeHint =
|
||||
timeMode === 'custom' && customSince && customUntil
|
||||
? '自定义区间'
|
||||
? t('ip.customRange')
|
||||
: timeMode === 'custom'
|
||||
? '自定义(未应用)'
|
||||
: formatOverviewRangeHint(hours);
|
||||
? t('ip.customPending')
|
||||
: formatOverviewRangeHint(hours, (key, values) => t(key, values));
|
||||
|
||||
const handleApplyCustom = () => {
|
||||
const message = validateCustomTimeRange(draftSince, draftUntil);
|
||||
const message = validateCustomTimeRange(draftSince, draftUntil, (key) =>
|
||||
t(key),
|
||||
);
|
||||
if (message) {
|
||||
setCustomError(message);
|
||||
return;
|
||||
@@ -253,11 +259,11 @@ export function IpTab({
|
||||
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm font-medium'>时间范围</p>
|
||||
<p className='text-sm font-medium'>{t('ip.timeRange')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前:{rangeHint}
|
||||
{t('ip.current', { range: rangeHint })}
|
||||
{data?.total_ip != null
|
||||
? ` · ${formatCompactNumber(data.total_ip)} 个 IP`
|
||||
? t('ip.total', { count: data.total_ip })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -281,7 +287,7 @@ export function IpTab({
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
@@ -291,7 +297,7 @@ export function IpTab({
|
||||
className='h-8 text-xs'
|
||||
onClick={() => onTimeModeChange('custom')}
|
||||
>
|
||||
自定义
|
||||
{t('ip.custom')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +306,7 @@ export function IpTab({
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-end gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>开始</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.start')}</p>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
className='h-9 w-52 text-xs'
|
||||
@@ -312,7 +318,7 @@ export function IpTab({
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>结束</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.end')}</p>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
className='h-9 w-52 text-xs'
|
||||
@@ -328,14 +334,14 @@ export function IpTab({
|
||||
className='h-9 text-xs'
|
||||
onClick={handleApplyCustom}
|
||||
>
|
||||
应用
|
||||
{t('ip.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
{customError ? (
|
||||
<p className='text-xs text-destructive'>{customError}</p>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。
|
||||
{t('ip.applyHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -343,7 +349,7 @@ export function IpTab({
|
||||
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>排序</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.sort')}</p>
|
||||
<Select value={sort} onValueChange={onSortChange}>
|
||||
<SelectTrigger className='h-8 w-48 text-xs'>
|
||||
<SelectValue />
|
||||
@@ -351,14 +357,14 @@ export function IpTab({
|
||||
<SelectContent>
|
||||
{IP_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>每页</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.pageSize')}</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) =>
|
||||
@@ -382,30 +388,38 @@ export function IpTab({
|
||||
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||
<p className='text-sm font-medium'>IP 明细</p>
|
||||
<p className='text-sm font-medium'>{t('ip.title')}</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={error.message || '加载失败'}
|
||||
message={error.message || t('loadFailed')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title='暂无 IP 数据' />
|
||||
<EmptyStateWithBorder title={t('ip.empty')} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>地区</TableHead>
|
||||
<TableHead className='text-xs text-right'>请求数</TableHead>
|
||||
<TableHead className='text-xs text-right'>2xx 比例</TableHead>
|
||||
<TableHead className='text-xs text-right'>入站</TableHead>
|
||||
<TableHead className='text-xs text-right'>出站</TableHead>
|
||||
<TableHead className='text-xs'>最后访问</TableHead>
|
||||
<TableHead className='text-xs'>{t('ip.region')}</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.requests')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.successRatio')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.inbound')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.outbound')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs'>{t('ip.lastSeen')}</TableHead>
|
||||
<TableHead className='w-12 text-center text-xs' />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -453,7 +467,7 @@ export function IpTab({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看 IP 详情
|
||||
{t('ip.view')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
@@ -278,6 +279,7 @@ export function OverviewTab({
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.overview');
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<OverviewToolbar
|
||||
@@ -289,18 +291,18 @@ export function OverviewTab({
|
||||
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder
|
||||
title='加载访问概览'
|
||||
description='正在聚合请求量、访问量与带宽趋势...'
|
||||
title={t('loadingTitle')}
|
||||
description={t('loadingDesc')}
|
||||
/>
|
||||
) : error ? (
|
||||
<ErrorInline
|
||||
message={error.message || '加载访问概览失败'}
|
||||
message={error.message || t('loadFailed')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
) : !data ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无概览数据'
|
||||
description='当前时间范围内没有可展示的访问统计。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<OverviewContent data={data} hours={hours} />
|
||||
@@ -316,13 +318,14 @@ function OverviewContent({
|
||||
data: AccessLogOverview;
|
||||
hours: number;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const requestLabels = data.trends.requests.map((point) =>
|
||||
formatOverviewTrendLabel(point.bucket_started_at, hours),
|
||||
);
|
||||
const requestValues = data.trends.requests.map((point) => point.value);
|
||||
const visitValues = data.trends.visits.map((point) => point.value);
|
||||
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
|
||||
const hint = formatOverviewRangeHint(hours);
|
||||
const hint = formatOverviewRangeHint(hours, (key, values) => t(key, values));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -339,7 +342,7 @@ function OverviewContent({
|
||||
<SparklineMetricCard
|
||||
title='Total Visits'
|
||||
value={formatCompactNumber(data.summary.total_visits)}
|
||||
hint={`${hint} · 独立访客`}
|
||||
hint={t('overview.uniqueVisitors', { hint })}
|
||||
color='#38bdf8'
|
||||
fillColor='rgba(56, 189, 248, 0.16)'
|
||||
labels={requestLabels}
|
||||
@@ -348,7 +351,7 @@ function OverviewContent({
|
||||
<SparklineMetricCard
|
||||
title='Bandwidth Served'
|
||||
value={formatBytes(data.summary.bandwidth_served)}
|
||||
hint={`${hint} · 已提供数据`}
|
||||
hint={t('overview.served', { hint })}
|
||||
color='#34d399'
|
||||
fillColor='rgba(52, 211, 153, 0.16)'
|
||||
labels={requestLabels}
|
||||
@@ -363,7 +366,7 @@ function OverviewContent({
|
||||
Requests over time
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
观察请求量是否出现异常抬升或回落。
|
||||
{t('overview.requestsHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -373,7 +376,7 @@ function OverviewContent({
|
||||
showSummary={false}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
label: t('overview.requests'),
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
@@ -387,32 +390,32 @@ function OverviewContent({
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<PieDistributionCard
|
||||
title='Requests by device type'
|
||||
description='按设备类型统计请求占比。'
|
||||
description={t('overview.deviceDesc')}
|
||||
items={data.device_types ?? []}
|
||||
emptyMessage='暂无设备类型数据'
|
||||
emptyMessage={t('overview.deviceEmpty')}
|
||||
/>
|
||||
<PieDistributionCard
|
||||
title='Status code'
|
||||
description='按 HTTP 状态码统计请求占比。'
|
||||
description={t('overview.statusDesc')}
|
||||
items={data.status_codes ?? []}
|
||||
emptyMessage='暂无状态码分布数据'
|
||||
emptyMessage={t('overview.statusEmpty')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<RankCard
|
||||
title='Top Paths'
|
||||
description='访问量最高的请求路径。'
|
||||
description={t('overview.topPathsDesc')}
|
||||
items={toRankItems(data.top_paths)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top Hosts'
|
||||
description='流量集中的访问域名。'
|
||||
description={t('overview.topHostsDesc')}
|
||||
items={toRankItems(data.top_hosts)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top IPs'
|
||||
description='请求次数最多的来源 IP。'
|
||||
description={t('overview.topIpsDesc')}
|
||||
items={toRankItems(data.top_ips)}
|
||||
/>
|
||||
</div>
|
||||
@@ -420,17 +423,17 @@ function OverviewContent({
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<RankCard
|
||||
title='Top browsers'
|
||||
description='按浏览器聚合的请求排行。'
|
||||
description={t('overview.browsersDesc')}
|
||||
items={toRankItems(data.top_browsers)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top Operating System'
|
||||
description='按操作系统聚合的请求排行。'
|
||||
description={t('overview.osDesc')}
|
||||
items={toRankItems(data.top_operating_systems)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top User-Agent'
|
||||
description='原始 User-Agent 请求排行。'
|
||||
description={t('overview.uaDesc')}
|
||||
items={toRankItems(data.top_user_agents)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -71,6 +72,7 @@ export function OverviewHostFilter({
|
||||
hosts: string[];
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.toolbar');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
|
||||
@@ -160,10 +162,14 @@ export function OverviewHostFilter({
|
||||
hosts.length > 0 ? 'border-primary text-primary' : undefined,
|
||||
)}
|
||||
title={
|
||||
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
|
||||
hosts.length > 0
|
||||
? t('hostsFiltered', { count: hosts.length })
|
||||
: t('filterByHost')
|
||||
}
|
||||
aria-label={
|
||||
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
|
||||
hosts.length > 0
|
||||
? t('hostsFiltered', { count: hosts.length })
|
||||
: t('filterByHost')
|
||||
}
|
||||
>
|
||||
<Filter className='size-3.5' />
|
||||
@@ -171,7 +177,7 @@ export function OverviewHostFilter({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<p className='text-sm font-medium'>筛选域名</p>
|
||||
<p className='text-sm font-medium'>{t('filterHosts')}</p>
|
||||
{hosts.length > 0 ? (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -181,25 +187,25 @@ export function OverviewHostFilter({
|
||||
onClick={() => onHostsChange([])}
|
||||
>
|
||||
<X className='mr-1 size-3' />
|
||||
清除
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder='搜索 Zone 或域名'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
|
||||
{zonesQuery.isLoading ? (
|
||||
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
加载域名…
|
||||
{t('loadingHosts')}
|
||||
</div>
|
||||
) : zonesQuery.isError ? (
|
||||
<div className='space-y-2 py-2'>
|
||||
<p className='text-xs text-destructive'>加载域名失败</p>
|
||||
<p className='text-xs text-destructive'>{t('loadHostsFailed')}</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -207,12 +213,12 @@ export function OverviewHostFilter({
|
||||
className='h-7 text-xs'
|
||||
onClick={() => void zonesQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
{t('retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredZones.length === 0 ? (
|
||||
<p className='py-6 text-center text-xs text-muted-foreground'>
|
||||
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'}
|
||||
{zones.length === 0 ? t('noRegistered') : t('noMatch')}
|
||||
</p>
|
||||
) : (
|
||||
filteredZones.map((zone) => {
|
||||
@@ -247,7 +253,7 @@ export function OverviewHostFilter({
|
||||
onCheckedChange={(checked) =>
|
||||
toggleZone(zone.domains, checked)
|
||||
}
|
||||
aria-label={`选择 Zone ${zone.zoneName}`}
|
||||
aria-label={t('selectZone', { name: zone.zoneName })}
|
||||
/>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
@@ -286,7 +292,7 @@ export function OverviewHostFilter({
|
||||
onCheckedChange={(checked) =>
|
||||
toggleHost(domain, checked)
|
||||
}
|
||||
aria-label={`选择域名 ${domain}`}
|
||||
aria-label={t('selectHost', { name: domain })}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate font-mono'>
|
||||
{domain}
|
||||
@@ -311,7 +317,7 @@ export function OverviewToolbar({
|
||||
hosts,
|
||||
onHoursChange,
|
||||
onHostsChange,
|
||||
rangeOptions = OVERVIEW_RANGE_OPTIONS,
|
||||
rangeOptions,
|
||||
}: {
|
||||
hours: OverviewRangeHours | RateLimitRangeHours;
|
||||
hosts: string[];
|
||||
@@ -319,11 +325,20 @@ export function OverviewToolbar({
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
rangeOptions?: ReadonlyArray<{ value: number; label: string }>;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const options =
|
||||
rangeOptions ??
|
||||
OVERVIEW_RANGE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}));
|
||||
return (
|
||||
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||
{hosts.length > 0 ? (
|
||||
<Badge variant='secondary' className='max-w-[260px] truncate'>
|
||||
{hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`}
|
||||
{hosts.length === 1
|
||||
? hosts[0]
|
||||
: t('toolbar.selectedCount', { count: hosts.length })}
|
||||
</Badge>
|
||||
) : null}
|
||||
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
|
||||
@@ -338,7 +353,7 @@ export function OverviewToolbar({
|
||||
size='sm'
|
||||
className='justify-end'
|
||||
>
|
||||
{rangeOptions.map((option) => (
|
||||
{options.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
@@ -45,6 +46,7 @@ function resolveTab(value: string | null): AccessLogTab {
|
||||
}
|
||||
|
||||
function AccessLogsPageContent() {
|
||||
const t = useTranslations('accessLogs');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -152,14 +154,14 @@ function AccessLogsPageContent() {
|
||||
mutationFn: (retentionDays: number) =>
|
||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||
onSuccess: async (result) => {
|
||||
toast.success(`已清理 ${result.deleted_count} 条日志`);
|
||||
toast.success(t('cleaned', { count: result.deleted_count }));
|
||||
setCleanupOpen(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'access-logs'],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '清理失败');
|
||||
toast.error(error instanceof Error ? error.message : t('cleanupFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,10 +215,10 @@ function AccessLogsPageContent() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<ScrollText className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看访问概览、IP 明细与请求日志。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
@@ -229,7 +231,7 @@ function AccessLogsPageContent() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -237,16 +239,16 @@ function AccessLogsPageContent() {
|
||||
onClick={() => setCleanupOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理日志
|
||||
{t('cleanup')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className='grid w-full max-w-lg grid-cols-3'>
|
||||
<TabsTrigger value='overview'>概览</TabsTrigger>
|
||||
<TabsTrigger value='ips'>IP 明细</TabsTrigger>
|
||||
<TabsTrigger value='list'>日志明细</TabsTrigger>
|
||||
<TabsTrigger value='overview'>{t('tabOverview')}</TabsTrigger>
|
||||
<TabsTrigger value='ips'>{t('tabIps')}</TabsTrigger>
|
||||
<TabsTrigger value='list'>{t('tabList')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='mt-4'>
|
||||
@@ -343,12 +345,13 @@ function AccessLogsPageContent() {
|
||||
}
|
||||
|
||||
export default function AccessLogsPage() {
|
||||
const t = useTranslations('accessLogs');
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<LoadingStateWithBorder
|
||||
title='加载访问日志'
|
||||
description='正在准备页面...'
|
||||
title={t('loadingTitle')}
|
||||
description={t('loadingDesc')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -52,6 +53,7 @@ interface CacheManagerProps {
|
||||
}
|
||||
|
||||
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
const t = useTranslations('admin.database.cache');
|
||||
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
|
||||
const [loadingCache, setLoadingCache] = useState<boolean>(true);
|
||||
const [savingConfig, setSavingConfig] = useState<boolean>(false);
|
||||
@@ -73,13 +75,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
setTtlMinutes(data.ttl_minutes.toString());
|
||||
setLruEnabled(data.lru_enabled);
|
||||
} catch (err) {
|
||||
toast.error('获取磁盘缓存状态失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('fetchCacheStatusFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setLoadingCache(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (e: React.FormEvent) => {
|
||||
@@ -87,14 +89,14 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
const size = parseInt(maxSizeMB, 10);
|
||||
const ttl = parseInt(ttlMinutes, 10);
|
||||
if (isNaN(size) || size < 1) {
|
||||
toast.error('保存失败', {
|
||||
description: '最大容量限制必须是大于等于 1 的整数',
|
||||
toast.error(t('saveFailed'), {
|
||||
description: t('maxSizeMustBePositive'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
toast.error('保存失败', {
|
||||
description: '默认过期时间必须是大于等于 0 的整数',
|
||||
toast.error(t('saveFailed'), {
|
||||
description: t('ttlMustBeNonNegative'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -105,11 +107,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
ttl_minutes: ttl,
|
||||
lru_enabled: lruEnabled,
|
||||
});
|
||||
toast.success('保存成功', { description: '磁盘缓存策略已热更新' });
|
||||
toast.success(t('saveSuccess'), {
|
||||
description: t('cachePolicyHotUpdated'),
|
||||
});
|
||||
await fetchCacheStatus(true);
|
||||
} catch (err) {
|
||||
toast.error('保存配置失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('saveConfigFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
@@ -121,12 +125,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
setClearingCache(true);
|
||||
try {
|
||||
await services.adminCache.clearCache();
|
||||
toast.success('清空成功', { description: '缓存数据已全部清除' });
|
||||
toast.success(t('clearSuccess'), { description: t('cacheDataCleared') });
|
||||
setShowClearConfirm(false);
|
||||
await fetchCacheStatus(true);
|
||||
} catch (err) {
|
||||
toast.error('清空缓存失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('clearCacheFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setClearingCache(false);
|
||||
@@ -149,10 +153,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
<div className='space-y-0.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<HardDrive className='size-4 text-primary animate-pulse' />
|
||||
<CardTitle className='text-sm font-semibold'>缓存管理</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('cacheManagement')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription className='text-[11px]'>
|
||||
管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略
|
||||
{t('cacheManagementDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +172,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
<RefreshCw
|
||||
className={`size-3 ${loadingCache ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新状态
|
||||
{t('refreshStatus')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-4'>
|
||||
@@ -181,13 +187,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
{/* 左边:状态区 (2/5 cols) */}
|
||||
<div className='lg:col-span-2 space-y-4'>
|
||||
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
|
||||
运行状态
|
||||
{t('runtimeStatus')}
|
||||
</h4>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
{/* 已占空间 */}
|
||||
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
|
||||
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
|
||||
已用空间
|
||||
{t('usedSpace')}
|
||||
</p>
|
||||
<p className='text-xl font-bold tracking-tight text-foreground'>
|
||||
{formatBytes(cacheStatus.total_size)}
|
||||
@@ -196,12 +202,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
{/* Key数量 */}
|
||||
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
|
||||
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
|
||||
缓存键数量
|
||||
{t('cacheKeysCount')}
|
||||
</p>
|
||||
<p className='text-xl font-bold tracking-tight text-foreground'>
|
||||
{formatNumber(cacheStatus.keys_count)}{' '}
|
||||
<span className='text-xs text-muted-foreground font-normal'>
|
||||
个文件
|
||||
{t('files')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -210,7 +216,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
{/* 存储路径 */}
|
||||
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
|
||||
<p className='text-[10px] text-muted-foreground font-medium mb-1.5'>
|
||||
缓存基准目录
|
||||
{t('cacheBaseDirectory')}
|
||||
</p>
|
||||
<code
|
||||
className='text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate'
|
||||
@@ -225,20 +231,20 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
className='h-8 text-xs font-medium w-full'
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
立即清空缓存
|
||||
{t('clearCacheNow')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 右边:配置区 (3/5 cols) */}
|
||||
<div className='lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4'>
|
||||
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
|
||||
策略配置
|
||||
{t('policyConfig')}
|
||||
</h4>
|
||||
<form onSubmit={handleSaveConfig} className='space-y-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='maxSizeMB' className='text-xs font-medium'>
|
||||
最大容量限制 (MB)
|
||||
{t('maxSizeLimit')}
|
||||
</Label>
|
||||
<Input
|
||||
id='maxSizeMB'
|
||||
@@ -247,17 +253,17 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => setMaxSizeMB(e.target.value)}
|
||||
className='h-8 text-xs bg-background/50 border-border/40'
|
||||
placeholder='例如 100'
|
||||
placeholder={t('maxSizePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className='text-[9px] text-muted-foreground'>
|
||||
当总大小超出该值时,自动触发淘汰算法
|
||||
{t('maxSizeHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='ttlMinutes' className='text-xs font-medium'>
|
||||
生存时间限制 (分钟)
|
||||
{t('ttlLimit')}
|
||||
</Label>
|
||||
<Input
|
||||
id='ttlMinutes'
|
||||
@@ -266,11 +272,11 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
value={ttlMinutes}
|
||||
onChange={(e) => setTtlMinutes(e.target.value)}
|
||||
className='h-8 text-xs bg-background/50 border-border/40'
|
||||
placeholder='例如 60,0 表示永不过期'
|
||||
placeholder={t('ttlPlaceholder')}
|
||||
required
|
||||
/>
|
||||
<p className='text-[9px] text-muted-foreground'>
|
||||
缓存项的最长存活时间,超期后将失效被清理
|
||||
{t('ttlHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,10 +287,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
htmlFor='lruEnabled'
|
||||
className='text-xs font-semibold block cursor-pointer'
|
||||
>
|
||||
启用 LRU 淘汰机制
|
||||
{t('enableLru')}
|
||||
</Label>
|
||||
<span className='text-[10px] text-muted-foreground block'>
|
||||
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
|
||||
{t('enableLruDesc')}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -300,7 +306,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
disabled={savingConfig}
|
||||
className='h-8 text-xs px-4'
|
||||
>
|
||||
{savingConfig ? '正在保存...' : '保存设置'}
|
||||
{savingConfig ? t('saving') : t('saveSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -309,7 +315,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
|
||||
<HardDrive className='size-8 opacity-45 mb-2' />
|
||||
<span className='text-xs'>未加载到缓存状态信息</span>
|
||||
<span className='text-xs'>{t('noCacheStatus')}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -320,11 +326,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
<DialogHeader>
|
||||
<DialogTitle className='text-sm font-semibold flex items-center gap-2 text-destructive'>
|
||||
<Trash2 className='size-4' />
|
||||
确认清空所有缓存?
|
||||
{t('confirmClearAllCache')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-xs text-muted-foreground pt-1'>
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为
|
||||
0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
{t('confirmClearDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className='flex gap-2 justify-end mt-4'>
|
||||
@@ -335,7 +340,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -344,7 +349,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
{clearingCache ? '清理中...' : '确认清空'}
|
||||
{clearingCache ? t('clearing') : t('confirmClear')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTheme } from 'next-themes';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -49,6 +50,7 @@ interface SQLConsoleProps {
|
||||
}
|
||||
|
||||
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
const t = useTranslations('admin.database.sql');
|
||||
// SQL 控制台状态
|
||||
const [sqlQuery, setSqlQuery] = useState<string>('');
|
||||
const [executingSQL, setExecutingSQL] = useState<boolean>(false);
|
||||
@@ -97,10 +99,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
try {
|
||||
const result = await DbManageService.executeSQL(sqlQuery);
|
||||
setSqlResult(result);
|
||||
toast.success('SQL 执行成功');
|
||||
toast.success(t('sqlExecSuccess'));
|
||||
} catch (err) {
|
||||
setSqlError(err instanceof Error ? err.message : '未知执行错误');
|
||||
toast.error('SQL 执行失败');
|
||||
setSqlError(err instanceof Error ? err.message : t('unknownExecError'));
|
||||
toast.error(t('sqlExecFailed'));
|
||||
} finally {
|
||||
setExecutingSQL(false);
|
||||
}
|
||||
@@ -134,7 +136,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
<Terminal className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
SQL 查询终端
|
||||
{t('sqlQueryTerminal')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,34 +155,34 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Terminal className='size-4 text-primary' />
|
||||
<span className='text-xs font-semibold'>SQL 编辑器</span>
|
||||
<span className='text-xs font-semibold'>{t('sqlEditor')}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
{/* 快速模板 */}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>
|
||||
快速模板:
|
||||
{t('quickTemplates')}
|
||||
</span>
|
||||
<Select onValueChange={handlePresetSQLChange}>
|
||||
<SelectTrigger className='h-7 w-[180px] text-[11px] bg-background'>
|
||||
<SelectValue placeholder='选择预设 SQL' />
|
||||
<SelectValue placeholder={t('selectPresetSQL')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='SELECT * FROM users LIMIT 10;'>
|
||||
查询用户 (SELECT users)
|
||||
{t('queryUsers')}
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT * FROM uploads LIMIT 10;'>
|
||||
查询文件 (SELECT uploads)
|
||||
{t('queryFiles')}
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;'>
|
||||
查询任务流水 (SELECT task_executions)
|
||||
{t('queryTaskExecutions')}
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT sqlite_version();'>
|
||||
SQLite 版本 (SQLite only)
|
||||
{t('sqliteVersion')}
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT version();'>
|
||||
PostgreSQL 版本 (Postgres only)
|
||||
{t('postgresVersion')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -193,7 +195,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
size='icon'
|
||||
className='h-7 w-7 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => setSqlQuery('')}
|
||||
title='清空编辑器'
|
||||
title={t('clearEditor')}
|
||||
>
|
||||
<Trash2 className='size-3.5' />
|
||||
</Button>
|
||||
@@ -204,7 +206,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
disabled={executingSQL || !sqlQuery.trim()}
|
||||
>
|
||||
<Play className='size-3' />
|
||||
{executingSQL ? '运行中...' : '运行'}
|
||||
{executingSQL ? t('running') : t('run')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -236,7 +238,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className='h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
|
||||
title='拖动调整大小'
|
||||
title={t('dragResize')}
|
||||
>
|
||||
<div className='w-8 h-1 rounded bg-muted-foreground/30' />
|
||||
</div>
|
||||
@@ -245,10 +247,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
<div className='flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10'>
|
||||
{/* 结果栏工具提示 */}
|
||||
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono'>
|
||||
<span className='font-semibold'>执行输出</span>
|
||||
<span className='font-semibold'>{t('executionOutput')}</span>
|
||||
{sqlResult && (
|
||||
<span>
|
||||
类型: {sqlResult.type.toUpperCase()} | 耗时:{' '}
|
||||
{t('type')}: {sqlResult.type.toUpperCase()} | {t('duration')}:{' '}
|
||||
{sqlResult.execution_time_ms} ms
|
||||
</span>
|
||||
)}
|
||||
@@ -260,14 +262,14 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
<div className='flex flex-col items-center justify-center h-full py-10 space-y-2'>
|
||||
<RefreshCw className='size-5 text-primary animate-spin' />
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
正在在数据库执行查询,请稍候...
|
||||
{t('executingQuery')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlError && (
|
||||
<div className='bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]'>
|
||||
<p className='font-semibold mb-1'>SQL 执行报错 (Error):</p>
|
||||
<p className='font-semibold mb-1'>{t('execErrorTitle')}</p>
|
||||
<pre className='whitespace-pre-wrap'>{sqlError}</pre>
|
||||
</div>
|
||||
)}
|
||||
@@ -319,7 +321,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
colSpan={sqlResult.columns.length}
|
||||
className='text-center py-10 text-muted-foreground'
|
||||
>
|
||||
查询结果为空
|
||||
{t('queryResultEmpty')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -331,11 +333,13 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
{sqlResult.type === 'exec' && (
|
||||
<div className='bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto'>
|
||||
<p className='text-foreground text-sm font-semibold'>
|
||||
SQL 执行成功
|
||||
{t('sqlExecSuccessTitle')}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
受影响行数: {sqlResult.affected_rows} 行,耗时{' '}
|
||||
{sqlResult.execution_time_ms} 毫秒。
|
||||
{t('affectedRows', {
|
||||
rows: sqlResult.affected_rows,
|
||||
ms: sqlResult.execution_time_ms,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -346,7 +350,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
<div className='flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60'>
|
||||
<Terminal className='size-8 mb-2' />
|
||||
<span className='text-xs'>
|
||||
编辑器就绪,请在上方编写 SQL 并运行查看结果
|
||||
{t('editorReady')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import { Layers, RefreshCw } from 'lucide-react';
|
||||
|
||||
@@ -67,6 +68,7 @@ export function TableBrowser({
|
||||
loadingTables,
|
||||
refreshTrigger,
|
||||
}: TableBrowserProps) {
|
||||
const t = useTranslations('admin.database.tableBrowser');
|
||||
const [selectedTable, setSelectedTable] = useState<string>('');
|
||||
const [tableData, setTableData] = useState<TableDataResponse | null>(null);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
@@ -86,14 +88,14 @@ export function TableBrowser({
|
||||
});
|
||||
setTableData(data);
|
||||
} catch (err) {
|
||||
toast.error(`获取数据表 ${tableName} 数据失败`, {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('fetchTableFailed', { table: tableName }), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
// 默认选择第一张表
|
||||
@@ -125,9 +127,11 @@ export function TableBrowser({
|
||||
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
|
||||
<CardHeader className='pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4'>
|
||||
<div className='space-y-0.5'>
|
||||
<CardTitle className='text-sm font-semibold'>数据表浏览器</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('tableBrowser')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[11px]'>
|
||||
浏览数据库中的物理数据表详情及内容
|
||||
{t('tableBrowserDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +141,7 @@ export function TableBrowser({
|
||||
) : (
|
||||
<Select value={selectedTable} onValueChange={handleTableChange}>
|
||||
<SelectTrigger className='h-8 w-[200px] text-xs bg-background border-border/40'>
|
||||
<SelectValue placeholder='选择数据表' />
|
||||
<SelectValue placeholder={t('selectTable')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className='max-h-[300px]'>
|
||||
{tables.map((t) => (
|
||||
@@ -202,7 +206,7 @@ export function TableBrowser({
|
||||
colSpan={tableData.columns.length}
|
||||
className='text-center py-10 text-muted-foreground'
|
||||
>
|
||||
表中无记录数据
|
||||
{t('noRecordsInTable')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -213,11 +217,11 @@ export function TableBrowser({
|
||||
{/* 分页控制 */}
|
||||
<div className='flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed'>
|
||||
<div>
|
||||
总行数:{' '}
|
||||
{t('totalRows')}:{' '}
|
||||
<span className='font-mono text-foreground font-semibold'>
|
||||
{formatNumber(tableData.total)}
|
||||
</span>{' '}
|
||||
条记录
|
||||
{t('records')}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
@@ -227,11 +231,13 @@ export function TableBrowser({
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || loadingData}
|
||||
>
|
||||
上一页
|
||||
{t('prevPage')}
|
||||
</Button>
|
||||
<span className='text-xs px-2 font-mono'>
|
||||
第 {page} /{' '}
|
||||
{Math.max(1, Math.ceil(tableData.total / pageSize))} 页
|
||||
{t('pageIndicator', {
|
||||
current: page,
|
||||
total: Math.max(1, Math.ceil(tableData.total / pageSize)),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -242,7 +248,7 @@ export function TableBrowser({
|
||||
page >= Math.ceil(tableData.total / pageSize) || loadingData
|
||||
}
|
||||
>
|
||||
下一页
|
||||
{t('nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,7 +256,7 @@ export function TableBrowser({
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
|
||||
<Layers className='size-8 opacity-45 mb-2' />
|
||||
<span className='text-xs'>未选择数据表或表结构无法加载</span>
|
||||
<span className='text-xs'>{t('noTableSelected')}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Activity,
|
||||
@@ -57,6 +58,7 @@ const formatNumber = (num: number | string) => {
|
||||
};
|
||||
|
||||
export function DatabasePageClient() {
|
||||
const t = useTranslations('admin.database');
|
||||
// 核心状态
|
||||
const [overview, setOverview] = useState<DBOverview | null>(null);
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
@@ -75,13 +77,13 @@ export function DatabasePageClient() {
|
||||
const data = await services.dbManage.getOverview();
|
||||
setOverview(data);
|
||||
} catch (err) {
|
||||
toast.error('获取数据库概览失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('fetchOverviewFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setLoadingOverview(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
// 2. 获取表列表
|
||||
const fetchTables = useCallback(async () => {
|
||||
@@ -90,13 +92,13 @@ export function DatabasePageClient() {
|
||||
const data = await services.dbManage.listTables();
|
||||
setTables(data);
|
||||
} catch (err) {
|
||||
toast.error('获取数据库数据表列表失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('fetchTablesFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
// 3. 协调刷新
|
||||
const handleRefreshAll = () => {
|
||||
@@ -118,12 +120,12 @@ export function DatabasePageClient() {
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('数据库导出成功', {
|
||||
description: `已下载归档文件: ${filename}`,
|
||||
toast.success(t('exportSuccess'), {
|
||||
description: t('exportSuccessDesc', { filename }),
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error('数据库导出失败', {
|
||||
description: err instanceof Error ? err.message : '导出异常',
|
||||
toast.error(t('exportFailed'), {
|
||||
description: err instanceof Error ? err.message : t('exportException'),
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
@@ -170,7 +172,9 @@ export function DatabasePageClient() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<Database className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>数据管理</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -183,7 +187,7 @@ export function DatabasePageClient() {
|
||||
<RefreshCw
|
||||
className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新数据
|
||||
{t('refreshData')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +200,7 @@ export function DatabasePageClient() {
|
||||
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardDescription className='text-[10px] font-medium'>
|
||||
数据库类型
|
||||
{t('dbType')}
|
||||
</CardDescription>
|
||||
<Server className='size-3.5 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -211,7 +215,7 @@ export function DatabasePageClient() {
|
||||
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardDescription className='text-[10px] font-medium'>
|
||||
版本信息
|
||||
{t('versionInfo')}
|
||||
</CardDescription>
|
||||
<Cpu className='size-3.5 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -229,7 +233,7 @@ export function DatabasePageClient() {
|
||||
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardDescription className='text-[10px] font-medium'>
|
||||
名称/路径
|
||||
{t('nameOrPath')}
|
||||
</CardDescription>
|
||||
<Database className='size-3.5 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -249,7 +253,7 @@ export function DatabasePageClient() {
|
||||
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardDescription className='text-[10px] font-medium'>
|
||||
数据库大小
|
||||
{t('dbSize')}
|
||||
</CardDescription>
|
||||
<HardDrive className='size-3.5 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -262,7 +266,7 @@ export function DatabasePageClient() {
|
||||
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardDescription className='text-[10px] font-medium'>
|
||||
物理数据表
|
||||
{t('physicalTables')}
|
||||
</CardDescription>
|
||||
<Layers className='size-3.5 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -277,7 +281,7 @@ export function DatabasePageClient() {
|
||||
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardDescription className='text-[10px] font-medium'>
|
||||
活跃连接数
|
||||
{t('activeConnections')}
|
||||
</CardDescription>
|
||||
<Activity className='size-3.5 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -303,9 +307,11 @@ export function DatabasePageClient() {
|
||||
{/* 4. 底部功能卡片区 */}
|
||||
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
|
||||
<CardHeader className='pb-3 border-b border-dashed'>
|
||||
<CardTitle className='text-sm font-semibold'>功能区</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('featureArea')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[11px]'>
|
||||
数据库导出及自定义高级 SQL 执行终端
|
||||
{t('featureAreaDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-4'>
|
||||
@@ -315,11 +321,10 @@ export function DatabasePageClient() {
|
||||
<div className='space-y-1 pr-4'>
|
||||
<p className='text-xs font-semibold flex items-center gap-1.5'>
|
||||
<Download className='size-4 text-primary' />
|
||||
数据库备份导出
|
||||
{t('dbBackupExport')}
|
||||
</p>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL
|
||||
导出为打包的 .sql 文本文件)
|
||||
{t('dbBackupExportDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -332,7 +337,7 @@ export function DatabasePageClient() {
|
||||
<RefreshCw
|
||||
className={`size-3 ${exporting ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{exporting ? '正在准备...' : '开始导出'}
|
||||
{exporting ? t('preparing') : t('startExport')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -341,11 +346,10 @@ export function DatabasePageClient() {
|
||||
<div className='space-y-1 pr-4'>
|
||||
<p className='text-xs font-semibold flex items-center gap-1.5'>
|
||||
<Terminal className='size-4 text-primary' />
|
||||
SQL 查询控台
|
||||
{t('sqlQueryConsole')}
|
||||
</p>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
打开在线 SQL 控制终端,执行原生的 SQL
|
||||
进行数据筛选、更新、调试或性能优化
|
||||
{t('sqlQueryConsoleDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -356,7 +360,7 @@ export function DatabasePageClient() {
|
||||
}}
|
||||
>
|
||||
<FileText className='size-3.5' />
|
||||
进入控台
|
||||
{t('enterConsole')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Code } from 'lucide-react';
|
||||
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import { TableTab } from './tabs/table-tab';
|
||||
import { ControlsTab } from './tabs/controls-tab';
|
||||
|
||||
export default function HeaderDemoPage() {
|
||||
const t = useTranslations('admin.demo');
|
||||
const [activeTab, setActiveTab] = React.useState('dashboard');
|
||||
|
||||
return (
|
||||
@@ -30,7 +32,7 @@ export default function HeaderDemoPage() {
|
||||
<Code className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
开发规范示例
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,28 +42,28 @@ export default function HeaderDemoPage() {
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Code className='size-4 text-primary' />
|
||||
<CardTitle className='text-base font-semibold'>规范说明</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
{t('specTitle')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
|
||||
</CardDescription>
|
||||
<CardDescription>{t('specDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<h3 className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
|
||||
核心规范细节与编写原则:
|
||||
{t('coreSpecTitle')}
|
||||
</h3>
|
||||
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
|
||||
<li>
|
||||
<strong>结构专注</strong>
|
||||
:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。
|
||||
<strong>{t('rules.structureFocus.title')}</strong>
|
||||
{t('rules.structureFocus.description')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>容器与上边距对齐</strong>:使用{' '}
|
||||
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
flex items-center gap-2
|
||||
</code>{' '}
|
||||
作为标题行基础容器(不含下边框),外层统一使用{' '}
|
||||
{t('rules.containerAlignment.description')}{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
py-6 px-1
|
||||
</code>{' '}
|
||||
@@ -69,53 +71,53 @@ export default function HeaderDemoPage() {
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
py-6
|
||||
</code>
|
||||
) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。
|
||||
) {t('rules.containerAlignment.description2')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>图标呈现</strong>:直接将 Lucide
|
||||
图标组件嵌套于标题容器中,应用{' '}
|
||||
<strong>{t('rules.iconPresentation.title')}</strong>:直接将
|
||||
Lucide 图标组件嵌套于标题容器中,应用{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
size-5 text-primary
|
||||
</code>{' '}
|
||||
样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。
|
||||
{t('rules.iconPresentation.description')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>文本样式标准</strong>:文本使用且仅使用{' '}
|
||||
<strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
{'h1 className="text-2xl font-semibold tracking-tight"'}
|
||||
</code>
|
||||
,禁用字重加粗(如{' '}
|
||||
{t('rules.textStyle.description')}{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
font-bold
|
||||
</code>
|
||||
)或任何渐变色,保证全站字体一致性。
|
||||
{t('rules.textStyle.description2')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab
|
||||
页切换的页面,**禁止**将所有 Tab
|
||||
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
|
||||
Tab 页切换的页面,**禁止**将所有 Tab
|
||||
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
|
||||
Content 必须单独拆分为独立组件文件(如{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
tabs/events-tab.tsx
|
||||
</code>{' '}
|
||||
或就近放在{' '}
|
||||
{t('rules.tabsModular.description')}{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
components/
|
||||
</code>{' '}
|
||||
下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。
|
||||
{t('rules.tabsModular.description2')}
|
||||
</li>
|
||||
<li>
|
||||
<strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs
|
||||
切换,当一个页面代码行数超过 600
|
||||
行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如{' '}
|
||||
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
|
||||
Tabs 切换,当一个页面代码行数超过 600
|
||||
{t('rules.complexityDriven.description')}{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
app/(main)/admin/database/components/
|
||||
</code>
|
||||
),只有真正跨页面复用的组件才进入全局{' '}
|
||||
{t('rules.complexityDriven.description2')}{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
components/common/
|
||||
</code>
|
||||
。典型标杆案例参考“数据管理” (
|
||||
{t('rules.complexityDriven.description3')}{' '}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
/admin/database
|
||||
</code>
|
||||
@@ -145,19 +147,19 @@ export default function HeaderDemoPage() {
|
||||
value='dashboard'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
仪表盘与指标卡片
|
||||
{t('tabs.dashboard')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='table'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
数据表格规范
|
||||
{t('tabs.table')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='controls'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
输入控件与表单
|
||||
{t('tabs.controls')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Code } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
@@ -25,6 +26,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function ControlsTab() {
|
||||
const t = useTranslations('admin.demo.controls');
|
||||
const [formData, setFormData] = React.useState({
|
||||
name: '',
|
||||
category: 'database',
|
||||
@@ -38,11 +40,9 @@ export function ControlsTab() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
表单与输入框交互演示
|
||||
{t('formDemoTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
包含按钮变体、下拉选择、多选框及基础输入框的标准样式
|
||||
</CardDescription>
|
||||
<CardDescription>{t('formDemoDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{/* 输入框 */}
|
||||
@@ -51,7 +51,7 @@ export function ControlsTab() {
|
||||
htmlFor='task-name'
|
||||
className='text-xs font-medium text-foreground'
|
||||
>
|
||||
任务名称
|
||||
{t('taskName')}
|
||||
</Label>
|
||||
<Input
|
||||
id='task-name'
|
||||
@@ -59,18 +59,18 @@ export function ControlsTab() {
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder='输入要创建的任务描述...'
|
||||
placeholder={t('taskNamePlaceholder')}
|
||||
className='h-8 text-xs shadow-none bg-background focus-visible:ring-1'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
任务名称将用于后台执行记录的可读性显示
|
||||
{t('taskNameHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 下拉框 */}
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-medium text-foreground'>
|
||||
任务分类
|
||||
{t('taskCategory')}
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
@@ -82,9 +82,11 @@ export function ControlsTab() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='database'>数据库运维</SelectItem>
|
||||
<SelectItem value='sync'>数据批量同步</SelectItem>
|
||||
<SelectItem value='cleanup'>过期磁盘清理</SelectItem>
|
||||
<SelectItem value='database'>
|
||||
{t('categoryDatabase')}
|
||||
</SelectItem>
|
||||
<SelectItem value='sync'>{t('categorySync')}</SelectItem>
|
||||
<SelectItem value='cleanup'>{t('categoryCleanup')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -107,10 +109,10 @@ export function ControlsTab() {
|
||||
htmlFor='allow-notify'
|
||||
className='text-xs font-medium text-foreground cursor-pointer select-none leading-none'
|
||||
>
|
||||
允许触发异步通知推送
|
||||
{t('allowNotify')}
|
||||
</label>
|
||||
<p className='text-[10px] text-muted-foreground leading-normal'>
|
||||
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。
|
||||
{t('allowNotifyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,32 +120,32 @@ export function ControlsTab() {
|
||||
{/* 按钮变体 */}
|
||||
<div className='pt-2'>
|
||||
<div className='text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2'>
|
||||
按钮样式对齐:
|
||||
{t('buttonStyles')}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button size='sm' className='h-8 text-xs shadow-none'>
|
||||
主要操作 (Primary)
|
||||
{t('primaryButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-8 text-xs shadow-none'
|
||||
>
|
||||
次要操作 (Secondary)
|
||||
{t('secondaryButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 border-dashed text-xs shadow-none'
|
||||
>
|
||||
辅助线框 (Outline)
|
||||
{t('outlineButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 text-xs hover:bg-muted'
|
||||
>
|
||||
幽灵按钮 (Ghost)
|
||||
{t('ghostButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,13 +157,13 @@ export function ControlsTab() {
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
|
||||
<Code className='size-4 text-primary' />
|
||||
表单设计规范
|
||||
{t('formDesignSpec')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4 text-xs text-muted-foreground'>
|
||||
<div className='space-y-2 leading-relaxed'>
|
||||
<p className='font-semibold text-foreground text-[11px]'>
|
||||
1. 统一控件高度与投影:
|
||||
{t('spec.unifiedHeight')}
|
||||
</p>
|
||||
<p>
|
||||
输入框 (
|
||||
@@ -188,7 +190,7 @@ export function ControlsTab() {
|
||||
</p>
|
||||
|
||||
<p className='font-semibold text-foreground text-[11px] pt-1'>
|
||||
2. Checkbox 与文本的对齐:
|
||||
{t('spec.checkboxAlignment')}
|
||||
</p>
|
||||
<p>
|
||||
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
|
||||
@@ -203,7 +205,7 @@ export function ControlsTab() {
|
||||
</p>
|
||||
|
||||
<p className='font-semibold text-foreground text-[11px] pt-1'>
|
||||
3. 按钮使用策略:
|
||||
{t('spec.buttonStrategy')}
|
||||
</p>
|
||||
<p>
|
||||
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Code, Cpu, Database, HardDrive } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
export function DashboardTab() {
|
||||
const t = useTranslations('admin.demo.dashboard');
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
|
||||
@@ -16,7 +18,7 @@ export function DashboardTab() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
容器 CPU 使用率
|
||||
{t('cpuUsage')}
|
||||
</span>
|
||||
<Cpu className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -25,7 +27,7 @@ export function DashboardTab() {
|
||||
<Progress value={42.8} className='h-1.5' />
|
||||
<p className='text-[10px] text-muted-foreground flex items-center gap-1'>
|
||||
<span className='size-1.5 rounded-full bg-emerald-500 inline-block animate-pulse' />
|
||||
容器运行正常,负载平稳
|
||||
{t('cpuStatus')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -34,7 +36,7 @@ export function DashboardTab() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
存储已用容量
|
||||
{t('storageUsage')}
|
||||
</span>
|
||||
<HardDrive className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -45,7 +47,7 @@ export function DashboardTab() {
|
||||
</div>
|
||||
<Progress value={72.4} className='h-1.5' />
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
数据已同步远端 S3 存储桶,支持自动过期
|
||||
{t('storageStatus')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -54,7 +56,7 @@ export function DashboardTab() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
物理数据库
|
||||
{t('database')}
|
||||
</span>
|
||||
<Database className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -67,16 +69,16 @@ export function DashboardTab() {
|
||||
</div>
|
||||
<div className='flex items-center gap-3 text-xs text-muted-foreground'>
|
||||
<div>
|
||||
活跃连接:{' '}
|
||||
{t('activeConnections')}{' '}
|
||||
<span className='font-semibold text-foreground'>12</span>
|
||||
</div>
|
||||
<div>
|
||||
总表数量:{' '}
|
||||
{t('totalTables')}{' '}
|
||||
<span className='font-semibold text-foreground'>34</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
链接池自动维护中,最大连接限制为 100
|
||||
{t('dbStatus')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -87,45 +89,45 @@ export function DashboardTab() {
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
|
||||
<Code className='size-4 text-primary' />
|
||||
指标卡片 & 仪表盘设计规范
|
||||
{t('dashboardDesignSpec')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
|
||||
<li>
|
||||
指标卡片统一采用{' '}
|
||||
{t('spec.dashedBorder')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
border-dashed shadow-none
|
||||
</code>{' '}
|
||||
作为边框和投影。
|
||||
{t('spec.dashedBorder2')}
|
||||
</li>
|
||||
<li>
|
||||
头部使用{' '}
|
||||
{t('spec.headerLayout')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
flex flex-row items-center justify-between pb-2
|
||||
</code>{' '}
|
||||
布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。
|
||||
{t('spec.headerLayout2')}
|
||||
</li>
|
||||
<li>
|
||||
数值强调使用且仅使用{' '}
|
||||
{t('spec.valueStyle')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
text-2xl font-semibold tracking-tight
|
||||
</code>{' '}
|
||||
展示。
|
||||
{t('spec.valueStyle2')}
|
||||
</li>
|
||||
<li>
|
||||
进度条使用{' '}
|
||||
{t('spec.progressBar')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
h-1.5
|
||||
</code>{' '}
|
||||
细条高度,不喧宾夺主。
|
||||
{t('spec.progressBar2')}
|
||||
</li>
|
||||
<li>
|
||||
底部的次要说明信息必须统一使用{' '}
|
||||
{t('spec.secondaryText')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
text-[10px] text-muted-foreground
|
||||
</code>{' '}
|
||||
等辅助类。
|
||||
{t('spec.secondaryText2')}
|
||||
</li>
|
||||
</ul>
|
||||
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -19,27 +20,28 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export function TableTab() {
|
||||
const t = useTranslations('admin.demo.table');
|
||||
const mockTableData = [
|
||||
{
|
||||
id: 'TX-1002',
|
||||
name: '用户信息批量同步导出',
|
||||
type: '数据同步',
|
||||
name: t('mockData.syncExport'),
|
||||
type: t('mockType.dataSync'),
|
||||
status: 'success',
|
||||
duration: '1.2s',
|
||||
time: '2026-06-15 14:22',
|
||||
},
|
||||
{
|
||||
id: 'TX-1003',
|
||||
name: '系统数据库每周自动备份归档',
|
||||
type: '数据库运维',
|
||||
name: t('mockData.dbBackup'),
|
||||
type: t('mockType.dbOps'),
|
||||
status: 'running',
|
||||
duration: '在执行中',
|
||||
duration: t('mockDuration.running'),
|
||||
time: '2026-06-15 15:00',
|
||||
},
|
||||
{
|
||||
id: 'TX-1004',
|
||||
name: '远端S3过期缓存对象自动清理任务',
|
||||
type: '磁盘清理',
|
||||
name: t('mockData.s3Cleanup'),
|
||||
type: t('mockType.diskCleanup'),
|
||||
status: 'failed',
|
||||
duration: '0.4s',
|
||||
time: '2026-06-15 10:05',
|
||||
@@ -54,7 +56,7 @@ export function TableTab() {
|
||||
<div className='relative w-64'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3 text-muted-foreground' />
|
||||
<Input
|
||||
placeholder='输入任务名称搜索...'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className='h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background'
|
||||
/>
|
||||
</div>
|
||||
@@ -64,12 +66,12 @@ export function TableTab() {
|
||||
className='h-8 border-dashed text-xs shadow-none'
|
||||
>
|
||||
<ListFilter className='size-3 mr-1' />
|
||||
过滤
|
||||
{t('filter')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size='sm' className='h-8 text-xs shadow-none'>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新建任务
|
||||
{t('newTask')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -79,25 +81,25 @@ export function TableTab() {
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='w-[100px] text-xs font-semibold'>
|
||||
任务编号
|
||||
{t('columns.taskId')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
执行任务名称
|
||||
{t('columns.taskName')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px] text-xs font-semibold'>
|
||||
任务类型
|
||||
{t('columns.taskType')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] text-xs font-semibold'>
|
||||
状态
|
||||
{t('columns.status')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] text-xs font-semibold'>
|
||||
执行耗时
|
||||
{t('columns.duration')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[150px] text-xs font-semibold'>
|
||||
触发时间
|
||||
{t('columns.triggerTime')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-xs font-semibold text-right'>
|
||||
操作
|
||||
{t('columns.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -123,7 +125,7 @@ export function TableTab() {
|
||||
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2 font-medium'
|
||||
>
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
成功
|
||||
{t('status.success')}
|
||||
</Badge>
|
||||
)}
|
||||
{row.status === 'running' && (
|
||||
@@ -132,7 +134,7 @@ export function TableTab() {
|
||||
className='text-[10px] bg-blue-500/10 border-blue-500/20 text-blue-600 rounded-full py-0 px-2 font-medium'
|
||||
>
|
||||
<span className='size-1 bg-blue-500 rounded-full mr-1.5 shrink-0 animate-pulse' />
|
||||
运行中
|
||||
{t('status.running')}
|
||||
</Badge>
|
||||
)}
|
||||
{row.status === 'failed' && (
|
||||
@@ -141,7 +143,7 @@ export function TableTab() {
|
||||
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2 font-medium'
|
||||
>
|
||||
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
|
||||
失败
|
||||
{t('status.failed')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -180,13 +182,13 @@ export function TableTab() {
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
|
||||
<Code className='size-4 text-primary' />
|
||||
表格设计规范与代码模板
|
||||
{t('tableDesignSpec')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
|
||||
<li>
|
||||
表格容器必须统一使用带有虚线边框的容器,即类名:
|
||||
{t('spec.container')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
border border-dashed shadow-none rounded-lg overflow-hidden
|
||||
bg-background
|
||||
@@ -194,40 +196,40 @@ export function TableTab() {
|
||||
。
|
||||
</li>
|
||||
<li>
|
||||
表头背景设为{' '}
|
||||
{t('spec.headerBg')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
bg-muted/40
|
||||
</code>
|
||||
。表头表项字号统一为小字粗体{' '}
|
||||
{t('spec.headerStyle')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
text-xs font-semibold
|
||||
</code>
|
||||
。
|
||||
</li>
|
||||
<li>
|
||||
单元格行与列之间使用虚线分割(
|
||||
{t('spec.cellDivider')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
border-dashed
|
||||
</code>
|
||||
)。表格行悬浮交互采用温和过渡色{' '}
|
||||
{t('spec.rowHover')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
hover:bg-muted/10 transition-colors
|
||||
</code>
|
||||
。
|
||||
</li>
|
||||
<li>
|
||||
编码、主键或唯一 ID 类字段一律采用等宽字体呈现:
|
||||
{t('spec.monoFont')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
font-mono text-xs
|
||||
</code>
|
||||
。
|
||||
</li>
|
||||
<li>
|
||||
状态标签(Badge)尽量圆角化(
|
||||
{t('spec.badgeStyle')}
|
||||
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
|
||||
rounded-full py-0 px-2
|
||||
</code>
|
||||
),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。
|
||||
{t('spec.badgeStyle2')}
|
||||
</li>
|
||||
</ul>
|
||||
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
@@ -81,6 +82,7 @@ function formatDate(dateStr: string) {
|
||||
}
|
||||
|
||||
export function FileList() {
|
||||
const t = useTranslations('admin.files');
|
||||
const queryClient = useQueryClient();
|
||||
const [keyword, setKeyword] = React.useState('');
|
||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
|
||||
@@ -157,10 +159,10 @@ export function FileList() {
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
|
||||
toast.success('文件已删除');
|
||||
toast.success(t('list.deleteSuccess'));
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || '删除失败'),
|
||||
onError: (err: Error) => toast.error(err.message || t('list.deleteFailed')),
|
||||
});
|
||||
|
||||
// 批量 ZIP 下载
|
||||
@@ -173,9 +175,9 @@ export function FileList() {
|
||||
a.download = 'batch_download.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('批量下载已开始');
|
||||
toast.success(t('list.batchDownloadSuccess'));
|
||||
},
|
||||
onError: () => toast.error('批量下载失败'),
|
||||
onError: () => toast.error(t('list.batchDownloadFailed')),
|
||||
});
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
@@ -209,7 +211,7 @@ export function FileList() {
|
||||
<div className='relative flex-1 max-w-sm'>
|
||||
<Search className='absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
placeholder='搜索文件名...'
|
||||
placeholder={t('list.searchPlaceholder')}
|
||||
className='pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0'
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
@@ -232,7 +234,9 @@ export function FileList() {
|
||||
selectedIds.size === files.length ? clearSelection : selectAll
|
||||
}
|
||||
>
|
||||
{selectedIds.size === files.length ? '取消全选' : '全选本页'}
|
||||
{selectedIds.size === files.length
|
||||
? t('list.deselectAll')
|
||||
: t('list.selectAll')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -247,7 +251,7 @@ export function FileList() {
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Badge variant='secondary' className='text-xs px-2.5'>
|
||||
已选 {selectedIds.size} 个
|
||||
{t('list.selectedCount', { count: selectedIds.size })}
|
||||
</Badge>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -261,7 +265,7 @@ export function FileList() {
|
||||
) : (
|
||||
<FileArchive className='size-3.5 mr-1' />
|
||||
)}
|
||||
打包下载
|
||||
{t('list.batchDownload')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -278,7 +282,7 @@ export function FileList() {
|
||||
|
||||
{total > 0 && (
|
||||
<span className='text-xs text-muted-foreground shrink-0'>
|
||||
共 {total} 个文件
|
||||
{t('list.totalFiles', { count: total })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,7 +296,7 @@ export function FileList() {
|
||||
<div className='flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground'>
|
||||
<Upload className='size-12 text-muted-foreground/30' />
|
||||
<p className='text-sm'>
|
||||
{debouncedKeyword ? '没有匹配的文件' : '您还没有上传任何文件'}
|
||||
{debouncedKeyword ? t('list.noMatch') : t('list.noFiles')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -309,18 +313,28 @@ export function FileList() {
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] py-3'>预览</TableHead>
|
||||
<TableHead className='w-[180px] py-3'>ID</TableHead>
|
||||
<TableHead className='py-3'>文件名</TableHead>
|
||||
<TableHead className='max-w-[200px] truncate py-3'>
|
||||
路径
|
||||
<TableHead className='w-[80px] py-3'>
|
||||
{t('list.colPreview')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[180px] py-3'>ID</TableHead>
|
||||
<TableHead className='py-3'>{t('list.colName')}</TableHead>
|
||||
<TableHead className='max-w-[200px] truncate py-3'>
|
||||
{t('list.colPath')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>
|
||||
{t('list.colType')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[125px] py-3'>
|
||||
{t('list.colMime')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>
|
||||
{t('list.colSize')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[150px] py-3'>
|
||||
{t('list.colUploadedAt')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>业务类别</TableHead>
|
||||
<TableHead className='w-[125px] py-3'>MIME类型</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>大小</TableHead>
|
||||
<TableHead className='w-[150px] py-3'>上传时间</TableHead>
|
||||
<TableHead className='w-[120px] text-right pr-4 py-3'>
|
||||
操作
|
||||
{t('list.colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -396,7 +410,7 @@ export function FileList() {
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
title='查看详情'
|
||||
title={t('list.viewDetail')}
|
||||
onClick={() => setDetailTarget(file)}
|
||||
>
|
||||
<Eye className='size-3.5' />
|
||||
@@ -405,7 +419,7 @@ export function FileList() {
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-7 w-7 rounded-md hover:bg-muted'
|
||||
title='下载'
|
||||
title={t('list.download')}
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className='size-3.5' />
|
||||
@@ -414,7 +428,7 @@ export function FileList() {
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
||||
title='删除'
|
||||
title={t('list.delete')}
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<Trash2 className='size-3.5' />
|
||||
@@ -439,7 +453,7 @@ export function FileList() {
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
{t('list.prevPage')}
|
||||
</Button>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{page} / {totalPages}
|
||||
@@ -451,7 +465,7 @@ export function FileList() {
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
{t('list.nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -463,9 +477,9 @@ export function FileList() {
|
||||
>
|
||||
<SheetContent className='w-full sm:max-w-md overflow-y-auto p-6'>
|
||||
<SheetHeader className='border-b pb-4 px-0'>
|
||||
<SheetTitle>文件详情</SheetTitle>
|
||||
<SheetTitle>{t('list.detailTitle')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
查看文件的完整元数据与配置属性。
|
||||
{t('list.detailDesc')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -485,7 +499,9 @@ export function FileList() {
|
||||
<div className='flex flex-col items-center gap-3'>
|
||||
{getFileIcon(detailTarget.mime_type, 'size-14')}
|
||||
<span className='text-xs font-medium text-muted-foreground uppercase'>
|
||||
{detailTarget.extension} 文件
|
||||
{t('list.extensionFile', {
|
||||
ext: detailTarget.extension,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -495,7 +511,7 @@ export function FileList() {
|
||||
<div className='space-y-4 text-xs'>
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
文件 ID
|
||||
{t('list.detailId')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
|
||||
{detailTarget.id}
|
||||
@@ -504,7 +520,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
文件名
|
||||
{t('list.detailName')}
|
||||
</span>
|
||||
<span className='col-span-2 break-all font-medium text-foreground/90'>
|
||||
{detailTarget.file_name}
|
||||
@@ -513,7 +529,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
存储路径
|
||||
{t('list.detailPath')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
|
||||
{detailTarget.file_path}
|
||||
@@ -522,17 +538,18 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
大小
|
||||
{t('list.detailSize')}
|
||||
</span>
|
||||
<span className='col-span-2 text-foreground/90'>
|
||||
{formatFileSize(detailTarget.file_size)} (
|
||||
{detailTarget.file_size.toLocaleString()} 字节)
|
||||
{detailTarget.file_size.toLocaleString()} {t('list.bytes')}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
MIME 类型
|
||||
{t('list.detailMime')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{detailTarget.mime_type}
|
||||
@@ -541,7 +558,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
文件后缀
|
||||
{t('list.detailExtension')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{detailTarget.extension}
|
||||
@@ -559,7 +576,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
业务类型
|
||||
{t('list.detailBizType')}
|
||||
</span>
|
||||
<span className='col-span-2'>
|
||||
<Badge
|
||||
@@ -573,7 +590,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
存储驱动
|
||||
{t('list.detailDriver')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{storageDriverQuery.data ?? 'local'}
|
||||
@@ -582,7 +599,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
上传者 ID
|
||||
{t('list.detailUploaderId')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{detailTarget.user_id}
|
||||
@@ -591,7 +608,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
上传时间
|
||||
{t('list.detailUploadedAt')}
|
||||
</span>
|
||||
<span className='col-span-2 text-foreground/90'>
|
||||
{formatDate(detailTarget.created_at)}
|
||||
@@ -602,7 +619,7 @@ export function FileList() {
|
||||
Object.keys(detailTarget.metadata).length > 0 && (
|
||||
<div className='space-y-1.5 pt-2'>
|
||||
<span className='text-muted-foreground font-medium block'>
|
||||
额外元数据
|
||||
{t('list.detailMetadata')}
|
||||
</span>
|
||||
<pre className='p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto'>
|
||||
{JSON.stringify(detailTarget.metadata, null, 2)}
|
||||
@@ -622,18 +639,16 @@ export function FileList() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('list.deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除文件{' '}
|
||||
<span className='font-semibold text-foreground'>
|
||||
「{deleteTarget?.file_name}」
|
||||
</span>{' '}
|
||||
吗?此操作不可撤销。
|
||||
{t('list.deleteConfirm', {
|
||||
name: deleteTarget?.file_name ?? '',
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{t('list.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
@@ -645,7 +660,7 @@ export function FileList() {
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
)}
|
||||
确认删除
|
||||
{t('list.confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Database,
|
||||
@@ -49,42 +50,47 @@ const categoryMap: Record<string, string> = {
|
||||
其他: 'others',
|
||||
};
|
||||
|
||||
const statsChartConfig = {
|
||||
images: {
|
||||
label: '图片',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
videos: {
|
||||
label: '视频',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
audio: {
|
||||
label: '音频',
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
documents: {
|
||||
label: '文档',
|
||||
color: 'hsl(var(--chart-4))',
|
||||
},
|
||||
archives: {
|
||||
label: '压缩包',
|
||||
color: 'hsl(var(--chart-5))',
|
||||
},
|
||||
others: {
|
||||
label: '其他',
|
||||
color: 'hsl(var(--chart-6))',
|
||||
},
|
||||
count: {
|
||||
label: '新增文件数',
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
size: {
|
||||
label: '新增大小',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
function useStatsChartConfig() {
|
||||
const t = useTranslations('admin.files');
|
||||
return {
|
||||
images: {
|
||||
label: t('stats.catImages'),
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
videos: {
|
||||
label: t('stats.catVideos'),
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
audio: {
|
||||
label: t('stats.catAudio'),
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
documents: {
|
||||
label: t('stats.catDocuments'),
|
||||
color: 'hsl(var(--chart-4))',
|
||||
},
|
||||
archives: {
|
||||
label: t('stats.catArchives'),
|
||||
color: 'hsl(var(--chart-5))',
|
||||
},
|
||||
others: {
|
||||
label: t('stats.catOthers'),
|
||||
color: 'hsl(var(--chart-6))',
|
||||
},
|
||||
count: {
|
||||
label: t('stats.chartLabelCount'),
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
size: {
|
||||
label: t('stats.chartLabelSize'),
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
}
|
||||
|
||||
export function FileStats() {
|
||||
const t = useTranslations('admin.files');
|
||||
const statsChartConfig = useStatsChartConfig();
|
||||
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
|
||||
'count',
|
||||
);
|
||||
@@ -137,11 +143,16 @@ export function FileStats() {
|
||||
|
||||
const maxStats = React.useMemo(() => {
|
||||
if (!stats?.categories || stats.categories.length === 0) {
|
||||
return { maxCountName: '无', maxCount: 0, maxSizeName: '无', maxSize: 0 };
|
||||
return {
|
||||
maxCountName: t('stats.none'),
|
||||
maxCount: 0,
|
||||
maxSizeName: t('stats.none'),
|
||||
maxSize: 0,
|
||||
};
|
||||
}
|
||||
let maxCountName = '无';
|
||||
let maxCountName = t('stats.none');
|
||||
let maxCount = 0;
|
||||
let maxSizeName = '无';
|
||||
let maxSizeName = t('stats.none');
|
||||
let maxSize = 0;
|
||||
|
||||
stats.categories.forEach((cat) => {
|
||||
@@ -156,7 +167,7 @@ export function FileStats() {
|
||||
});
|
||||
|
||||
return { maxCountName, maxCount, maxSizeName, maxSize };
|
||||
}, [stats?.categories]);
|
||||
}, [stats?.categories, t]);
|
||||
|
||||
const trendSummary = React.useMemo(() => {
|
||||
if (!stats?.trend) return { count: 0, size: 0 };
|
||||
@@ -182,9 +193,9 @@ export function FileStats() {
|
||||
<div className='flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4'>
|
||||
<Upload className='size-14 text-muted-foreground/25' />
|
||||
<div className='text-center space-y-1'>
|
||||
<p className='font-medium'>暂无上传数据</p>
|
||||
<p className='font-medium'>{t('stats.noData')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
上传文件后即可在此查看容量与文件分布分析。
|
||||
{t('stats.noDataDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +209,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
总文件数
|
||||
{t('stats.totalFiles')}
|
||||
</CardTitle>
|
||||
<Files className='size-4 text-sky-500' />
|
||||
</CardHeader>
|
||||
@@ -206,11 +217,11 @@ export function FileStats() {
|
||||
<div className='text-2xl font-bold font-mono text-sky-500'>
|
||||
{stats.total_count.toLocaleString()}{' '}
|
||||
<span className='text-xs font-normal text-muted-foreground'>
|
||||
个
|
||||
{t('stats.countUnit')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
您上传的可用文件数量汇总
|
||||
{t('stats.totalFilesDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -218,7 +229,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
占用存储容量
|
||||
{t('stats.totalSize')}
|
||||
</CardTitle>
|
||||
<Database className='size-4 text-emerald-500' />
|
||||
</CardHeader>
|
||||
@@ -227,7 +238,7 @@ export function FileStats() {
|
||||
{formatFileSize(stats.total_size)}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
文件实际在磁盘/S3中占用的总空间
|
||||
{t('stats.totalSizeDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -235,7 +246,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
近 7 天新增文件
|
||||
{t('stats.weeklyNewFiles')}
|
||||
</CardTitle>
|
||||
<TrendingUp className='size-4 text-indigo-500' />
|
||||
</CardHeader>
|
||||
@@ -243,11 +254,11 @@ export function FileStats() {
|
||||
<div className='text-2xl font-bold font-mono text-indigo-500'>
|
||||
+{trendSummary.count.toLocaleString()}{' '}
|
||||
<span className='text-xs font-normal text-muted-foreground'>
|
||||
个
|
||||
{t('stats.countUnit')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
最近 7 天上传成功的文件总数
|
||||
{t('stats.weeklyNewFilesDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -255,7 +266,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
近 7 天新增大小
|
||||
{t('stats.weeklyNewSize')}
|
||||
</CardTitle>
|
||||
<HardDrive className='size-4 text-amber-500' />
|
||||
</CardHeader>
|
||||
@@ -264,7 +275,7 @@ export function FileStats() {
|
||||
+{formatFileSize(trendSummary.size)}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
最近 7 天上传所消耗的存储带宽
|
||||
{t('stats.weeklyNewSizeDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -275,10 +286,10 @@ export function FileStats() {
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<div>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
最近 7 天新增趋势
|
||||
{t('stats.trendTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
按日统计近一周期内的上传波动
|
||||
{t('stats.trendDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30'>
|
||||
@@ -288,7 +299,7 @@ export function FileStats() {
|
||||
className='h-7 text-xs px-2.5 rounded-md'
|
||||
onClick={() => setTrendMetric('count')}
|
||||
>
|
||||
新增数量 (个)
|
||||
{t('stats.trendCount')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -296,7 +307,7 @@ export function FileStats() {
|
||||
className='h-7 text-xs px-2.5 rounded-md'
|
||||
onClick={() => setTrendMetric('size')}
|
||||
>
|
||||
新增大小 (MB)
|
||||
{t('stats.trendSize')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -361,11 +372,13 @@ export function FileStats() {
|
||||
hideLabel
|
||||
formatter={(value) => {
|
||||
const label =
|
||||
trendMetric === 'count' ? '新增文件数' : '新增大小';
|
||||
trendMetric === 'count'
|
||||
? t('stats.chartLabelCount')
|
||||
: t('stats.chartLabelSize');
|
||||
const formattedValue =
|
||||
trendMetric === 'size'
|
||||
? formatFileSize(Number(value))
|
||||
: `${value} 个`;
|
||||
: `${value} ${t('stats.countUnit')}`;
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
@@ -419,16 +432,16 @@ export function FileStats() {
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
文件格式数量分布
|
||||
{t('stats.countDistTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
各种格式类型的文件个数占比
|
||||
{t('stats.countDistDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
|
||||
{categoryCountData.length === 0 ? (
|
||||
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
|
||||
暂无分类数据
|
||||
{t('stats.noCategoryData')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-[240px] w-full'>
|
||||
@@ -475,7 +488,7 @@ export function FileStats() {
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
|
||||
{value} 个
|
||||
{value} {t('stats.countUnit')}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -498,15 +511,10 @@ export function FileStats() {
|
||||
<div className='bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400'>
|
||||
<Info className='size-4 shrink-0 mt-0.5' />
|
||||
<p className='leading-normal'>
|
||||
文件类型中数量最多的是{' '}
|
||||
<strong className='font-bold'>
|
||||
「{maxStats.maxCountName}」
|
||||
</strong>
|
||||
,累计上传了{' '}
|
||||
<strong className='font-mono font-bold'>
|
||||
{maxStats.maxCount}
|
||||
</strong>{' '}
|
||||
个文件。
|
||||
{t('stats.maxCountHint', {
|
||||
name: maxStats.maxCountName,
|
||||
count: maxStats.maxCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -516,16 +524,16 @@ export function FileStats() {
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
文件占用容量分布
|
||||
{t('stats.sizeDistTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
各种格式类型的文件大小占比
|
||||
{t('stats.sizeDistDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
|
||||
{categorySizeData.length === 0 ? (
|
||||
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
|
||||
暂无容量数据
|
||||
{t('stats.noSizeData')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-[240px] w-full'>
|
||||
@@ -595,15 +603,10 @@ export function FileStats() {
|
||||
<div className='bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400'>
|
||||
<Info className='size-4 shrink-0 mt-0.5' />
|
||||
<p className='leading-normal'>
|
||||
文件类型中占用存储最大的是{' '}
|
||||
<strong className='font-bold'>
|
||||
「{maxStats.maxSizeName}」
|
||||
</strong>
|
||||
,共消耗了{' '}
|
||||
<strong className='font-mono font-bold'>
|
||||
{formatFileSize(maxStats.maxSize)}
|
||||
</strong>{' '}
|
||||
存储容量。
|
||||
{t('stats.maxSizeHint', {
|
||||
name: maxStats.maxSizeName,
|
||||
size: formatFileSize(maxStats.maxSize),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Database, Loader2, Play, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -44,13 +45,13 @@ import type {
|
||||
const storageConfigKey = 'storage_config';
|
||||
const storageMigrationTaskType = 'storage_migration';
|
||||
|
||||
const driverLabels: Record<StorageDriver, string> = {
|
||||
local: '本地文件系统',
|
||||
s3: 'AWS S3',
|
||||
r2: 'Cloudflare R2',
|
||||
minio: 'MinIO',
|
||||
oss: '阿里云 OSS',
|
||||
webdav: 'WebDAV',
|
||||
const driverLabelKeys: Record<StorageDriver, string> = {
|
||||
local: 'driverLocal',
|
||||
s3: 'driverS3',
|
||||
r2: 'driverR2',
|
||||
minio: 'driverMinio',
|
||||
oss: 'driverOss',
|
||||
webdav: 'driverWebdav',
|
||||
};
|
||||
|
||||
const emptyObjectConfig: ObjectStorageConfig = {
|
||||
@@ -112,8 +113,8 @@ function parseMigrationPayload(payload: string): StorageConfig | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function driverLabel(driver?: StorageDriver): string {
|
||||
return driver ? driverLabels[driver] : '未知';
|
||||
function driverLabelKey(driver?: StorageDriver): string {
|
||||
return driver ? driverLabelKeys[driver] : 'driverUnknown';
|
||||
}
|
||||
|
||||
function latestMigration(
|
||||
@@ -136,6 +137,7 @@ function latestMigration(
|
||||
}
|
||||
|
||||
export function StorageConfigTab() {
|
||||
const t = useTranslations('admin.files');
|
||||
const queryClient = useQueryClient();
|
||||
const [config, setConfig] = React.useState<StorageConfig | null>(null);
|
||||
const query = useQuery({
|
||||
@@ -186,13 +188,13 @@ export function StorageConfigTab() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('存储配置已保存');
|
||||
toast.success(t('storage.saveSuccess'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '保存存储配置失败');
|
||||
toast.error(error.message || t('storage.saveFailed'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
@@ -208,13 +210,13 @@ export function StorageConfigTab() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('存储迁移任务已下发');
|
||||
toast.success(t('storage.migrateSuccess'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '下发存储迁移任务失败');
|
||||
toast.error(error.message || t('storage.migrateFailed'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
@@ -224,12 +226,13 @@ export function StorageConfigTab() {
|
||||
mutationFn: (executionID: string) =>
|
||||
services.adminTask.retryTaskExecution(executionID),
|
||||
onSuccess: () => {
|
||||
toast.success('存储迁移任务已重新下发');
|
||||
toast.success(t('storage.retrySuccess'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'),
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || t('storage.retryFailed')),
|
||||
});
|
||||
|
||||
if (query.isPending || !config) {
|
||||
@@ -269,7 +272,7 @@ export function StorageConfigTab() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
存储维护模式
|
||||
{t('storage.maintenanceMode')}
|
||||
<Badge
|
||||
variant={
|
||||
migration.state === 'failed' ? 'destructive' : 'secondary'
|
||||
@@ -279,9 +282,10 @@ export function StorageConfigTab() {
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{driverLabel(migration.source_driver)} →{' '}
|
||||
{driverLabel(migration.target_driver)}
|
||||
。迁移期间文件只允许读取,禁止上传、删除和清理。
|
||||
{t('storage.maintenanceDesc', {
|
||||
source: t(`storage.${driverLabelKey(migration.source_driver)}`),
|
||||
target: t(`storage.${driverLabelKey(migration.target_driver)}`),
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3'>
|
||||
@@ -289,7 +293,9 @@ export function StorageConfigTab() {
|
||||
value={migration.state === 'succeeded' ? 100 : undefined}
|
||||
/>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'}
|
||||
{t('storage.migrationProgress', {
|
||||
taskId: migration.task_id || t('storage.notDispatched'),
|
||||
})}
|
||||
</p>
|
||||
{migration.error && (
|
||||
<p className='text-sm text-destructive'>{migration.error}</p>
|
||||
@@ -310,7 +316,7 @@ export function StorageConfigTab() {
|
||||
) : (
|
||||
<Play data-icon='inline-start' />
|
||||
)}
|
||||
重试迁移
|
||||
{t('storage.retryMigration')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -324,10 +330,10 @@ export function StorageConfigTab() {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
文件存储
|
||||
{t('storage.fileStorage')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
|
||||
{t('storage.fileStorageDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,7 +343,7 @@ export function StorageConfigTab() {
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<Field className='flex flex-col gap-1.5 md:col-span-2'>
|
||||
<FieldLabel className='text-xs font-semibold'>
|
||||
存储类型
|
||||
{t('storage.storageType')}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={config.driver}
|
||||
@@ -351,10 +357,10 @@ export function StorageConfigTab() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(driverLabels) as StorageDriver[]).map(
|
||||
{(Object.keys(driverLabelKeys) as StorageDriver[]).map(
|
||||
(driver) => (
|
||||
<SelectItem key={driver} value={driver}>
|
||||
{driverLabels[driver]}
|
||||
{t(`storage.${driverLabelKeys[driver]}`)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
@@ -366,7 +372,7 @@ export function StorageConfigTab() {
|
||||
{config.driver === 'local' && (
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='根目录'
|
||||
label={t('storage.rootDir')}
|
||||
value={config.local.root}
|
||||
placeholder='.'
|
||||
onChange={(root) =>
|
||||
@@ -396,7 +402,7 @@ export function StorageConfigTab() {
|
||||
<>
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='服务地址'
|
||||
label={t('storage.endpoint')}
|
||||
value={config.webdav.endpoint}
|
||||
placeholder='https://dav.example.com'
|
||||
onChange={(endpoint) =>
|
||||
@@ -408,7 +414,7 @@ export function StorageConfigTab() {
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label='用户名'
|
||||
label={t('storage.username')}
|
||||
value={config.webdav.username}
|
||||
onChange={(username) =>
|
||||
setConfig({
|
||||
@@ -418,7 +424,7 @@ export function StorageConfigTab() {
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label='密码'
|
||||
label={t('storage.password')}
|
||||
type='password'
|
||||
value={config.webdav.password}
|
||||
onChange={(password) =>
|
||||
@@ -430,7 +436,7 @@ export function StorageConfigTab() {
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='基础路径'
|
||||
label={t('storage.basePath')}
|
||||
value={config.webdav.base_path}
|
||||
placeholder='openflare'
|
||||
onChange={(base_path) =>
|
||||
@@ -449,8 +455,7 @@ export function StorageConfigTab() {
|
||||
<CardFooter className='justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6'>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<span className='text-xs text-amber-500 mr-auto text-left max-w-md'>
|
||||
⚠️
|
||||
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
|
||||
{t('storage.switchWarning')}
|
||||
</span>
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
@@ -465,7 +470,7 @@ export function StorageConfigTab() {
|
||||
) : (
|
||||
<Save data-icon='inline-start' />
|
||||
)}
|
||||
保存配置
|
||||
{t('storage.saveConfig')}
|
||||
</Button>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<Button
|
||||
@@ -477,7 +482,7 @@ export function StorageConfigTab() {
|
||||
) : (
|
||||
<Play data-icon='inline-start' />
|
||||
)}
|
||||
开始迁移
|
||||
{t('storage.startMigration')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -496,6 +501,7 @@ function ObjectFields({
|
||||
value: ObjectStorageConfig;
|
||||
onChange: (patch: Partial<ObjectStorageConfig>) => void;
|
||||
}) {
|
||||
const t = useTranslations('admin.files');
|
||||
return (
|
||||
<>
|
||||
{driver === 'r2' && (
|
||||
@@ -539,13 +545,13 @@ function ObjectFields({
|
||||
onChange={(secret_access_key) => onChange({ secret_access_key })}
|
||||
/>
|
||||
<TextField
|
||||
label='对象前缀'
|
||||
label={t('storage.keyPrefix')}
|
||||
value={value.key_prefix}
|
||||
placeholder='uploads'
|
||||
onChange={(key_prefix) => onChange({ key_prefix })}
|
||||
/>
|
||||
<TextField
|
||||
label='CDN 地址'
|
||||
label={t('storage.cdnUrl')}
|
||||
value={value.cdn_url}
|
||||
placeholder='https://cdn.example.com'
|
||||
onChange={(cdn_url) => onChange({ cdn_url })}
|
||||
@@ -561,7 +567,7 @@ function ObjectFields({
|
||||
Path Style
|
||||
</FieldLabel>
|
||||
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
MinIO 等自托管 S3 通常需要开启。
|
||||
{t('storage.pathStyleDesc')}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { FileStats } from './components/file-stats';
|
||||
@@ -10,6 +11,7 @@ import { FileList } from './components/file-list';
|
||||
import { StorageConfigTab } from './components/storage-config-tab';
|
||||
|
||||
export default function FilesPage() {
|
||||
const t = useTranslations('admin.files');
|
||||
const [activeTab, setActiveTab] = React.useState('stats');
|
||||
|
||||
return (
|
||||
@@ -23,7 +25,9 @@ export default function FilesPage() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<FolderOpen className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>存储管理</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,16 +37,16 @@ export default function FilesPage() {
|
||||
value='stats'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
文件存储信息
|
||||
{t('tabStats')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='list' className='px-0 pb-2 text-xs font-semibold'>
|
||||
文件列表
|
||||
{t('tabList')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='storage'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
存储配置
|
||||
{t('tabStorage')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import services from '@/lib/services';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -65,12 +66,15 @@ interface TopUserData {
|
||||
count: number;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: '请求量',
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
function useAnalyticsChartConfig() {
|
||||
const t = useTranslations('admin.logs.analytics');
|
||||
return {
|
||||
count: {
|
||||
label: t('requestVolume'),
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
}
|
||||
|
||||
// Format YYYY-MM-DD to MM/DD
|
||||
function formatDateLabel(dateStr: string) {
|
||||
@@ -83,6 +87,8 @@ function formatDateLabel(dateStr: string) {
|
||||
}
|
||||
|
||||
export function AccessAnalytics() {
|
||||
const t = useTranslations('admin.logs.analytics');
|
||||
const chartConfig = useAnalyticsChartConfig();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
|
||||
@@ -109,7 +115,7 @@ export function AccessAnalytics() {
|
||||
setClickhouseDisabled(false);
|
||||
} catch (err) {
|
||||
const errorInstance =
|
||||
err instanceof Error ? err : new Error('获取数据统计失败');
|
||||
err instanceof Error ? err : new Error(t('fetchFailed'));
|
||||
const errMsg = errorInstance.message || '';
|
||||
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
|
||||
setClickhouseDisabled(true);
|
||||
@@ -119,7 +125,7 @@ export function AccessAnalytics() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
@@ -135,11 +141,9 @@ export function AccessAnalytics() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
|
||||
<XCircle className='size-10 text-muted-foreground mb-3' />
|
||||
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
|
||||
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
|
||||
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
|
||||
当前系统配置未启用 ClickHouse
|
||||
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
|
||||
配置文件中配置并启用 ClickHouse。
|
||||
{t('clickhouseDisabledDesc')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -153,7 +157,7 @@ export function AccessAnalytics() {
|
||||
return (
|
||||
<LoadingStateWithBorder
|
||||
icon={BarChart3}
|
||||
description='加载访问统计指标中...'
|
||||
description={t('loadingAnalytics')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +169,7 @@ export function AccessAnalytics() {
|
||||
<Card className='bg-card/25 border-border/40'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-sm font-semibold tracking-tight'>
|
||||
近 7 天总请求数
|
||||
{t('totalRequests7d')}
|
||||
</CardTitle>
|
||||
<TrendingUp className='size-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -174,14 +178,14 @@ export function AccessAnalytics() {
|
||||
{totalTrendRequests.toLocaleString()}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
系统记录的所有成功认证的访问总频次
|
||||
{t('totalRequests7dDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='bg-card/25 border-border/40'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-sm font-semibold tracking-tight'>
|
||||
活跃终端分类数
|
||||
{t('activeBrowserTypes')}
|
||||
</CardTitle>
|
||||
<Globe className='size-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -190,14 +194,14 @@ export function AccessAnalytics() {
|
||||
{browsers.length}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
在一周内发起请求的浏览器代理大类统计
|
||||
{t('activeBrowserTypesDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-sm font-semibold tracking-tight'>
|
||||
活跃独立用户数
|
||||
{t('activeUsers')}
|
||||
</CardTitle>
|
||||
<Users className='size-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
@@ -206,7 +210,7 @@ export function AccessAnalytics() {
|
||||
{topUsers.length}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
一周内累计发起高频请求的注册账户总量
|
||||
{t('activeUsersDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -217,10 +221,10 @@ export function AccessAnalytics() {
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<div>
|
||||
<CardTitle className='text-base font-bold'>
|
||||
一周访问量趋势
|
||||
{t('weeklyAccessTrend')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
展现系统最近 7 天内的每日 API 请求曲线
|
||||
{t('weeklyAccessTrendDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -241,7 +245,7 @@ export function AccessAnalytics() {
|
||||
{trend.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={TrendingUp}
|
||||
description='暂无趋势数据'
|
||||
description={t('noTrendData')}
|
||||
/>
|
||||
) : (
|
||||
<div className='h-[280px] w-full'>
|
||||
@@ -289,7 +293,7 @@ export function AccessAnalytics() {
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill='url(#colorCount)'
|
||||
name='请求次数'
|
||||
name={t('requestCount')}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
@@ -305,17 +309,17 @@ export function AccessAnalytics() {
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
|
||||
<Globe className='size-4.5 text-muted-foreground' />
|
||||
使用的浏览器排行
|
||||
{t('browserRanking')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
基于请求头 User-Agent 智能分类的一周占比统计
|
||||
{t('browserRankingDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex-1'>
|
||||
{browsers.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description='暂无浏览器排行数据'
|
||||
description={t('noBrowserData')}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
@@ -337,8 +341,10 @@ export function AccessAnalytics() {
|
||||
<span className='font-semibold'>{item.browser}</span>
|
||||
</span>
|
||||
<span className='text-muted-foreground font-mono text-xs'>
|
||||
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}
|
||||
%)
|
||||
{t('countTimes', {
|
||||
count: item.count.toLocaleString(),
|
||||
percent: percent.toFixed(1),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className='h-2 w-full rounded-full bg-muted overflow-hidden'>
|
||||
@@ -360,17 +366,17 @@ export function AccessAnalytics() {
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
|
||||
<Users className='size-4.5 text-muted-foreground' />
|
||||
最活跃的用户排行 (Top 10)
|
||||
{t('topActiveUsers')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
统计最近一周发起接口访问请求数量最多的账户
|
||||
{t('topActiveUsersDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex-1'>
|
||||
{topUsers.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Users}
|
||||
description='暂无活跃用户数据'
|
||||
description={t('noActiveUsersData')}
|
||||
/>
|
||||
) : (
|
||||
<div className='divide-y divide-border/40'>
|
||||
@@ -385,10 +391,13 @@ export function AccessAnalytics() {
|
||||
</div>
|
||||
<div className='truncate max-w-[180px] sm:max-w-xs'>
|
||||
<div className='text-xs font-bold leading-tight truncate'>
|
||||
{user.username || '未知'}
|
||||
{user.username || t('unknown')}
|
||||
</div>
|
||||
<div className='text-[10px] text-muted-foreground leading-normal truncate'>
|
||||
{user.nickname ? `(${user.nickname})` : '(无昵称)'} |
|
||||
{user.nickname
|
||||
? `(${user.nickname})`
|
||||
: t('noNickname')}{' '}
|
||||
|
|
||||
ID: {user.user_id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,7 +407,7 @@ export function AccessAnalytics() {
|
||||
{user.count.toLocaleString()}
|
||||
</div>
|
||||
<div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
次请求
|
||||
{t('requests')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import services from '@/lib/services';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -97,6 +98,7 @@ function isClickhouseDisabledError(error: Error) {
|
||||
}
|
||||
|
||||
export function AccessLogs() {
|
||||
const t = useTranslations('admin.logs.accessLogs');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// 搜索过滤条件
|
||||
@@ -184,12 +186,12 @@ export function AccessLogs() {
|
||||
|
||||
const copyToClipboard = (text: string, subject: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${subject}已复制到剪贴板`);
|
||||
toast.success(t('copiedToClipboard', { subject }));
|
||||
};
|
||||
|
||||
// Prettify Headers JSON string
|
||||
const getPrettyHeaders = (headersRaw?: string) => {
|
||||
if (!headersRaw) return '暂无头部数据';
|
||||
if (!headersRaw) return t('noHeaderData');
|
||||
try {
|
||||
const parsed = JSON.parse(headersRaw);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
@@ -202,11 +204,9 @@ export function AccessLogs() {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
|
||||
<XCircle className='size-10 text-muted-foreground mb-3' />
|
||||
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
|
||||
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
|
||||
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
|
||||
当前系统配置未启用 ClickHouse
|
||||
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
|
||||
配置文件中配置并启用 ClickHouse。
|
||||
{t('clickhouseDisabledDesc')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -227,11 +227,11 @@ export function AccessLogs() {
|
||||
>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-user' className='text-xs'>
|
||||
用户名
|
||||
{t('username')}
|
||||
</Label>
|
||||
<Input
|
||||
id='search-user'
|
||||
placeholder='输入用户名搜索...'
|
||||
placeholder={t('usernamePlaceholder')}
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
className='h-8 text-xs'
|
||||
@@ -239,11 +239,11 @@ export function AccessLogs() {
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-path' className='text-xs'>
|
||||
接口路径
|
||||
{t('apiPath')}
|
||||
</Label>
|
||||
<Input
|
||||
id='search-path'
|
||||
placeholder='模糊匹配路径...'
|
||||
placeholder={t('pathPlaceholder')}
|
||||
value={pathFilter}
|
||||
onChange={(e) => setPathFilter(e.target.value)}
|
||||
className='h-8 text-xs'
|
||||
@@ -251,7 +251,7 @@ export function AccessLogs() {
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-start' className='text-xs'>
|
||||
起始时间
|
||||
{t('startTime')}
|
||||
</Label>
|
||||
<Input
|
||||
id='search-start'
|
||||
@@ -263,7 +263,7 @@ export function AccessLogs() {
|
||||
</div>
|
||||
<div className='grid gap-1.5'>
|
||||
<Label htmlFor='search-end' className='text-xs'>
|
||||
结束时间
|
||||
{t('endTime')}
|
||||
</Label>
|
||||
<Input
|
||||
id='search-end'
|
||||
@@ -276,7 +276,7 @@ export function AccessLogs() {
|
||||
<div className='flex gap-2'>
|
||||
<Button type='submit' size='sm' className='h-8 flex-1'>
|
||||
<Search className='size-3.5 mr-1' />
|
||||
搜索
|
||||
{t('search')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -286,7 +286,7 @@ export function AccessLogs() {
|
||||
className='h-8 flex-1'
|
||||
>
|
||||
<RotateCcw className='size-3.5 mr-1' />
|
||||
重置
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -295,26 +295,27 @@ export function AccessLogs() {
|
||||
{loading && logs.length === 0 ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Activity}
|
||||
description='加载访问日志中...'
|
||||
description={t('loadingAccessLogs')}
|
||||
/>
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Activity}
|
||||
description='未查询到任何访问日志数据'
|
||||
/>
|
||||
<EmptyStateWithBorder icon={Activity} description={t('noAccessLogs')} />
|
||||
) : (
|
||||
<div className='rounded-lg border bg-card overflow-hidden'>
|
||||
<Table className='min-w-[900px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[100px]'>请求方法</TableHead>
|
||||
<TableHead className='min-w-[200px]'>路径</TableHead>
|
||||
<TableHead className='w-[150px]'>用户</TableHead>
|
||||
<TableHead className='w-[120px]'>IP</TableHead>
|
||||
<TableHead className='w-[90px]'>状态</TableHead>
|
||||
<TableHead className='w-[100px]'>耗时</TableHead>
|
||||
<TableHead className='w-[170px]'>请求时间</TableHead>
|
||||
<TableHead className='w-[80px] text-center'>详情</TableHead>
|
||||
<TableHead className='w-[100px]'>{t('colMethod')}</TableHead>
|
||||
<TableHead className='min-w-[200px]'>{t('colPath')}</TableHead>
|
||||
<TableHead className='w-[150px]'>{t('colUser')}</TableHead>
|
||||
<TableHead className='w-[120px]'>{t('colIp')}</TableHead>
|
||||
<TableHead className='w-[90px]'>{t('colStatus')}</TableHead>
|
||||
<TableHead className='w-[100px]'>{t('colLatency')}</TableHead>
|
||||
<TableHead className='w-[170px]'>
|
||||
{t('colRequestTime')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-center'>
|
||||
{t('colDetail')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -334,7 +335,7 @@ export function AccessLogs() {
|
||||
<TableCell>
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-xs font-medium'>
|
||||
{log.username || '未知用户'}
|
||||
{log.username || t('unknownUser')}
|
||||
</span>
|
||||
{log.nickname && (
|
||||
<span className='text-[10px] text-muted-foreground'>
|
||||
@@ -381,7 +382,7 @@ export function AccessLogs() {
|
||||
{logs.length > 0 && (
|
||||
<div className='flex items-center justify-between py-1'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
共 {total} 条记录,当前第 {page}/{totalPages} 页
|
||||
{t('paginationInfo', { total, page, totalPages })}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
@@ -392,7 +393,7 @@ export function AccessLogs() {
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
<ChevronLeft className='size-3.5 mr-1' />
|
||||
上一页
|
||||
{t('prevPage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -401,7 +402,7 @@ export function AccessLogs() {
|
||||
disabled={page >= totalPages || loading}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
下一页
|
||||
{t('nextPage')}
|
||||
<ChevronRight className='size-3.5 ml-1' />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -412,9 +413,11 @@ export function AccessLogs() {
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className='w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l'>
|
||||
<SheetHeader className='border-b px-5 py-4 shrink-0'>
|
||||
<SheetTitle className='text-lg font-bold'>访问日志详情</SheetTitle>
|
||||
<SheetTitle className='text-lg font-bold'>
|
||||
{t('accessLogDetail')}
|
||||
</SheetTitle>
|
||||
<SheetDescription className='text-xs'>
|
||||
访问 ID: {selectedLog?.id}
|
||||
{t('accessId', { id: selectedLog?.id ?? '' })}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -424,7 +427,7 @@ export function AccessLogs() {
|
||||
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3'>
|
||||
<div className='rounded-lg border p-3 bg-muted/10'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
请求方法
|
||||
{t('method')}
|
||||
</div>
|
||||
<div className='mt-1 font-mono text-sm font-bold uppercase'>
|
||||
{selectedLog.method}
|
||||
@@ -432,7 +435,7 @@ export function AccessLogs() {
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
响应状态
|
||||
{t('responseStatus')}
|
||||
</div>
|
||||
<div className='mt-1'>
|
||||
<Badge variant={statusVariant(selectedLog.status)}>
|
||||
@@ -442,7 +445,7 @@ export function AccessLogs() {
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
耗时
|
||||
{t('latency')}
|
||||
</div>
|
||||
<div className='mt-1 font-mono text-sm'>
|
||||
{formatLatency(selectedLog.latency)}
|
||||
@@ -450,18 +453,18 @@ export function AccessLogs() {
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
IP 地址
|
||||
{t('ipAddress')}
|
||||
</div>
|
||||
<div className='mt-1 font-mono text-sm'>{selectedLog.ip}</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3 bg-muted/10 col-span-2'>
|
||||
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
|
||||
用户
|
||||
{t('user')}
|
||||
</div>
|
||||
<div className='mt-1 text-sm font-medium'>
|
||||
{selectedLog.username
|
||||
? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})`
|
||||
: '未知/游客'}
|
||||
? `${selectedLog.username} (${selectedLog.nickname || t('noNickname')})`
|
||||
: t('unknownGuest')}
|
||||
<span className='block font-mono text-[10px] text-muted-foreground mt-0.5'>
|
||||
ID: {selectedLog.user_id}
|
||||
</span>
|
||||
@@ -472,7 +475,7 @@ export function AccessLogs() {
|
||||
{/* Path */}
|
||||
<div className='grid gap-2'>
|
||||
<Label className='text-xs font-bold text-muted-foreground'>
|
||||
请求路径
|
||||
{t('requestPath')}
|
||||
</Label>
|
||||
<div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'>
|
||||
{selectedLog.path}
|
||||
@@ -482,7 +485,7 @@ export function AccessLogs() {
|
||||
{/* Request Time */}
|
||||
<div className='grid gap-2'>
|
||||
<Label className='text-xs font-bold text-muted-foreground'>
|
||||
请求时间
|
||||
{t('requestTime')}
|
||||
</Label>
|
||||
<div className='font-mono text-xs'>
|
||||
{formatDateTime(selectedLog.created_at)}
|
||||
@@ -502,7 +505,7 @@ export function AccessLogs() {
|
||||
onClick={() =>
|
||||
copyToClipboard(selectedLog.user_agent, 'User-Agent')
|
||||
}
|
||||
title='复制 User-Agent'
|
||||
title={t('copyUserAgent')}
|
||||
>
|
||||
<Copy className='size-3' />
|
||||
</Button>
|
||||
@@ -528,7 +531,7 @@ export function AccessLogs() {
|
||||
'Headers',
|
||||
)
|
||||
}
|
||||
title='复制 Headers'
|
||||
title={t('copyHeaders')}
|
||||
>
|
||||
<Copy className='size-3' />
|
||||
</Button>
|
||||
@@ -542,7 +545,7 @@ export function AccessLogs() {
|
||||
<div className='flex-grow flex items-center justify-center'>
|
||||
<EmptyStateWithBorder
|
||||
icon={Activity}
|
||||
description='未选中日志明细'
|
||||
description={t('noLogSelected')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { toast } from 'sonner';
|
||||
import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
|
||||
import services from '@/lib/services';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -92,6 +93,7 @@ function parseLogLevel(
|
||||
}
|
||||
|
||||
export function AppLogs() {
|
||||
const t = useTranslations('admin.logs.appLogs');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
@@ -176,9 +178,9 @@ export function AppLogs() {
|
||||
try {
|
||||
return await services.adminLog.getLogs(cursor);
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error('获取日志失败');
|
||||
throw err instanceof Error ? err : new Error(t('fetchFailed'));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
async (cursor: number = 0) => {
|
||||
@@ -209,16 +211,16 @@ export function AppLogs() {
|
||||
setNextCursor(data.next_cursor);
|
||||
} catch (err) {
|
||||
if (isInitial) {
|
||||
setError(err instanceof Error ? err : new Error('获取日志失败'));
|
||||
setError(err instanceof Error ? err : new Error(t('fetchFailed')));
|
||||
} else {
|
||||
toast.error('加载更早日志失败');
|
||||
toast.error(t('loadOlderFailed'));
|
||||
}
|
||||
} finally {
|
||||
if (isInitial) setLoading(false);
|
||||
else setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[fetchLogs],
|
||||
[fetchLogs, t],
|
||||
);
|
||||
|
||||
// ---- WebSocket -------------------------------------------------------
|
||||
@@ -300,12 +302,12 @@ export function AppLogs() {
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className='size-3 mr-1.5 animate-spin' />
|
||||
加载中...
|
||||
{t('loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronUp className='size-3 mr-1.5' />
|
||||
加载更早日志
|
||||
{t('loadOlderLogs')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -313,7 +315,9 @@ export function AppLogs() {
|
||||
)}
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className='px-4 py-8 text-center text-gray-500'>暂无日志</div>
|
||||
<div className='px-4 py-8 text-center text-gray-500'>
|
||||
{t('noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className='relative px-3 py-3'
|
||||
@@ -349,7 +353,7 @@ export function AppLogs() {
|
||||
className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
|
||||
>
|
||||
<ArrowDown className='size-3.5 mr-1.5' />
|
||||
回到最新
|
||||
{t('backToLatest')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Activity, BarChart3, Terminal } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
@@ -26,13 +27,16 @@ const AppLogs = dynamic(
|
||||
);
|
||||
|
||||
export function LogsPageClient() {
|
||||
const t = useTranslations('admin.logs');
|
||||
return (
|
||||
<div className='flex flex-col h-full space-y-6 py-6'>
|
||||
{/* Header */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Terminal className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>系统日志</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs Layout */}
|
||||
@@ -43,21 +47,21 @@ export function LogsPageClient() {
|
||||
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
|
||||
>
|
||||
<BarChart3 className='size-3.5' />
|
||||
访问分析
|
||||
{t('accessAnalytics')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='access'
|
||||
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
|
||||
>
|
||||
<Activity className='size-3.5' />
|
||||
用户访问日志
|
||||
{t('userAccessLogs')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='app'
|
||||
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
|
||||
>
|
||||
<Terminal className='size-3.5' />
|
||||
应用运行日志
|
||||
{t('appRuntimeLogs')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -80,6 +81,7 @@ import type {
|
||||
import { PushService } from '@/lib/services/push';
|
||||
|
||||
export function EventsTab() {
|
||||
const t = useTranslations('admin.push.events');
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
|
||||
null,
|
||||
@@ -120,12 +122,12 @@ export function EventsTab() {
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) =>
|
||||
PushService.updateEvent(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success('事件更新成功');
|
||||
toast.success(t('eventUpdateSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
|
||||
setEditEventOpen(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error('事件更新失败: ' + (err as Error).message);
|
||||
toast.error(t('eventUpdateFailed') + ': ' + (err as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -135,14 +137,14 @@ export function EventsTab() {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error('操作失败: ' + (err as Error).message);
|
||||
toast.error(t('operationFailed') + ': ' + (err as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
|
||||
onSuccess: () => {
|
||||
toast.success('事件创建成功');
|
||||
toast.success(t('eventCreateSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
|
||||
setCreateEventOpen(false);
|
||||
setNewEventKey('');
|
||||
@@ -152,7 +154,7 @@ export function EventsTab() {
|
||||
setNewEventEnabled(true);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error('事件创建失败: ' + (err as Error).message);
|
||||
toast.error(t('eventCreateFailed') + ': ' + (err as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,11 +162,11 @@ export function EventsTab() {
|
||||
mutationFn: (id: number) => PushService.deleteEvent(id),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
toast.success('配置删除成功');
|
||||
toast.success(t('configDeleteSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error('配置删除失败: ' + (err as Error).message);
|
||||
toast.error(t('configDeleteFailed') + ': ' + (err as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,7 +214,7 @@ export function EventsTab() {
|
||||
try {
|
||||
JSON.parse(eventTemplate);
|
||||
} catch {
|
||||
toast.error('消息模板不是合法的 JSON 格式');
|
||||
toast.error(t('templateInvalidJson'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -260,8 +262,8 @@ export function EventsTab() {
|
||||
);
|
||||
if (taskMeta) {
|
||||
const defaultTemplate = {
|
||||
title: `任务完成: ${taskMeta.name}`,
|
||||
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
|
||||
title: t('taskCompleteTitle', { name: taskMeta.name }),
|
||||
content: t('taskCompleteContent'),
|
||||
level: 'INFO',
|
||||
};
|
||||
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2));
|
||||
@@ -272,11 +274,11 @@ export function EventsTab() {
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
if (newEventType === 'builtin' && !newEventKey) {
|
||||
toast.error('请选择系统事件');
|
||||
toast.error(t('selectSystemEvent'));
|
||||
return;
|
||||
}
|
||||
if (newEventType === 'task' && !newEventTaskType) {
|
||||
toast.error('请选择异步任务');
|
||||
toast.error(t('selectAsyncTask'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,7 +286,7 @@ export function EventsTab() {
|
||||
try {
|
||||
JSON.parse(newEventTemplate);
|
||||
} catch {
|
||||
toast.error('内容渲染模板不是合法的 JSON 格式');
|
||||
toast.error(t('templateInvalidJson'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -309,11 +311,11 @@ export function EventsTab() {
|
||||
<div className='flex justify-end'>
|
||||
<Button size='sm' onClick={handleCreateEventClick} className='text-xs'>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新增通知事件
|
||||
{t('addEvent')}
|
||||
</Button>
|
||||
</div>
|
||||
{eventsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Layers} description='加载通知事件中...' />
|
||||
<LoadingStateWithBorder icon={Layers} description={t('loadingEvents')} />
|
||||
) : eventsQuery.isError ? (
|
||||
<div className='p-8 border border-dashed rounded-xl bg-card'>
|
||||
<ErrorInline
|
||||
@@ -325,7 +327,7 @@ export function EventsTab() {
|
||||
) : (eventsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Layers}
|
||||
description='暂无通知配置事件,请点击右上角新增'
|
||||
description={t('noEvents')}
|
||||
/>
|
||||
) : (
|
||||
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
|
||||
@@ -336,19 +338,19 @@ export function EventsTab() {
|
||||
ID
|
||||
</TableHead>
|
||||
<TableHead className='w-[180px] whitespace-nowrap py-2 h-8'>
|
||||
通知事件
|
||||
{t('colEvent')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[200px] whitespace-nowrap py-2 h-8'>
|
||||
关联渠道
|
||||
{t('colChannels')}
|
||||
</TableHead>
|
||||
<TableHead className='whitespace-nowrap py-2 h-8'>
|
||||
推送目标
|
||||
{t('colTargets')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
|
||||
状态
|
||||
{t('colStatus')}
|
||||
</TableHead>
|
||||
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
|
||||
操作
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -376,7 +378,7 @@ export function EventsTab() {
|
||||
variant='outline'
|
||||
className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200'
|
||||
>
|
||||
任务
|
||||
{t('taskBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -389,7 +391,7 @@ export function EventsTab() {
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{(event.channels ?? []).length === 0 ? (
|
||||
<span className='text-xs text-muted-foreground italic'>
|
||||
未指定渠道
|
||||
{t('noChannelSpecified')}
|
||||
</span>
|
||||
) : (
|
||||
event.channels.map((ch) => (
|
||||
@@ -398,7 +400,7 @@ export function EventsTab() {
|
||||
variant='secondary'
|
||||
className='text-[10px] py-0 px-1.5 h-4.5'
|
||||
>
|
||||
{ch === 'email' ? '邮件' : ch}
|
||||
{ch === 'email' ? t('emailChannel') : ch}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
@@ -453,7 +455,7 @@ export function EventsTab() {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
配置
|
||||
{t('configure')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -472,7 +474,7 @@ export function EventsTab() {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
删除
|
||||
{t('delete')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -489,15 +491,15 @@ export function EventsTab() {
|
||||
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
|
||||
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增通知事件</DialogTitle>
|
||||
<DialogTitle>{t('addEventDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
|
||||
{t('addEventDialogDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>事件类型</Label>
|
||||
<Label className='text-xs font-semibold'>{t('eventType')}</Label>
|
||||
<div className='flex gap-4 p-1.5 border rounded-md bg-muted/20'>
|
||||
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
|
||||
<input
|
||||
@@ -511,7 +513,7 @@ export function EventsTab() {
|
||||
}}
|
||||
className='scale-90'
|
||||
/>
|
||||
<span>系统内置事件</span>
|
||||
<span>{t('builtinEventType')}</span>
|
||||
</label>
|
||||
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
|
||||
<input
|
||||
@@ -525,22 +527,22 @@ export function EventsTab() {
|
||||
}}
|
||||
className='scale-90'
|
||||
/>
|
||||
<span>任务完成事件</span>
|
||||
<span>{t('taskEventType')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newEventType === 'builtin' ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>系统事件</Label>
|
||||
<Label className='text-xs font-semibold'>{t('systemEvent')}</Label>
|
||||
{builtInEventsQuery.isLoading ? (
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
<span>加载系统事件中...</span>
|
||||
<span>{t('loadingSystemEvents')}</span>
|
||||
</div>
|
||||
) : availableBuiltInEvents.length === 0 ? (
|
||||
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
|
||||
所有内置事件都已配置,没有可新增的事件。
|
||||
{t('allBuiltinEventsConfigured')}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
@@ -548,7 +550,9 @@ export function EventsTab() {
|
||||
onValueChange={handleNewEventKeyChange}
|
||||
>
|
||||
<SelectTrigger className='text-xs h-9'>
|
||||
<SelectValue placeholder='请选择系统事件' />
|
||||
<SelectValue
|
||||
placeholder={t('selectSystemEventPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableBuiltInEvents.map((ev) => (
|
||||
@@ -566,15 +570,17 @@ export function EventsTab() {
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>系统异步任务</Label>
|
||||
<Label className='text-xs font-semibold'>
|
||||
{t('systemAsyncTask')}
|
||||
</Label>
|
||||
{taskTypesQuery.isLoading ? (
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
<span>加载异步任务中...</span>
|
||||
<span>{t('loadingAsyncTasks')}</span>
|
||||
</div>
|
||||
) : (taskTypesQuery.data ?? []).length === 0 ? (
|
||||
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
|
||||
暂无可用的系统任务。
|
||||
{t('noAvailableTasks')}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
@@ -582,7 +588,9 @@ export function EventsTab() {
|
||||
onValueChange={handleNewEventTaskTypeChange}
|
||||
>
|
||||
<SelectTrigger className='text-xs h-9'>
|
||||
<SelectValue placeholder='请选择异步任务' />
|
||||
<SelectValue
|
||||
placeholder={t('selectAsyncTaskPlaceholder')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(taskTypesQuery.data ?? []).map((taskMeta) => (
|
||||
@@ -603,11 +611,11 @@ export function EventsTab() {
|
||||
{newEventType === 'builtin' && newEventKey && (
|
||||
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
|
||||
<span className='font-semibold text-foreground'>
|
||||
事件说明:
|
||||
{t('eventDescription')}
|
||||
</span>
|
||||
<span>
|
||||
{availableBuiltInEvents.find((e) => e.key === newEventKey)
|
||||
?.description || '无描述信息'}
|
||||
?.description || t('noDescription')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -615,18 +623,20 @@ export function EventsTab() {
|
||||
{newEventType === 'task' && newEventTaskType && (
|
||||
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
|
||||
<span className='font-semibold text-foreground'>
|
||||
任务说明:
|
||||
{t('taskDescription')}
|
||||
</span>
|
||||
<span>
|
||||
{(taskTypesQuery.data ?? []).find(
|
||||
(t) => t.asynq_task === newEventTaskType,
|
||||
)?.description || '无描述信息'}
|
||||
)?.description || t('noDescription')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>推送渠道 (可多选)</Label>
|
||||
<Label className='text-xs font-semibold'>
|
||||
{t('pushChannelsMulti')}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -636,11 +646,11 @@ export function EventsTab() {
|
||||
{newEventChannels.length > 0
|
||||
? newEventChannels
|
||||
.map((ch) => {
|
||||
if (ch === 'email') return '邮件';
|
||||
if (ch === 'email') return t('emailChannel');
|
||||
return ch;
|
||||
})
|
||||
.join(', ')
|
||||
: '选择已配置推送渠道'}
|
||||
: t('selectConfiguredChannels')}
|
||||
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -666,12 +676,12 @@ export function EventsTab() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{ch === 'email' ? '邮件推送' : ch}</span>
|
||||
<span>{ch === 'email' ? t('emailPush') : ch}</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className='text-[11px] text-muted-foreground italic p-1'>
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
|
||||
{t('noAvailableChannels')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -681,11 +691,11 @@ export function EventsTab() {
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>
|
||||
推送目标 (仅邮件等渠道需要)
|
||||
{t('pushTargets')}
|
||||
</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
|
||||
placeholder={t('pushTargetsPlaceholder')}
|
||||
value={newEventTargets}
|
||||
onChange={(e) => setNewEventTargets(e.target.value)}
|
||||
className='text-xs h-9'
|
||||
@@ -695,12 +705,12 @@ export function EventsTab() {
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Label className='text-xs font-semibold'>
|
||||
内容渲染模板 (JSON 格式)
|
||||
{t('contentTemplate')}
|
||||
</Label>
|
||||
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
|
||||
{newEventType === 'task'
|
||||
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
|
||||
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
|
||||
? t('taskTemplateVars')
|
||||
: t('eventTemplateVars')}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -714,9 +724,9 @@ export function EventsTab() {
|
||||
|
||||
<div className='flex items-center justify-between p-3 border rounded-lg bg-muted/10'>
|
||||
<div className='space-y-0.5'>
|
||||
<Label className='text-xs font-semibold'>启用状态</Label>
|
||||
<Label className='text-xs font-semibold'>{t('enableStatus')}</Label>
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
创建后是否立即开始接收此事件的通知
|
||||
{t('enableStatusDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -733,7 +743,7 @@ export function EventsTab() {
|
||||
onClick={() => setCreateEventOpen(false)}
|
||||
className='h-9 text-xs'
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='default'
|
||||
@@ -749,7 +759,7 @@ export function EventsTab() {
|
||||
{createEventMutation.isPending && (
|
||||
<Loader2 className='size-3 animate-spin mr-1' />
|
||||
)}
|
||||
启用保存
|
||||
{t('enableSave')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -759,9 +769,9 @@ export function EventsTab() {
|
||||
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
|
||||
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>事件通知渠道配置</DialogTitle>
|
||||
<DialogTitle>{t('editEventDialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
|
||||
{t('editEventDialogDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -770,7 +780,7 @@ export function EventsTab() {
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold text-muted-foreground'>
|
||||
事件名称
|
||||
{t('eventName')}
|
||||
</Label>
|
||||
<Input
|
||||
value={selectedEvent.name}
|
||||
@@ -780,7 +790,7 @@ export function EventsTab() {
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold text-muted-foreground'>
|
||||
事件键 (Key)
|
||||
{t('eventKey')}
|
||||
</Label>
|
||||
<Input
|
||||
value={selectedEvent.event_key}
|
||||
@@ -793,7 +803,7 @@ export function EventsTab() {
|
||||
{selectedEvent.task_type && (
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold text-muted-foreground'>
|
||||
关联异步任务
|
||||
{t('relatedAsyncTask')}
|
||||
</Label>
|
||||
<Input
|
||||
value={`${(taskTypesQuery.data ?? []).find((t) => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
|
||||
@@ -805,7 +815,7 @@ export function EventsTab() {
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>
|
||||
推送渠道 (可多选)
|
||||
{t('pushChannelsMulti')}
|
||||
</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -816,11 +826,11 @@ export function EventsTab() {
|
||||
{eventChannels.length > 0
|
||||
? eventChannels
|
||||
.map((ch) => {
|
||||
if (ch === 'email') return '邮件';
|
||||
if (ch === 'email') return t('emailChannel');
|
||||
return ch;
|
||||
})
|
||||
.join(', ')
|
||||
: '选择已配置推送渠道'}
|
||||
: t('selectConfiguredChannels')}
|
||||
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -846,12 +856,12 @@ export function EventsTab() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{ch === 'email' ? '邮件推送' : ch}</span>
|
||||
<span>{ch === 'email' ? t('emailPush') : ch}</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className='text-[11px] text-muted-foreground italic p-1'>
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
|
||||
{t('noAvailableChannelsEdit')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -861,11 +871,11 @@ export function EventsTab() {
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>
|
||||
推送目标 (仅邮件等渠道需要)
|
||||
{t('pushTargets')}
|
||||
</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
|
||||
placeholder={t('pushTargetsPlaceholder')}
|
||||
value={eventTargets}
|
||||
onChange={(e) => setEventTargets(e.target.value)}
|
||||
className='text-xs h-9'
|
||||
@@ -875,12 +885,12 @@ export function EventsTab() {
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Label className='text-xs font-semibold'>
|
||||
内容渲染模板 (JSON 格式)
|
||||
{t('contentTemplate')}
|
||||
</Label>
|
||||
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
|
||||
{selectedEvent.task_type
|
||||
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
|
||||
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
|
||||
? t('taskTemplateVars')
|
||||
: t('eventTemplateVars')}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -901,7 +911,7 @@ export function EventsTab() {
|
||||
onClick={() => setEditEventOpen(false)}
|
||||
className='h-9 text-xs'
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='default'
|
||||
@@ -913,7 +923,7 @@ export function EventsTab() {
|
||||
{updateEventMutation.isPending && (
|
||||
<Loader2 className='size-3 animate-spin mr-1' />
|
||||
)}
|
||||
保存修改
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -925,14 +935,14 @@ export function EventsTab() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除通知事件</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteEventTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除该通知事件配置吗?删除后无法恢复。
|
||||
{t('deleteEventConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteEventMutation.isPending}>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteEventMutation.isPending}
|
||||
@@ -940,7 +950,9 @@ export function EventsTab() {
|
||||
deleteTarget && deleteEventMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteEventMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteEventMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ChevronLeft,
|
||||
@@ -62,6 +63,7 @@ function getLevelBadgeVariant(
|
||||
}
|
||||
|
||||
export function HistoriesTab() {
|
||||
const t = useTranslations('admin.push.histories');
|
||||
const [historyPage, setHistoryPage] = React.useState(1);
|
||||
const [historySearch, setHistorySearch] = React.useState('');
|
||||
const [historyStatus, setHistoryStatus] = React.useState('all');
|
||||
@@ -99,7 +101,7 @@ export function HistoriesTab() {
|
||||
<Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='输入事件键过滤...'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className='pl-8 text-xs h-9'
|
||||
value={historySearch}
|
||||
onChange={(e) => {
|
||||
@@ -117,17 +119,17 @@ export function HistoriesTab() {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='text-xs h-9'>
|
||||
<SelectValue placeholder='发送状态' />
|
||||
<SelectValue placeholder={t('sendStatus')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all' className='text-xs'>
|
||||
全部状态
|
||||
{t('allStatus')}
|
||||
</SelectItem>
|
||||
<SelectItem value='success' className='text-xs'>
|
||||
发送成功
|
||||
{t('sendSuccess')}
|
||||
</SelectItem>
|
||||
<SelectItem value='failed' className='text-xs'>
|
||||
发送失败
|
||||
{t('sendFailed')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -151,13 +153,27 @@ export function HistoriesTab() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/30'>
|
||||
<TableHead className='text-xs font-semibold'>事件</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>渠道</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>目标</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>标题</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>等级</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>状态</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>时间</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colEvent')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colChannel')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colTarget')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colTitle')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colLevel')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colStatus')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colTime')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -166,7 +182,7 @@ export function HistoriesTab() {
|
||||
<TableCell colSpan={7} className='h-32'>
|
||||
<LoadingStateWithBorder
|
||||
icon={History}
|
||||
description='加载推送历史记录中...'
|
||||
description={t('loadingHistories')}
|
||||
className='border-0 shadow-none'
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -187,7 +203,7 @@ export function HistoriesTab() {
|
||||
colSpan={7}
|
||||
className='h-32 text-center text-xs text-muted-foreground'
|
||||
>
|
||||
无推送历史数据
|
||||
{t('noHistoryData')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -201,7 +217,9 @@ export function HistoriesTab() {
|
||||
{hist.event_key}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs uppercase font-semibold text-muted-foreground'>
|
||||
{hist.channel === 'email' ? '邮件' : hist.channel}
|
||||
{hist.channel === 'email'
|
||||
? t('emailChannel')
|
||||
: hist.channel}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-xs font-mono text-muted-foreground max-w-[120px] truncate'
|
||||
@@ -230,7 +248,7 @@ export function HistoriesTab() {
|
||||
}
|
||||
className='text-[10px] font-semibold'
|
||||
>
|
||||
{hist.status === 'success' ? '成功' : '失败'}
|
||||
{hist.status === 'success' ? t('success') : t('failed')}
|
||||
</Badge>
|
||||
{hist.status !== 'success' && hist.error_msg && (
|
||||
<div
|
||||
@@ -253,7 +271,9 @@ export function HistoriesTab() {
|
||||
|
||||
{historiesQuery.data && historiesQuery.data.total > 0 && (
|
||||
<div className='flex justify-between items-center text-xs text-muted-foreground'>
|
||||
<span>共 {historiesQuery.data.total} 条历史记录</span>
|
||||
<span>
|
||||
{t('totalRecords', { total: historiesQuery.data.total })}
|
||||
</span>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -284,10 +304,10 @@ export function HistoriesTab() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<History className='size-5 text-primary' />
|
||||
推送通知详情
|
||||
{t('pushNotificationDetail')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看该条通知发送的详细审计信息与内容
|
||||
{t('pushNotificationDetailDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -296,7 +316,7 @@ export function HistoriesTab() {
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
事件标识
|
||||
{t('eventIdentifier')}
|
||||
</span>
|
||||
<div className='font-mono bg-muted/40 p-2 rounded border'>
|
||||
{selectedHistory.event_key}
|
||||
@@ -304,11 +324,11 @@ export function HistoriesTab() {
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
发送渠道
|
||||
{t('sendChannel')}
|
||||
</span>
|
||||
<div className='bg-muted/40 p-2 rounded border uppercase font-medium'>
|
||||
{selectedHistory.channel === 'email'
|
||||
? '邮件推送'
|
||||
? t('emailPush')
|
||||
: selectedHistory.channel}
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,7 +337,7 @@ export function HistoriesTab() {
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
推送目标
|
||||
{t('pushTarget')}
|
||||
</span>
|
||||
<div
|
||||
className='font-mono bg-muted/40 p-2 rounded border truncate'
|
||||
@@ -328,7 +348,7 @@ export function HistoriesTab() {
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
发送时间
|
||||
{t('sendTime')}
|
||||
</span>
|
||||
<div className='bg-muted/40 p-2 rounded border'>
|
||||
{new Date(selectedHistory.created_at).toLocaleString()}
|
||||
@@ -339,7 +359,7 @@ export function HistoriesTab() {
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
通知等级
|
||||
{t('notificationLevel')}
|
||||
</span>
|
||||
<div>
|
||||
<Badge
|
||||
@@ -352,7 +372,7 @@ export function HistoriesTab() {
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
发送状态
|
||||
{t('sendStatus')}
|
||||
</span>
|
||||
<div>
|
||||
<Badge
|
||||
@@ -363,7 +383,9 @@ export function HistoriesTab() {
|
||||
}
|
||||
className='text-[10px] font-semibold py-0.5 px-2'
|
||||
>
|
||||
{selectedHistory.status === 'success' ? '成功' : '失败'}
|
||||
{selectedHistory.status === 'success'
|
||||
? t('success')
|
||||
: t('failed')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,7 +395,7 @@ export function HistoriesTab() {
|
||||
selectedHistory.error_msg && (
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-destructive block'>
|
||||
失败原因
|
||||
{t('failureReason')}
|
||||
</span>
|
||||
<div className='font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all'>
|
||||
{selectedHistory.error_msg}
|
||||
@@ -383,7 +405,7 @@ export function HistoriesTab() {
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
通知标题
|
||||
{t('notificationTitle')}
|
||||
</span>
|
||||
<div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'>
|
||||
{selectedHistory.title}
|
||||
@@ -392,7 +414,7 @@ export function HistoriesTab() {
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<span className='font-semibold text-muted-foreground block'>
|
||||
通知内容
|
||||
{t('notificationContent')}
|
||||
</span>
|
||||
<div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'>
|
||||
{selectedHistory.content}
|
||||
@@ -408,7 +430,7 @@ export function HistoriesTab() {
|
||||
onClick={() => setDetailOpen(false)}
|
||||
className='h-9 text-xs'
|
||||
>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -61,6 +62,7 @@ import type {
|
||||
import { PushService } from '@/lib/services/push';
|
||||
|
||||
export function SettingsTab() {
|
||||
const t = useTranslations('admin.push.settings');
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
|
||||
null,
|
||||
@@ -82,12 +84,12 @@ export function SettingsTab() {
|
||||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
|
||||
onSuccess: () => {
|
||||
toast.success('通道创建成功');
|
||||
toast.success(t('channelCreateSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
|
||||
setChannelDialogOpen(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error('通道创建失败: ' + (err as Error).message);
|
||||
toast.error(t('channelCreateFailed') + ': ' + (err as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,12 +97,12 @@ export function SettingsTab() {
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
|
||||
PushService.updateChannel(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success('通道更新成功');
|
||||
toast.success(t('channelUpdateSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
|
||||
setChannelDialogOpen(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error('通道更新失败: ' + (err as Error).message);
|
||||
toast.error(t('channelUpdateFailed') + ': ' + (err as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -108,11 +110,11 @@ export function SettingsTab() {
|
||||
mutationFn: (id: number) => PushService.deleteChannel(id),
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
toast.success('通道删除成功');
|
||||
toast.success(t('channelDeleteSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error('通道删除失败: ' + (err as Error).message);
|
||||
toast.error(t('channelDeleteFailed') + ': ' + (err as Error).message);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -195,16 +197,16 @@ export function SettingsTab() {
|
||||
|
||||
const handleSaveChannel = () => {
|
||||
if (!channelName && !editingChannel) {
|
||||
toast.error('通道名称不能为空');
|
||||
toast.error(t('channelNameRequired'));
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
|
||||
toast.error('通道名称只能使用英文字母、数字和下划线');
|
||||
toast.error(t('channelNameFormat'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeDef) {
|
||||
toast.error('无效的通道类型');
|
||||
toast.error(t('invalidChannelType'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -217,7 +219,7 @@ export function SettingsTab() {
|
||||
? channelToken
|
||||
: channelOther;
|
||||
if (field.required && !value.trim()) {
|
||||
toast.error(`${field.label}不能为空`);
|
||||
toast.error(t('fieldRequired', { label: field.label }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -225,7 +227,7 @@ export function SettingsTab() {
|
||||
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
|
||||
if (channelType !== 'email') {
|
||||
if (channelUrl && !channelUrl.startsWith('https://')) {
|
||||
toast.error('地址必须以 https:// 开头以确保安全性');
|
||||
toast.error(t('httpsRequired'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -235,14 +237,14 @@ export function SettingsTab() {
|
||||
try {
|
||||
JSON.parse(channelOther);
|
||||
} catch {
|
||||
toast.error('请求体必须是合法的 JSON 格式');
|
||||
toast.error(t('jsonFormatRequired'));
|
||||
return;
|
||||
}
|
||||
} else if (channelType === 'lark' && channelOther) {
|
||||
try {
|
||||
JSON.parse(channelOther);
|
||||
} catch {
|
||||
toast.error('自定义卡片模版必须是合法的 JSON 格式');
|
||||
toast.error(t('larkTemplateFormat'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -281,15 +283,15 @@ export function SettingsTab() {
|
||||
const handleSendChannelTest = async () => {
|
||||
try {
|
||||
setIsTestingChannel(true);
|
||||
toast.info('正在发送测试推送...');
|
||||
toast.info(t('sendingTestPush'));
|
||||
await PushService.testChannel({
|
||||
name: testChannelName,
|
||||
target: testChannelTarget || undefined,
|
||||
});
|
||||
toast.success('测试推送发送成功,请前往对应平台确认。');
|
||||
toast.success(t('testPushSuccess'));
|
||||
setTestChannelOpen(false);
|
||||
} catch (err: unknown) {
|
||||
toast.error('连通性测试失败: ' + (err as Error).message);
|
||||
toast.error(t('connectivityTestFailed') + ': ' + (err as Error).message);
|
||||
} finally {
|
||||
setIsTestingChannel(false);
|
||||
}
|
||||
@@ -299,9 +301,9 @@ export function SettingsTab() {
|
||||
<div className='pt-4 space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<h2 className='text-sm font-semibold'>自定义推送通道</h2>
|
||||
<h2 className='text-sm font-semibold'>{t('customPushChannels')}</h2>
|
||||
<p className='text-[11px] text-muted-foreground mt-0.5'>
|
||||
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
|
||||
{t('customPushChannelsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -310,14 +312,14 @@ export function SettingsTab() {
|
||||
className='text-xs'
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新建消息通道
|
||||
{t('createChannel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{channelsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Settings}
|
||||
description='加载消息通道中...'
|
||||
description={t('loadingChannels')}
|
||||
/>
|
||||
) : channelsQuery.isError ? (
|
||||
<div className='p-8 border border-dashed rounded-xl bg-card'>
|
||||
@@ -334,7 +336,7 @@ export function SettingsTab() {
|
||||
style={{ animationDuration: '3s' }}
|
||||
/>
|
||||
<span className='text-xs font-medium'>
|
||||
暂无自定义通道配置,请点击右上角新建
|
||||
{t('noCustomChannels')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -343,19 +345,19 @@ export function SettingsTab() {
|
||||
<TableHeader className='sticky top-0 z-20 bg-background'>
|
||||
<TableRow className='border-b border-dashed hover:bg-transparent'>
|
||||
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
|
||||
名称
|
||||
{t('colName')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] whitespace-nowrap py-2 h-8'>
|
||||
类型
|
||||
{t('colType')}
|
||||
</TableHead>
|
||||
<TableHead className='whitespace-nowrap py-2 h-8'>
|
||||
备注
|
||||
{t('colRemark')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
|
||||
状态
|
||||
{t('colStatus')}
|
||||
</TableHead>
|
||||
<TableHead className='sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8'>
|
||||
操作
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -380,7 +382,9 @@ export function SettingsTab() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-muted-foreground max-w-[200px] truncate py-1'>
|
||||
{ch.description || <span className='italic'>无备注</span>}
|
||||
{ch.description || (
|
||||
<span className='italic'>{t('noRemark')}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-center py-1'
|
||||
@@ -416,7 +420,7 @@ export function SettingsTab() {
|
||||
className='h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10'
|
||||
>
|
||||
<Play className='size-2.5 mr-1' />
|
||||
测试
|
||||
{t('test')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -425,7 +429,7 @@ export function SettingsTab() {
|
||||
className='h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<Edit2 className='size-2.5 mr-1' />
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -435,7 +439,7 @@ export function SettingsTab() {
|
||||
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
|
||||
>
|
||||
<Trash2 className='size-2.5 mr-1' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -451,35 +455,34 @@ export function SettingsTab() {
|
||||
<DialogContent className='sm:max-w-[600px] max-h-[85vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingChannel ? '编辑消息通道' : '新建消息通道'}
|
||||
{editingChannel
|
||||
? t('editChannelDialogTitle')
|
||||
: t('createChannelDialogTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook
|
||||
或推送服务投递数据
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t('channelDialogDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>名称</Label>
|
||||
<Label className='text-xs font-semibold'>{t('channelName')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一'
|
||||
placeholder={t('channelNamePlaceholder')}
|
||||
value={channelName}
|
||||
onChange={(e) => setChannelName(e.target.value)}
|
||||
disabled={!!editingChannel}
|
||||
className='text-xs h-9 font-mono'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
通道唯一标识,创建后不可修改
|
||||
{t('channelNameHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>备注</Label>
|
||||
<Label className='text-xs font-semibold'>{t('channelRemark')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='请输入备注信息'
|
||||
placeholder={t('channelRemarkPlaceholder')}
|
||||
value={channelDescription}
|
||||
onChange={(e) => setChannelDescription(e.target.value)}
|
||||
className='text-xs h-9'
|
||||
@@ -487,13 +490,13 @@ export function SettingsTab() {
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs font-semibold'>通道类型</Label>
|
||||
<Label className='text-xs font-semibold'>{t('channelType')}</Label>
|
||||
<Select
|
||||
value={channelType}
|
||||
onValueChange={handleChannelTypeChange}
|
||||
>
|
||||
<SelectTrigger className='text-xs h-9'>
|
||||
<SelectValue placeholder='选择通道类型' />
|
||||
<SelectValue placeholder={t('selectChannelType')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(definitionsQuery.data ?? []).map((d) => (
|
||||
@@ -509,7 +512,7 @@ export function SettingsTab() {
|
||||
<>
|
||||
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-1.5'>
|
||||
<div className='text-xs font-semibold'>
|
||||
{activeDef.name}配置说明
|
||||
{t('configHelp', { name: activeDef.name })}
|
||||
</div>
|
||||
<p className='text-[11px] text-muted-foreground leading-relaxed'>
|
||||
{activeDef.description}
|
||||
@@ -572,7 +575,7 @@ export function SettingsTab() {
|
||||
{channelType === 'custom' && (
|
||||
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-2.5'>
|
||||
<Label className='text-[11px] font-semibold'>
|
||||
快捷加载常用模版实例:
|
||||
{t('quickLoadTemplates')}
|
||||
</Label>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
<Button
|
||||
@@ -596,7 +599,7 @@ export function SettingsTab() {
|
||||
2,
|
||||
),
|
||||
);
|
||||
toast.success('已加载飞书 Webhook 模版');
|
||||
toast.success(t('loadedFeishuTemplate'));
|
||||
}}
|
||||
>
|
||||
飞书 Webhook
|
||||
@@ -623,7 +626,7 @@ export function SettingsTab() {
|
||||
2,
|
||||
),
|
||||
);
|
||||
toast.success('已加载钉钉机器人模版');
|
||||
toast.success(t('loadedDingTalkTemplate'));
|
||||
}}
|
||||
>
|
||||
钉钉群机器人
|
||||
@@ -650,7 +653,7 @@ export function SettingsTab() {
|
||||
2,
|
||||
),
|
||||
);
|
||||
toast.success('已加载企业微信机器人模版');
|
||||
toast.success(t('loadedWeChatTemplate'));
|
||||
}}
|
||||
>
|
||||
企业微信群机器人
|
||||
@@ -674,7 +677,7 @@ export function SettingsTab() {
|
||||
2,
|
||||
),
|
||||
);
|
||||
toast.success('已加载 Bark App 模版');
|
||||
toast.success(t('loadedBarkTemplate'));
|
||||
}}
|
||||
>
|
||||
Bark App
|
||||
@@ -693,7 +696,7 @@ export function SettingsTab() {
|
||||
onClick={() => setChannelDialogOpen(false)}
|
||||
className='h-9 text-xs'
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='default'
|
||||
@@ -709,7 +712,7 @@ export function SettingsTab() {
|
||||
updateChannelMutation.isPending) && (
|
||||
<Loader2 className='size-3 animate-spin mr-1' />
|
||||
)}
|
||||
确定
|
||||
{t('confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -719,15 +722,15 @@ export function SettingsTab() {
|
||||
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
|
||||
<DialogContent className='sm:max-w-[450px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送测试通知</DialogTitle>
|
||||
<DialogTitle>{t('sendTestNotification')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
|
||||
{t('sendTestNotificationDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4 py-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>推送通道名称</Label>
|
||||
<Label className='text-xs'>{t('pushChannelName')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
value={testChannelName}
|
||||
@@ -736,10 +739,10 @@ export function SettingsTab() {
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-xs'>测试推送目标 (对应模板变量 $to)</Label>
|
||||
<Label className='text-xs'>{t('testPushTarget')}</Label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='请输入测试推送接收人/目标标识,如 Bark Token、邮箱等'
|
||||
placeholder={t('testPushTargetPlaceholder')}
|
||||
value={testChannelTarget}
|
||||
onChange={(e) => setTestChannelTarget(e.target.value)}
|
||||
className='text-xs h-9'
|
||||
@@ -754,7 +757,7 @@ export function SettingsTab() {
|
||||
onClick={() => setTestChannelOpen(false)}
|
||||
className='h-9 text-xs'
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='default'
|
||||
@@ -766,7 +769,7 @@ export function SettingsTab() {
|
||||
{isTestingChannel && (
|
||||
<Loader2 className='size-3 animate-spin mr-1' />
|
||||
)}
|
||||
发送测试
|
||||
{t('sendTest')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -778,14 +781,14 @@ export function SettingsTab() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除通道</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteChannelTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除通道「{deleteTarget?.name}」吗?删除后无法恢复。
|
||||
{t('deleteChannelConfirm', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteChannelMutation.isPending}>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
@@ -793,7 +796,9 @@ export function SettingsTab() {
|
||||
deleteTarget && deleteChannelMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteChannelMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteChannelMutation.isPending
|
||||
? t('deleting')
|
||||
: t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Bell, History, Layers, Settings } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { EventsTab } from './components/events-tab';
|
||||
@@ -13,6 +14,7 @@ import { HistoriesTab } from './components/histories-tab';
|
||||
import { SettingsTab } from './components/settings-tab';
|
||||
|
||||
export default function PushAdminPage() {
|
||||
const t = useTranslations('admin.push');
|
||||
const [activeTab, setActiveTab] = React.useState('events');
|
||||
|
||||
return (
|
||||
@@ -26,7 +28,7 @@ export default function PushAdminPage() {
|
||||
<Bell className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
通知推送管理
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,21 +40,21 @@ export default function PushAdminPage() {
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
<Layers className='size-3.5 mr-1' />
|
||||
事件管理
|
||||
{t('eventsTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='histories'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
<History className='size-3.5 mr-1' />
|
||||
通知历史
|
||||
{t('historiesTab')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
<Settings className='size-3.5 mr-1' />
|
||||
通道管理与设置
|
||||
{t('settingsTab')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -89,11 +89,18 @@ export function mapOptionsToOpsFields(
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDurationLabel(value: string) {
|
||||
export type OpsMessage = (
|
||||
key: string,
|
||||
values?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function formatDurationLabel(value: string, t: OpsMessage) {
|
||||
const milliseconds = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(milliseconds)) return value;
|
||||
if (milliseconds >= 60000) return `${milliseconds / 60000} 分钟`;
|
||||
return `${milliseconds / 1000} 秒`;
|
||||
if (milliseconds >= 60000) {
|
||||
return t('duration.minutes', { count: milliseconds / 60000 });
|
||||
}
|
||||
return t('duration.seconds', { count: milliseconds / 1000 });
|
||||
}
|
||||
|
||||
export function normalizeServerUrl(value: string) {
|
||||
@@ -116,21 +123,24 @@ export function buildDiscoveryCommand(
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function validateAgentFields(fields: OpenFlareOpsFields) {
|
||||
export function validateAgentFields(fields: OpenFlareOpsFields, t: OpsMessage) {
|
||||
const heartbeat = Number.parseInt(fields.agent_heartbeat_interval, 10);
|
||||
const offline = Number.parseInt(fields.node_offline_threshold, 10);
|
||||
if (Number.isNaN(heartbeat) || heartbeat < 1000) {
|
||||
throw new Error('心跳间隔不能小于 1000 毫秒。');
|
||||
throw new Error(t('errors.heartbeatMin'));
|
||||
}
|
||||
if (Number.isNaN(offline) || offline < 10000) {
|
||||
throw new Error('离线阈值不能小于 10000 毫秒。');
|
||||
throw new Error(t('errors.offlineMin'));
|
||||
}
|
||||
if (offline < heartbeat * 3) {
|
||||
throw new Error('离线阈值建议至少为心跳间隔的 3 倍。');
|
||||
throw new Error(t('errors.offlineRatio'));
|
||||
}
|
||||
}
|
||||
|
||||
export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
|
||||
export function validateUptimeKumaFields(
|
||||
fields: OpenFlareOpsFields,
|
||||
t: OpsMessage,
|
||||
) {
|
||||
const syncInt = Number.parseInt(fields.uptime_kuma_sync_interval, 10);
|
||||
const interval = Number.parseInt(fields.uptime_kuma_interval, 10);
|
||||
const retry = Number.parseInt(fields.uptime_kuma_retry, 10);
|
||||
@@ -138,25 +148,26 @@ export function validateUptimeKumaFields(fields: OpenFlareOpsFields) {
|
||||
const timeout = Number.parseInt(fields.uptime_kuma_timeout, 10);
|
||||
|
||||
if (fields.uptime_kuma_enabled) {
|
||||
if (!fields.uptime_kuma_url.trim())
|
||||
throw new Error('请输入 Uptime Kuma 地址。');
|
||||
if (!fields.uptime_kuma_url.trim()) throw new Error(t('errors.kumaUrl'));
|
||||
if (!fields.uptime_kuma_username.trim())
|
||||
throw new Error('请输入 Uptime Kuma 用户名。');
|
||||
throw new Error(t('errors.kumaUser'));
|
||||
}
|
||||
if (Number.isNaN(syncInt) || syncInt <= 0)
|
||||
throw new Error('同步间隔必须为正整数。');
|
||||
throw new Error(t('errors.syncInterval'));
|
||||
if (Number.isNaN(interval) || interval <= 0)
|
||||
throw new Error('心跳间隔必须为正整数。');
|
||||
if (Number.isNaN(retry) || retry < 0)
|
||||
throw new Error('重试次数必须为非负整数。');
|
||||
throw new Error(t('errors.interval'));
|
||||
if (Number.isNaN(retry) || retry < 0) throw new Error(t('errors.retry'));
|
||||
if (Number.isNaN(retryInt) || retryInt <= 0)
|
||||
throw new Error('心跳重试间隔必须为正整数。');
|
||||
throw new Error(t('errors.retryInterval'));
|
||||
if (Number.isNaN(timeout) || timeout <= 0)
|
||||
throw new Error('请求超时必须为正整数。');
|
||||
throw new Error(t('errors.timeout'));
|
||||
}
|
||||
|
||||
export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validateAgentFields(fields);
|
||||
export function agentOptionEntries(
|
||||
fields: OpenFlareOpsFields,
|
||||
t: OpsMessage,
|
||||
): OptionItem[] {
|
||||
validateAgentFields(fields, t);
|
||||
return [
|
||||
{ key: 'agent_heartbeat_interval', value: fields.agent_heartbeat_interval },
|
||||
{
|
||||
@@ -171,8 +182,9 @@ export function agentOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
|
||||
export function uptimeKumaOptionEntries(
|
||||
fields: OpenFlareOpsFields,
|
||||
t: OpsMessage,
|
||||
): OptionItem[] {
|
||||
validateUptimeKumaFields(fields);
|
||||
validateUptimeKumaFields(fields, t);
|
||||
return [
|
||||
{ key: 'uptime_kuma_enabled', value: String(fields.uptime_kuma_enabled) },
|
||||
{ key: 'uptime_kuma_url', value: fields.uptime_kuma_url.trim() },
|
||||
@@ -200,21 +212,22 @@ export function uptimeKumaOptionEntries(
|
||||
];
|
||||
}
|
||||
|
||||
export function validatePagesFields(fields: OpenFlareOpsFields) {
|
||||
export function validatePagesFields(fields: OpenFlareOpsFields, t: OpsMessage) {
|
||||
const packageSize = Number.parseInt(fields.pages_max_package_size_mb, 10);
|
||||
const historyCount = Number.parseInt(fields.pages_max_history_count, 10);
|
||||
if (Number.isNaN(packageSize) || packageSize < 1 || packageSize > 2048) {
|
||||
throw new Error('Pages 部署包大小上限必须为 1~2048 MiB。');
|
||||
throw new Error(t('errors.pagesSize'));
|
||||
}
|
||||
if (Number.isNaN(historyCount) || historyCount < 0) {
|
||||
throw new Error(
|
||||
'Pages 历史保留数必须为大于等于 0 的整数(0 表示不限制)。',
|
||||
);
|
||||
throw new Error(t('errors.pagesHistory'));
|
||||
}
|
||||
}
|
||||
|
||||
export function pagesOptionEntries(fields: OpenFlareOpsFields): OptionItem[] {
|
||||
validatePagesFields(fields);
|
||||
export function pagesOptionEntries(
|
||||
fields: OpenFlareOpsFields,
|
||||
t: OpsMessage,
|
||||
): OptionItem[] {
|
||||
validatePagesFields(fields, t);
|
||||
return [
|
||||
{
|
||||
key: 'pages_max_package_size_mb',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ async function copyText(value: string) {
|
||||
}
|
||||
|
||||
export function OpenFlareOpsSettings() {
|
||||
const t = useTranslations('openflareOps');
|
||||
const queryClient = useQueryClient();
|
||||
const [fields, setFields] = useState<OpenFlareOpsFields>(
|
||||
defaultOpenFlareOpsFields,
|
||||
@@ -113,20 +115,20 @@ export function OpenFlareOpsSettings() {
|
||||
await OptionService.updateBatch(entries);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('OpenFlare 运维设置已保存');
|
||||
toast.success(t('saved'));
|
||||
await queryClient.invalidateQueries({ queryKey: optionsQueryKey });
|
||||
setSavingSection(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
setSavingSection(null);
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
toast.error(error instanceof Error ? error.message : t('saveFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: () => NodeService.rotateBootstrapToken(),
|
||||
onSuccess: async (data) => {
|
||||
toast.success('Discovery Token 已重新生成');
|
||||
toast.success(t('tokenRotated'));
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'bootstrap-token'],
|
||||
});
|
||||
@@ -139,15 +141,17 @@ export function OpenFlareOpsSettings() {
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'Token 轮换失败');
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('tokenRotateFailed'),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const syncUptimeKumaMutation = useMutation({
|
||||
mutationFn: () => UptimeKumaService.sync(),
|
||||
onSuccess: () => toast.success('Uptime Kuma 同步任务已执行'),
|
||||
onSuccess: () => toast.success(t('kumaSynced')),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '同步失败'),
|
||||
toast.error(error instanceof Error ? error.message : t('syncFailed')),
|
||||
});
|
||||
|
||||
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
|
||||
@@ -167,10 +171,10 @@ export function OpenFlareOpsSettings() {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
section: 'agent',
|
||||
entries: agentOptionEntries(fields),
|
||||
entries: agentOptionEntries(fields, t),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '参数校验失败');
|
||||
toast.error(error instanceof Error ? error.message : t('invalidParams'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,10 +182,10 @@ export function OpenFlareOpsSettings() {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
section: 'uptimekuma',
|
||||
entries: uptimeKumaOptionEntries(fields),
|
||||
entries: uptimeKumaOptionEntries(fields, t),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '参数校验失败');
|
||||
toast.error(error instanceof Error ? error.message : t('invalidParams'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,19 +193,16 @@ export function OpenFlareOpsSettings() {
|
||||
try {
|
||||
saveMutation.mutate({
|
||||
section: 'pages',
|
||||
entries: pagesOptionEntries(fields),
|
||||
entries: pagesOptionEntries(fields, t),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '参数校验失败');
|
||||
toast.error(error instanceof Error ? error.message : t('invalidParams'));
|
||||
}
|
||||
};
|
||||
|
||||
if (optionsQuery.isLoading) {
|
||||
return (
|
||||
<LoadingStateWithBorder
|
||||
icon={Server}
|
||||
description='加载 OpenFlare 运维设置...'
|
||||
/>
|
||||
<LoadingStateWithBorder icon={Server} description={t('loading')} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,7 +212,7 @@ export function OpenFlareOpsSettings() {
|
||||
message={
|
||||
optionsQuery.error instanceof Error
|
||||
? optionsQuery.error.message
|
||||
: '加载失败'
|
||||
: t('loadFailed')
|
||||
}
|
||||
onRetry={() => void optionsQuery.refetch()}
|
||||
/>
|
||||
@@ -224,10 +225,8 @@ export function OpenFlareOpsSettings() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>Agent 运行参数</CardTitle>
|
||||
<CardDescription>
|
||||
心跳间隔与离线阈值会在下个心跳周期同步到节点。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base'>{t('agent.title')}</CardTitle>
|
||||
<CardDescription>{t('agent.description')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -239,13 +238,18 @@ export function OpenFlareOpsSettings() {
|
||||
) : (
|
||||
<Save className='size-3.5 mr-1' />
|
||||
)}
|
||||
保存
|
||||
{t('save')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldInput
|
||||
label={`心跳间隔 (${formatDurationLabel(fields.agent_heartbeat_interval)})`}
|
||||
label={t('agent.heartbeat', {
|
||||
duration: formatDurationLabel(
|
||||
fields.agent_heartbeat_interval,
|
||||
t,
|
||||
),
|
||||
})}
|
||||
value={fields.agent_heartbeat_interval}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
@@ -253,7 +257,12 @@ export function OpenFlareOpsSettings() {
|
||||
}
|
||||
/>
|
||||
<FieldInput
|
||||
label={`离线阈值 (${formatDurationLabel(fields.node_offline_threshold)})`}
|
||||
label={t('agent.offline', {
|
||||
duration: formatDurationLabel(
|
||||
fields.node_offline_threshold,
|
||||
t,
|
||||
),
|
||||
})}
|
||||
value={fields.node_offline_threshold}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
@@ -262,15 +271,15 @@ export function OpenFlareOpsSettings() {
|
||||
/>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label='开启 WS 连接升级'
|
||||
description='HTTP 心跳成功后尝试升级为 WebSocket,配置发布可即时通知。'
|
||||
label={t('agent.wsUpgrade')}
|
||||
description={t('agent.wsUpgradeDesc')}
|
||||
checked={fields.agent_websocket_upgrade_enabled}
|
||||
onChange={(value) =>
|
||||
updateField('agent_websocket_upgrade_enabled', value)
|
||||
}
|
||||
/>
|
||||
<FieldInput
|
||||
label='Agent 更新仓库'
|
||||
label={t('agent.updateRepo')}
|
||||
value={fields.agent_update_repo}
|
||||
placeholder='Rain-kl/OpenFlare'
|
||||
onChange={(value) => updateField('agent_update_repo', value)}
|
||||
@@ -281,11 +290,8 @@ export function OpenFlareOpsSettings() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>IP 归属解析</CardTitle>
|
||||
<CardDescription>
|
||||
控制节点地图等场景的 GeoIP 来源;访客访问记录归属地固定使用
|
||||
MaxMind mmdb。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base'>{t('geo.title')}</CardTitle>
|
||||
<CardDescription>{t('geo.description')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -297,12 +303,12 @@ export function OpenFlareOpsSettings() {
|
||||
) : (
|
||||
<Save className='size-3.5 mr-1' />
|
||||
)}
|
||||
保存
|
||||
{t('save')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>归属方式</Label>
|
||||
<Label>{t('geo.mode')}</Label>
|
||||
<Select
|
||||
value={fields.geoip_provider}
|
||||
onValueChange={(value) => updateField('geoip_provider', value)}
|
||||
@@ -311,7 +317,7 @@ export function OpenFlareOpsSettings() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='disabled'>关闭</SelectItem>
|
||||
<SelectItem value='disabled'>{t('geo.disabled')}</SelectItem>
|
||||
<SelectItem value='mmdb'>MaxMind mmdb</SelectItem>
|
||||
<SelectItem value='ip-api'>ip-api.com</SelectItem>
|
||||
<SelectItem value='geojs'>geojs.io</SelectItem>
|
||||
@@ -321,7 +327,7 @@ export function OpenFlareOpsSettings() {
|
||||
</div>
|
||||
<div className='flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-end'>
|
||||
<FieldInput
|
||||
label='测试 IP'
|
||||
label={t('geo.testIp')}
|
||||
value={geoIPTestIP}
|
||||
onChange={setGeoIPTestIP}
|
||||
placeholder='8.8.8.8'
|
||||
@@ -332,13 +338,13 @@ export function OpenFlareOpsSettings() {
|
||||
disabled={geoIPMutation.isPending}
|
||||
onClick={() => geoIPMutation.mutate()}
|
||||
>
|
||||
{geoIPMutation.isPending ? '查询中...' : '查询归属'}
|
||||
{geoIPMutation.isPending ? t('geo.querying') : t('geo.query')}
|
||||
</Button>
|
||||
</div>
|
||||
{geoIPMutation.data ? (
|
||||
<div className='grid gap-2 text-sm sm:grid-cols-2'>
|
||||
<InfoCell
|
||||
label='国家/地区'
|
||||
label={t('geo.country')}
|
||||
value={geoIPMutation.data.name || '—'}
|
||||
/>
|
||||
<InfoCell
|
||||
@@ -354,10 +360,8 @@ export function OpenFlareOpsSettings() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>Pages 静态托管</CardTitle>
|
||||
<CardDescription>
|
||||
配置部署包上传体积上限与每个项目的历史部署保留数量。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base'>{t('pages.title')}</CardTitle>
|
||||
<CardDescription>{t('pages.description')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -369,13 +373,13 @@ export function OpenFlareOpsSettings() {
|
||||
) : (
|
||||
<Save className='size-3.5 mr-1' />
|
||||
)}
|
||||
保存
|
||||
{t('save')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldInput
|
||||
label='部署包大小上限 (MiB)'
|
||||
label={t('pages.maxSize')}
|
||||
value={fields.pages_max_package_size_mb}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
@@ -384,7 +388,7 @@ export function OpenFlareOpsSettings() {
|
||||
placeholder='100'
|
||||
/>
|
||||
<FieldInput
|
||||
label='历史部署保留数(0 不限制)'
|
||||
label={t('pages.history')}
|
||||
value={fields.pages_max_history_count}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
@@ -394,9 +398,7 @@ export function OpenFlareOpsSettings() {
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
每个项目最多保留 N
|
||||
条部署:激活部署始终保留,其余按从新到旧填充;超出的非激活部署会在上传成功后自动清理。支持
|
||||
zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式。
|
||||
{t('pages.historyHint')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -404,17 +406,16 @@ export function OpenFlareOpsSettings() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>Discovery Token 与部署</CardTitle>
|
||||
<CardDescription>
|
||||
新节点首次接入使用 Discovery
|
||||
Token;轮换请前往节点管理或使用下方操作。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base'>
|
||||
{t('discovery.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('discovery.description')}</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href='/nodes'>
|
||||
<ExternalLink className='size-3.5 mr-1' />
|
||||
节点管理
|
||||
{t('discovery.manageNodes')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -424,7 +425,7 @@ export function OpenFlareOpsSettings() {
|
||||
onClick={() => rotateTokenMutation.mutate()}
|
||||
>
|
||||
<RotateCw className='size-3.5 mr-1' />
|
||||
轮换 Token
|
||||
{t('discovery.rotate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -437,21 +438,19 @@ export function OpenFlareOpsSettings() {
|
||||
/>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
Discovery Token(只读)
|
||||
{t('discovery.tokenReadonly')}
|
||||
</p>
|
||||
<p className='mt-2 break-all text-sm font-mono'>
|
||||
{bootstrapQuery.isLoading
|
||||
? '加载中...'
|
||||
: discoveryToken || '未生成'}
|
||||
? t('discovery.loading')
|
||||
: discoveryToken || t('discovery.notGenerated')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>一键部署命令</Label>
|
||||
<Label>{t('discovery.command')}</Label>
|
||||
<Textarea
|
||||
readOnly
|
||||
value={
|
||||
discoveryCommand || '请先填写可访问的 Server URL 并获取 Token。'
|
||||
}
|
||||
value={discoveryCommand || t('discovery.commandHint')}
|
||||
className='min-h-24 font-mono text-xs'
|
||||
/>
|
||||
{discoveryCommand ? (
|
||||
@@ -461,12 +460,12 @@ export function OpenFlareOpsSettings() {
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
void copyText(discoveryCommand).then(() =>
|
||||
toast.success('命令已复制'),
|
||||
toast.success(t('discovery.copied')),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className='size-3.5 mr-1' />
|
||||
复制命令
|
||||
{t('discovery.copy')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -476,10 +475,8 @@ export function OpenFlareOpsSettings() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>Uptime Kuma 集成</CardTitle>
|
||||
<CardDescription>
|
||||
将反代站点差分同步至 Uptime Kuma 监控实例。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base'>{t('kuma.title')}</CardTitle>
|
||||
<CardDescription>{t('kuma.description')}</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
@@ -491,20 +488,20 @@ export function OpenFlareOpsSettings() {
|
||||
onClick={() => syncUptimeKumaMutation.mutate()}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
立即同步
|
||||
{t('kuma.syncNow')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
disabled={savingSection === 'uptimekuma'}
|
||||
onClick={saveUptimeKumaSettings}
|
||||
>
|
||||
保存
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<ToggleRow
|
||||
label='开启 Uptime Kuma'
|
||||
label={t('kuma.enable')}
|
||||
checked={fields.uptime_kuma_enabled}
|
||||
onChange={(value) => updateField('uptime_kuma_enabled', value)}
|
||||
/>
|
||||
@@ -512,29 +509,29 @@ export function OpenFlareOpsSettings() {
|
||||
<>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldInput
|
||||
label='Uptime Kuma 地址'
|
||||
label={t('kuma.url')}
|
||||
value={fields.uptime_kuma_url}
|
||||
onChange={(value) => updateField('uptime_kuma_url', value)}
|
||||
placeholder='http://localhost:3001'
|
||||
/>
|
||||
<FieldInput
|
||||
label='用户名'
|
||||
label={t('kuma.username')}
|
||||
value={fields.uptime_kuma_username}
|
||||
onChange={(value) =>
|
||||
updateField('uptime_kuma_username', value)
|
||||
}
|
||||
/>
|
||||
<FieldInput
|
||||
label='密码'
|
||||
label={t('kuma.password')}
|
||||
value={fields.uptime_kuma_password}
|
||||
type='password'
|
||||
onChange={(value) =>
|
||||
updateField('uptime_kuma_password', value)
|
||||
}
|
||||
placeholder='留空表示不更新'
|
||||
placeholder={t('kuma.passwordKeep')}
|
||||
/>
|
||||
<FieldInput
|
||||
label='同步间隔 (分钟)'
|
||||
label={t('kuma.syncInterval')}
|
||||
value={fields.uptime_kuma_sync_interval}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
@@ -542,7 +539,7 @@ export function OpenFlareOpsSettings() {
|
||||
}
|
||||
/>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>监控范围</Label>
|
||||
<Label>{t('kuma.scope')}</Label>
|
||||
<Select
|
||||
value={fields.uptime_kuma_monitor_scope}
|
||||
onValueChange={(value) =>
|
||||
@@ -553,8 +550,10 @@ export function OpenFlareOpsSettings() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>全部站点</SelectItem>
|
||||
<SelectItem value='selected'>选择站点</SelectItem>
|
||||
<SelectItem value='all'>{t('kuma.allSites')}</SelectItem>
|
||||
<SelectItem value='selected'>
|
||||
{t('kuma.selectedSites')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -562,26 +561,28 @@ export function OpenFlareOpsSettings() {
|
||||
{fields.uptime_kuma_monitor_scope === 'selected' ? (
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<p className='text-sm font-medium'>已选站点</p>
|
||||
<p className='text-sm font-medium'>
|
||||
{t('kuma.selectedLabel')}
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setUptimeKumaModalOpen(true)}
|
||||
>
|
||||
选择监控站点
|
||||
{t('kuma.pickSites')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='mt-2 break-all text-xs text-muted-foreground'>
|
||||
{fields.uptime_kuma_selected_sites
|
||||
? fields.uptime_kuma_selected_sites.split(',').join(', ')
|
||||
: '未选择任何站点'}
|
||||
: t('kuma.noneSelected')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<FieldInput
|
||||
label='检测频率 (秒)'
|
||||
label={t('kuma.interval')}
|
||||
value={fields.uptime_kuma_interval}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
@@ -589,13 +590,13 @@ export function OpenFlareOpsSettings() {
|
||||
}
|
||||
/>
|
||||
<FieldInput
|
||||
label='重试次数'
|
||||
label={t('kuma.retry')}
|
||||
value={fields.uptime_kuma_retry}
|
||||
type='number'
|
||||
onChange={(value) => updateField('uptime_kuma_retry', value)}
|
||||
/>
|
||||
<FieldInput
|
||||
label='重试间隔 (秒)'
|
||||
label={t('kuma.retryInterval')}
|
||||
value={fields.uptime_kuma_retry_interval}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
@@ -603,7 +604,7 @@ export function OpenFlareOpsSettings() {
|
||||
}
|
||||
/>
|
||||
<FieldInput
|
||||
label='请求超时 (秒)'
|
||||
label={t('kuma.timeout')}
|
||||
value={fields.uptime_kuma_timeout}
|
||||
type='number'
|
||||
onChange={(value) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ const formatNumber = (num: number | string) => {
|
||||
* 运行时系统状态展示与管理组件
|
||||
*/
|
||||
export function SystemStatusManager() {
|
||||
const t = useTranslations('openflareOps.status');
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [wavelet, setWavelet] = useState(false);
|
||||
@@ -82,14 +84,14 @@ export function SystemStatusManager() {
|
||||
setStatus(data);
|
||||
prevStatusRef.current = data;
|
||||
} catch (err) {
|
||||
toast.error('获取系统状态失败', {
|
||||
description: err instanceof Error ? err.message : '请求时发生未知错误',
|
||||
toast.error(t('loadFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setWavelet(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
// 轮询逻辑
|
||||
useEffect(() => {
|
||||
@@ -196,7 +198,9 @@ export function SystemStatusManager() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<Activity className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>系统状态</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -211,7 +215,7 @@ export function SystemStatusManager() {
|
||||
htmlFor='auto-refresh'
|
||||
className='text-xs font-medium cursor-pointer select-none'
|
||||
>
|
||||
自动刷新
|
||||
{t('autoRefresh')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -237,7 +241,7 @@ export function SystemStatusManager() {
|
||||
disabled={wavelet}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,10 +255,10 @@ export function SystemStatusManager() {
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<div className='space-y-0.5'>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
服务概览
|
||||
{t('overview')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[10px]'>
|
||||
服务的基础生命指标
|
||||
{t('overviewDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -266,17 +270,17 @@ export function SystemStatusManager() {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-1'>
|
||||
<RenderMetricRow
|
||||
label='服务运行时间'
|
||||
label={t('uptime')}
|
||||
value={status.uptime}
|
||||
field='uptime'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='当前 Goroutines 数量'
|
||||
label={t('goroutines')}
|
||||
value={status.num_goroutine}
|
||||
field='num_goroutine'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='Heap 对象数量'
|
||||
label={t('heapObjects')}
|
||||
value={status.heap_objects}
|
||||
field='heap_objects'
|
||||
/>
|
||||
@@ -288,10 +292,10 @@ export function SystemStatusManager() {
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<div className='space-y-0.5'>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
内存统计
|
||||
{t('memory')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[10px]'>
|
||||
主内存消耗概览
|
||||
{t('memoryDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -304,22 +308,22 @@ export function SystemStatusManager() {
|
||||
<CardContent className='space-y-3'>
|
||||
<div className='space-y-1'>
|
||||
<RenderMetricRow
|
||||
label='当前内存使用量'
|
||||
label={t('alloc')}
|
||||
value={status.alloc}
|
||||
field='alloc'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='所有已分配的内存'
|
||||
label={t('totalAlloc')}
|
||||
value={status.total_alloc}
|
||||
field='total_alloc'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='内存占用量'
|
||||
label={t('sys')}
|
||||
value={status.sys}
|
||||
field='sys'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='下次 GC 内存回收量'
|
||||
label={t('nextGc')}
|
||||
value={status.next_gc}
|
||||
field='next_gc'
|
||||
/>
|
||||
@@ -327,7 +331,7 @@ export function SystemStatusManager() {
|
||||
{/* 物理内存水位比例 */}
|
||||
<div className='space-y-1 px-1 pt-1'>
|
||||
<div className='flex justify-between text-[10px] text-muted-foreground'>
|
||||
<span>当前使用率 (Alloc / Sys)</span>
|
||||
<span>{t('usageRatio')}</span>
|
||||
<span className='font-mono'>
|
||||
{getMemoryUsagePercent()}%
|
||||
</span>
|
||||
@@ -342,10 +346,10 @@ export function SystemStatusManager() {
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<div className='space-y-0.5'>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
堆/栈详情
|
||||
{t('heapStack')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[10px]'>
|
||||
运行时堆栈内存空间细节
|
||||
{t('heapStackDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -357,37 +361,37 @@ export function SystemStatusManager() {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-1'>
|
||||
<RenderMetricRow
|
||||
label='当前 Heap 内存使用量'
|
||||
label={t('heapAlloc')}
|
||||
value={status.heap_alloc}
|
||||
field='heap_alloc'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='Heap 内存占用量'
|
||||
label={t('heapSys')}
|
||||
value={status.heap_sys}
|
||||
field='heap_sys'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='Heap 内存空闲量'
|
||||
label={t('heapIdle')}
|
||||
value={status.heap_idle}
|
||||
field='heap_idle'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='正在使用的 Heap 内存'
|
||||
label={t('heapInuse')}
|
||||
value={status.heap_inuse}
|
||||
field='heap_inuse'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='已释放的 Heap 内存'
|
||||
label={t('heapReleased')}
|
||||
value={status.heap_released}
|
||||
field='heap_released'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='启动 Stack 使用量'
|
||||
label={t('stackInuse')}
|
||||
value={status.stack_inuse}
|
||||
field='stack_inuse'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='已分配的 Stack 内存'
|
||||
label={t('stackSys')}
|
||||
value={status.stack_sys}
|
||||
field='stack_sys'
|
||||
/>
|
||||
@@ -399,10 +403,10 @@ export function SystemStatusManager() {
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<div className='space-y-0.5'>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
底层及结构体内存
|
||||
{t('structs')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[10px]'>
|
||||
运行时底层管理结构体开销
|
||||
{t('structsDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -414,37 +418,37 @@ export function SystemStatusManager() {
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-1'>
|
||||
<RenderMetricRow
|
||||
label='MSpan 结构内存使用量'
|
||||
label={t('mspanInuse')}
|
||||
value={status.mspan_inuse}
|
||||
field='mspan_inuse'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='已分配的 MSpan 结构内存'
|
||||
label={t('mspanSys')}
|
||||
value={status.mspan_sys}
|
||||
field='mspan_sys'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='MCache 结构内存使用量'
|
||||
label={t('mcacheInuse')}
|
||||
value={status.mcache_inuse}
|
||||
field='mcache_inuse'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='已分配的 MCache 结构内存'
|
||||
label={t('mcacheSys')}
|
||||
value={status.mcache_sys}
|
||||
field='mcache_sys'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='已分配的剖析哈希表内存'
|
||||
label={t('buckHashSys')}
|
||||
value={status.buck_hash_sys}
|
||||
field='buck_hash_sys'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='已分配的 GC 元数据内存'
|
||||
label={t('gcSys')}
|
||||
value={status.gc_sys}
|
||||
field='gc_sys'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='其它已分配的系统内存'
|
||||
label={t('otherSys')}
|
||||
value={status.other_sys}
|
||||
field='other_sys'
|
||||
/>
|
||||
@@ -456,10 +460,10 @@ export function SystemStatusManager() {
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<div className='space-y-0.5'>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
垃圾回收与分配计数
|
||||
{t('gc')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-[10px]'>
|
||||
GC 历史数据与分配频次
|
||||
{t('gcDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
@@ -473,39 +477,39 @@ export function SystemStatusManager() {
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'>
|
||||
<div>
|
||||
<RenderMetricRow
|
||||
label='GC 执行次数'
|
||||
label={t('numGc')}
|
||||
value={status.num_gc}
|
||||
field='num_gc'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='距离上次 GC 时间'
|
||||
label={t('lastGc')}
|
||||
value={status.last_gc_time}
|
||||
field='last_gc_time'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='GC 暂停时间总量'
|
||||
label={t('pauseTotal')}
|
||||
value={status.pause_total_ns}
|
||||
field='pause_total_ns'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='上次 GC 暂停时间'
|
||||
label={t('lastPause')}
|
||||
value={status.last_pause}
|
||||
field='last_pause'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<RenderMetricRow
|
||||
label='内存分配次数'
|
||||
label={t('mallocs')}
|
||||
value={status.mallocs}
|
||||
field='mallocs'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='内存释放次数'
|
||||
label={t('frees')}
|
||||
value={status.frees}
|
||||
field='frees'
|
||||
/>
|
||||
<RenderMetricRow
|
||||
label='指针查找次数'
|
||||
label={t('lookups')}
|
||||
value={status.lookups}
|
||||
field='lookups'
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -29,6 +30,8 @@ export function UptimeKumaSiteSelectModal({
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: UptimeKumaSiteSelectModalProps) {
|
||||
const t = useTranslations('openflareOps.siteModal');
|
||||
const tCommon = useTranslations('common');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -90,19 +93,17 @@ export function UptimeKumaSiteSelectModal({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择监控站点</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择要同步到 Uptime Kuma 的反代站点,支持按站点名称和域名搜索。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>搜索站点</Label>
|
||||
<Label>{t('search')}</Label>
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder='按名称或域名搜索...'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +114,7 @@ export function UptimeKumaSiteSelectModal({
|
||||
size='sm'
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
全选过滤项
|
||||
{t('selectFiltered')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -121,30 +122,32 @@ export function UptimeKumaSiteSelectModal({
|
||||
size='sm'
|
||||
onClick={handleDeselectAll}
|
||||
>
|
||||
清空过滤项
|
||||
{t('clearFiltered')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='max-h-72 overflow-y-auto rounded-lg border'>
|
||||
{routesQuery.isLoading ? (
|
||||
<p className='p-4 text-sm text-muted-foreground'>
|
||||
加载站点列表...
|
||||
{t('loading')}
|
||||
</p>
|
||||
) : routesQuery.isError ? (
|
||||
<p className='p-4 text-sm text-destructive'>
|
||||
{routesQuery.error instanceof Error
|
||||
? routesQuery.error.message
|
||||
: '加载站点失败'}
|
||||
: t('loadFailed')}
|
||||
</p>
|
||||
) : filteredRoutes.length === 0 ? (
|
||||
<p className='p-4 text-sm text-muted-foreground'>无匹配的站点</p>
|
||||
<p className='p-4 text-sm text-muted-foreground'>
|
||||
{t('noMatch')}
|
||||
</p>
|
||||
) : (
|
||||
<table className='w-full text-left text-sm'>
|
||||
<thead className='sticky top-0 bg-muted/60 text-xs uppercase text-muted-foreground'>
|
||||
<tr>
|
||||
<th className='w-12 px-3 py-2'>选择</th>
|
||||
<th className='px-3 py-2'>站点名称</th>
|
||||
<th className='px-3 py-2'>主域名</th>
|
||||
<th className='w-12 px-3 py-2'>{t('select')}</th>
|
||||
<th className='px-3 py-2'>{t('siteName')}</th>
|
||||
<th className='px-3 py-2'>{t('primaryDomain')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y'>
|
||||
@@ -184,7 +187,7 @@ export function UptimeKumaSiteSelectModal({
|
||||
</div>
|
||||
|
||||
<p className='text-right text-xs text-muted-foreground'>
|
||||
已选择 {tempSelected.size} 个监控站点
|
||||
{t('selectedCount', { count: tempSelected.size })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -194,7 +197,7 @@ export function UptimeKumaSiteSelectModal({
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -203,7 +206,7 @@ export function UptimeKumaSiteSelectModal({
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
保存选择
|
||||
{t('save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Loader2, Settings } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
@@ -79,6 +80,7 @@ function systemConfigMap(configs: SystemConfig[]) {
|
||||
}
|
||||
|
||||
export function AdminSettingsPageClient() {
|
||||
const t = useTranslations('admin.settings');
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -136,7 +138,9 @@ export function AdminSettingsPageClient() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<Settings className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>系统设置</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
@@ -149,40 +153,40 @@ export function AdminSettingsPageClient() {
|
||||
value='openflare-ops'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
OpenFlare
|
||||
{t('tabs.openflare')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='security'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
安全设置
|
||||
{t('tabs.security')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='operation'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
业务设置
|
||||
{t('tabs.operation')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='system'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
系统设置
|
||||
{t('tabs.system')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='other'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
其他设置
|
||||
{t('tabs.other')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='status'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
系统状态
|
||||
{t('tabs.status')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='info' className='px-0 pb-2 text-xs font-semibold'>
|
||||
系统信息
|
||||
{t('tabs.info')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
@@ -51,13 +52,14 @@ function SystemConfigDetailPanel({
|
||||
onSave: () => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations('admin.system');
|
||||
return (
|
||||
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
|
||||
<div className='grid grid-cols-1 gap-0'>
|
||||
<div className='border border-dashed rounded-lg'>
|
||||
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
配置键
|
||||
{t('configKey')}
|
||||
</label>
|
||||
<p className='text-xs text-muted-foreground font-mono'>
|
||||
{config?.key}
|
||||
@@ -66,7 +68,7 @@ function SystemConfigDetailPanel({
|
||||
|
||||
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
配置值
|
||||
{t('configValue')}
|
||||
</label>
|
||||
<div className='flex gap-1 w-[90%] justify-end items-center pr-3'>
|
||||
{config?.key.endsWith('_enabled') ? (
|
||||
@@ -107,7 +109,9 @@ function SystemConfigDetailPanel({
|
||||
: config?.value || ''
|
||||
}
|
||||
placeholder={
|
||||
editData.value === undefined && !config?.value ? '必需' : ''
|
||||
editData.value === undefined && !config?.value
|
||||
? t('required')
|
||||
: ''
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
@@ -128,7 +132,7 @@ function SystemConfigDetailPanel({
|
||||
|
||||
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
配置描述
|
||||
{t('configDescription')}
|
||||
</label>
|
||||
<div className='flex gap-1 w-[90%]'>
|
||||
<Input
|
||||
@@ -138,7 +142,7 @@ function SystemConfigDetailPanel({
|
||||
? editData.description
|
||||
: config?.description || ''
|
||||
}
|
||||
placeholder='可选描述'
|
||||
placeholder={t('optionalDescription')}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onEditDataChange('description', value);
|
||||
@@ -150,7 +154,7 @@ function SystemConfigDetailPanel({
|
||||
|
||||
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
公共可见
|
||||
{t('publicVisibility')}
|
||||
</label>
|
||||
<Switch
|
||||
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
|
||||
@@ -162,7 +166,7 @@ function SystemConfigDetailPanel({
|
||||
|
||||
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
配置类型
|
||||
{t('configType')}
|
||||
</label>
|
||||
<span
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
|
||||
@@ -171,13 +175,15 @@ function SystemConfigDetailPanel({
|
||||
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
|
||||
}`}
|
||||
>
|
||||
{config?.type === 'system' ? '系统配置' : '业务配置'}
|
||||
{config?.type === 'system'
|
||||
? t('systemConfig')
|
||||
: t('businessConfig')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='px-3 py-2 flex items-center justify-between'>
|
||||
<label className='text-xs font-medium text-muted-foreground'>
|
||||
创建时间
|
||||
{t('createdAt')}
|
||||
</label>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{config ? formatDateTime(config.created_at) : ''}
|
||||
@@ -199,6 +205,7 @@ function SystemConfigDetailPanel({
|
||||
* @returns {React.ReactNode} 系统配置管理组件
|
||||
*/
|
||||
export function SystemConfigs() {
|
||||
const t = useTranslations('admin.system');
|
||||
const {
|
||||
systemConfigs: configs,
|
||||
systemConfigsLoading: loading,
|
||||
@@ -236,7 +243,7 @@ export function SystemConfigs() {
|
||||
|
||||
return (
|
||||
<ManagePage<SystemConfig>
|
||||
title='系统配置'
|
||||
title={t('pageTitle')}
|
||||
icon={ShieldCheck}
|
||||
data={configs}
|
||||
loading={loading}
|
||||
@@ -245,8 +252,8 @@ export function SystemConfigs() {
|
||||
getInitialEditData={getInitialEditData}
|
||||
onSave={handleSave}
|
||||
getId={(config) => config.key}
|
||||
emptyDescription='未发现系统配置'
|
||||
loadingDescription='配置加载中'
|
||||
emptyDescription={t('emptyDescription')}
|
||||
loadingDescription={t('loadingDescription')}
|
||||
headerExtra={
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -255,24 +262,24 @@ export function SystemConfigs() {
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2 h-8'>
|
||||
<TabsTrigger value='business' className='text-[11px] h-7'>
|
||||
业务配置
|
||||
{t('businessConfig')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='system' className='text-[11px] h-7'>
|
||||
系统配置
|
||||
{t('systemConfig')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
header: '配置键',
|
||||
header: t('configKey'),
|
||||
cell: (item) => (
|
||||
<span className='font-mono font-medium'>{item.key}</span>
|
||||
),
|
||||
width: '200px',
|
||||
},
|
||||
{
|
||||
header: '配置值',
|
||||
header: t('configValue'),
|
||||
cell: (item) => (
|
||||
<span
|
||||
className='truncate max-w-[120px] inline-block'
|
||||
@@ -284,14 +291,14 @@ export function SystemConfigs() {
|
||||
width: '120px',
|
||||
},
|
||||
{
|
||||
header: '公共可见',
|
||||
header: t('publicVisibility'),
|
||||
cell: (item) => (
|
||||
<span>{item.visibility === 1 ? '可见' : '不可见'}</span>
|
||||
<span>{item.visibility === 1 ? t('visible') : t('invisible')}</span>
|
||||
),
|
||||
width: '80px',
|
||||
},
|
||||
{
|
||||
header: '描述',
|
||||
header: t('description'),
|
||||
cell: (item) => (
|
||||
<span
|
||||
className='truncate max-w-[200px] inline-block text-muted-foreground'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
@@ -49,18 +50,18 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
|
||||
pending: '等待中',
|
||||
running: '执行中',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
const STATUS_LABELS_KEYS: Record<TaskExecutionStatus, string> = {
|
||||
pending: 'statusPending',
|
||||
running: 'statusRunning',
|
||||
succeeded: 'statusSucceeded',
|
||||
failed: 'statusFailed',
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
system: '系统',
|
||||
manual: '手动',
|
||||
retry: '重试',
|
||||
schedule: '定时',
|
||||
const TRIGGER_LABELS_KEYS: Record<string, string> = {
|
||||
system: 'triggerSystem',
|
||||
manual: 'triggerManual',
|
||||
retry: 'triggerRetry',
|
||||
schedule: 'triggerSchedule',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -85,6 +86,7 @@ function statusVariant(status: TaskExecutionStatus) {
|
||||
}
|
||||
|
||||
export function TaskExecutionsManager() {
|
||||
const t = useTranslations('admin.tasks');
|
||||
const queryClient = useQueryClient();
|
||||
const [executionsPage, setExecutionsPage] = useState(1);
|
||||
const [executionStatus, setExecutionStatus] = useState<
|
||||
@@ -129,16 +131,16 @@ export function TaskExecutionsManager() {
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
|
||||
onSuccess: (taskID) => {
|
||||
toast.success('任务已重新下发', {
|
||||
description: `新任务 ID:${taskID}`,
|
||||
toast.success(t('retrySuccess'), {
|
||||
description: t('retrySuccessDesc', { taskId: taskID }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'task-executions'],
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error('任务重试失败', {
|
||||
description: err.message || '未知错误',
|
||||
toast.error(t('retryFailed'), {
|
||||
description: err.message || t('unknownError'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -187,14 +189,14 @@ export function TaskExecutionsManager() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger size='sm' className='w-[120px]'>
|
||||
<SelectValue placeholder='状态' />
|
||||
<SelectValue placeholder={t('colStatus')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>全部状态</SelectItem>
|
||||
<SelectItem value='pending'>等待中</SelectItem>
|
||||
<SelectItem value='running'>执行中</SelectItem>
|
||||
<SelectItem value='succeeded'>成功</SelectItem>
|
||||
<SelectItem value='failed'>失败</SelectItem>
|
||||
<SelectItem value='all'>{t('allStatuses')}</SelectItem>
|
||||
<SelectItem value='pending'>{t('statusPending')}</SelectItem>
|
||||
<SelectItem value='running'>{t('statusRunning')}</SelectItem>
|
||||
<SelectItem value='succeeded'>{t('statusSucceeded')}</SelectItem>
|
||||
<SelectItem value='failed'>{t('statusFailed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
@@ -202,10 +204,10 @@ export function TaskExecutionsManager() {
|
||||
onValueChange={handleTaskTypeChange}
|
||||
>
|
||||
<SelectTrigger size='sm' className='w-[180px]'>
|
||||
<SelectValue placeholder='任务类型' />
|
||||
<SelectValue placeholder={t('taskTypeFilter')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>全部任务</SelectItem>
|
||||
<SelectItem value='all'>{t('allTasks')}</SelectItem>
|
||||
{taskTypes.map((task) => (
|
||||
<SelectItem key={task.type} value={task.type}>
|
||||
{task.name || task.type}
|
||||
@@ -224,7 +226,7 @@ export function TaskExecutionsManager() {
|
||||
) : (
|
||||
<RefreshCw className='size-4' />
|
||||
)}
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,22 +242,22 @@ export function TaskExecutionsManager() {
|
||||
) : executionsLoading && executions.length === 0 ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Activity}
|
||||
description='加载任务执行记录中...'
|
||||
description={t('loadingExecutions')}
|
||||
/>
|
||||
) : executions.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description='暂无任务执行记录' />
|
||||
<EmptyStateWithBorder icon={Activity} description={t('noExecutions')} />
|
||||
) : (
|
||||
<div className='rounded-lg border bg-card'>
|
||||
<Table className='min-w-[900px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[180px]'>任务</TableHead>
|
||||
<TableHead className='w-[100px]'>状态</TableHead>
|
||||
<TableHead className='w-[110px]'>触发</TableHead>
|
||||
<TableHead className='w-[120px]'>重试</TableHead>
|
||||
<TableHead className='w-[120px]'>耗时</TableHead>
|
||||
<TableHead className='min-w-[220px]'>结果/错误</TableHead>
|
||||
<TableHead className='w-[170px]'>创建时间</TableHead>
|
||||
<TableHead className='w-[180px]'>{t('colTask')}</TableHead>
|
||||
<TableHead className='w-[100px]'>{t('colStatus')}</TableHead>
|
||||
<TableHead className='w-[110px]'>{t('colTrigger')}</TableHead>
|
||||
<TableHead className='w-[120px]'>{t('colRetry')}</TableHead>
|
||||
<TableHead className='w-[120px]'>{t('colDuration')}</TableHead>
|
||||
<TableHead className='min-w-[220px]'>{t('colResult')}</TableHead>
|
||||
<TableHead className='w-[170px]'>{t('colCreatedAt')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -277,12 +279,13 @@ export function TaskExecutionsManager() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(execution.status)}>
|
||||
{STATUS_LABELS[execution.status] || execution.status}
|
||||
{t(STATUS_LABELS_KEYS[execution.status]) ||
|
||||
execution.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>
|
||||
{TRIGGER_LABELS[execution.triggered_by] ||
|
||||
{t(TRIGGER_LABELS_KEYS[execution.triggered_by]) ||
|
||||
execution.triggered_by}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -307,7 +310,11 @@ export function TaskExecutionsManager() {
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
|
||||
{t('paginationInfo', {
|
||||
total: executionsTotal,
|
||||
current: executionsPage,
|
||||
totalPages,
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
@@ -317,7 +324,7 @@ export function TaskExecutionsManager() {
|
||||
disabled={executionsPage <= 1 || executionsLoading}
|
||||
>
|
||||
<ChevronLeft className='size-4' />
|
||||
上一页
|
||||
{t('prevPage')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -327,7 +334,7 @@ export function TaskExecutionsManager() {
|
||||
}
|
||||
disabled={executionsPage >= totalPages || executionsLoading}
|
||||
>
|
||||
下一页
|
||||
{t('nextPage')}
|
||||
<ChevronRight className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -336,11 +343,11 @@ export function TaskExecutionsManager() {
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className='w-full p-0 sm:max-w-[640px]'>
|
||||
<SheetHeader className='border-b'>
|
||||
<SheetTitle>任务执行详情</SheetTitle>
|
||||
<SheetTitle>{t('executionDetailTitle')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selectedExecution?.task_name ||
|
||||
selectedExecution?.task_type ||
|
||||
'任务记录'}
|
||||
t('executionRecord')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -348,32 +355,35 @@ export function TaskExecutionsManager() {
|
||||
{detailLoading && !selectedExecution ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Activity}
|
||||
description='加载任务详情中...'
|
||||
description={t('loadingDetail')}
|
||||
/>
|
||||
) : selectedExecution ? (
|
||||
<div className='space-y-5 py-4'>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>状态</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('detailStatus')}
|
||||
</div>
|
||||
<div className='mt-2'>
|
||||
<Badge variant={statusVariant(selectedExecution.status)}>
|
||||
{STATUS_LABELS[selectedExecution.status] ||
|
||||
{t(STATUS_LABELS_KEYS[selectedExecution.status]) ||
|
||||
selectedExecution.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
触发来源
|
||||
{t('detailTrigger')}
|
||||
</div>
|
||||
<div className='mt-2 text-sm font-medium'>
|
||||
{TRIGGER_LABELS[selectedExecution.triggered_by] ||
|
||||
selectedExecution.triggered_by}
|
||||
{t(
|
||||
TRIGGER_LABELS_KEYS[selectedExecution.triggered_by],
|
||||
) || selectedExecution.triggered_by}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
重试次数
|
||||
{t('detailRetryCount')}
|
||||
</div>
|
||||
<div className='mt-2 font-mono text-sm'>
|
||||
{selectedExecution.retry_count}/
|
||||
@@ -381,7 +391,9 @@ export function TaskExecutionsManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>耗时</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('detailDuration')}
|
||||
</div>
|
||||
<div className='mt-2 font-mono text-sm'>
|
||||
{formatDuration(selectedExecution.duration)}
|
||||
</div>
|
||||
@@ -389,7 +401,7 @@ export function TaskExecutionsManager() {
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label>任务标识</Label>
|
||||
<Label>{t('detailTaskId')}</Label>
|
||||
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
|
||||
{selectedExecution.task_id}
|
||||
</div>
|
||||
@@ -397,25 +409,25 @@ export function TaskExecutionsManager() {
|
||||
|
||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>创建时间</Label>
|
||||
<Label>{t('detailCreatedAt')}</Label>
|
||||
<div className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>开始时间</Label>
|
||||
<Label>{t('detailStartedAt')}</Label>
|
||||
<div className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>结束时间</Label>
|
||||
<Label>{t('detailFinishedAt')}</Label>
|
||||
<div className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.finished_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>更新时间</Label>
|
||||
<Label>{t('detailUpdatedAt')}</Label>
|
||||
<div className='font-mono text-xs text-muted-foreground'>
|
||||
{formatDateTime(selectedExecution.updated_at)}
|
||||
</div>
|
||||
@@ -423,7 +435,7 @@ export function TaskExecutionsManager() {
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label>执行结果</Label>
|
||||
<Label>{t('detailResult')}</Label>
|
||||
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
|
||||
{selectedExecution.result || '-'}
|
||||
</div>
|
||||
@@ -431,7 +443,7 @@ export function TaskExecutionsManager() {
|
||||
|
||||
{selectedExecution.error_message && (
|
||||
<div className='grid gap-2'>
|
||||
<Label>错误信息</Label>
|
||||
<Label>{t('detailError')}</Label>
|
||||
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
|
||||
{selectedExecution.error_message}
|
||||
</div>
|
||||
@@ -439,23 +451,23 @@ export function TaskExecutionsManager() {
|
||||
)}
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label>Payload</Label>
|
||||
<Label>{t('detailPayload')}</Label>
|
||||
<pre className='max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
|
||||
{selectedExecution.payload || '{}'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label>执行日志</Label>
|
||||
<Label>{t('detailLog')}</Label>
|
||||
<pre className='min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap'>
|
||||
{selectedExecution.log || '暂无日志'}
|
||||
{selectedExecution.log || t('noLog')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateWithBorder
|
||||
icon={Activity}
|
||||
description='未选择任务记录'
|
||||
description={t('noExecutionSelected')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -471,7 +483,7 @@ export function TaskExecutionsManager() {
|
||||
) : (
|
||||
<RefreshCw className='size-4' />
|
||||
)}
|
||||
刷新详情
|
||||
{t('refreshDetail')}
|
||||
</Button>
|
||||
{selectedExecution &&
|
||||
selectedExecution.status === 'failed' &&
|
||||
@@ -485,7 +497,7 @@ export function TaskExecutionsManager() {
|
||||
) : (
|
||||
<RotateCcw className='size-4' />
|
||||
)}
|
||||
重试任务
|
||||
{t('retryTask')}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -88,10 +89,10 @@ const TASK_CONFIGS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
const LOG_DATABASE_LABELS: Record<string, string> = {
|
||||
postgres: 'PostgreSQL(主库)',
|
||||
sqlite: 'SQLite(主库)',
|
||||
clickhouse: 'ClickHouse',
|
||||
const LOG_DATABASE_LABEL_KEYS: Record<string, string> = {
|
||||
postgres: 'dbPostgresPrimary',
|
||||
sqlite: 'dbSqlitePrimary',
|
||||
clickhouse: 'dbClickhouse',
|
||||
};
|
||||
|
||||
const DEFAULT_TASK_CONFIG = {
|
||||
@@ -108,6 +109,7 @@ function DatePickerWithTime({
|
||||
date: Date | undefined;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
}) {
|
||||
const t = useTranslations('admin.tasks');
|
||||
const timeString = date ? format(date, 'HH:mm:ss') : '';
|
||||
|
||||
const handleDateSelect = (newDate: Date | undefined) => {
|
||||
@@ -163,7 +165,7 @@ function DatePickerWithTime({
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className='mr-1 size-3' />
|
||||
{date ? format(date, 'yyyy-MM-dd') : <span>选择日期</span>}
|
||||
{date ? format(date, 'yyyy-MM-dd') : <span>{t('selectDate')}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
@@ -190,6 +192,7 @@ function DatePickerWithTime({
|
||||
}
|
||||
|
||||
export function TaskManager() {
|
||||
const t = useTranslations('admin.tasks');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]);
|
||||
@@ -210,11 +213,11 @@ export function TaskManager() {
|
||||
const data = await services.adminTask.getTaskTypes();
|
||||
setTaskTypes(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('加载任务类型失败'));
|
||||
setError(err instanceof Error ? err : new Error(t('loadTaskTypesFailed')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const [logDbStatus, setLogDbStatus] = useState<LogDatabaseStatus | null>(
|
||||
null,
|
||||
@@ -302,8 +305,11 @@ export function TaskManager() {
|
||||
|
||||
const taskID = await services.adminTask.dispatchTask(params);
|
||||
|
||||
toast.success('任务下发成功', {
|
||||
description: `已成功将任务 ${targetTask?.name || selectedTaskType} 加入队列:${taskID}`,
|
||||
toast.success(t('dispatchSuccess'), {
|
||||
description: t('dispatchSuccessDesc', {
|
||||
name: targetTask?.name || selectedTaskType,
|
||||
taskId: taskID,
|
||||
}),
|
||||
});
|
||||
setDialogOpen(false);
|
||||
|
||||
@@ -312,8 +318,8 @@ export function TaskManager() {
|
||||
setUserId('');
|
||||
setParamValues({});
|
||||
} catch (err) {
|
||||
toast.error('任务下发失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('dispatchFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setDispatching(false);
|
||||
@@ -345,10 +351,10 @@ export function TaskManager() {
|
||||
) : loading && taskTypes.length === 0 ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Layers}
|
||||
description='加载任务类型中...'
|
||||
description={t('loadingTaskTypes')}
|
||||
/>
|
||||
) : taskTypes.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description='暂无可用任务类型' />
|
||||
<EmptyStateWithBorder icon={Layers} description={t('noTaskTypes')} />
|
||||
) : (
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'>
|
||||
{taskTypes.map((task, index) => {
|
||||
@@ -386,13 +392,15 @@ export function TaskManager() {
|
||||
variant='outline'
|
||||
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
|
||||
>
|
||||
类型:{task.type}
|
||||
{t('taskType')}
|
||||
{task.type}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
|
||||
>
|
||||
重试:{task.max_retry}
|
||||
{t('taskRetry')}
|
||||
{task.max_retry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -401,16 +409,23 @@ export function TaskManager() {
|
||||
<div className='pt-3 mt-3 border-t border-border/50 space-y-1.5'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='text-[10px] text-muted-foreground shrink-0'>
|
||||
日志主库
|
||||
{t('logPrimaryDb')}
|
||||
</span>
|
||||
<span className='text-[10px] font-mono text-foreground truncate'>
|
||||
{LOG_DATABASE_LABELS[logDbStatus.active_database] ||
|
||||
logDbStatus.active_database}
|
||||
{LOG_DATABASE_LABEL_KEYS[
|
||||
logDbStatus.active_database
|
||||
]
|
||||
? t(
|
||||
LOG_DATABASE_LABEL_KEYS[
|
||||
logDbStatus.active_database
|
||||
],
|
||||
)
|
||||
: logDbStatus.active_database}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='text-[10px] text-muted-foreground shrink-0'>
|
||||
保留天数
|
||||
{t('retentionDays')}
|
||||
</span>
|
||||
<span className='text-[10px] font-mono text-muted-foreground truncate'>
|
||||
{retentionSummary || '-'}
|
||||
@@ -418,7 +433,7 @@ export function TaskManager() {
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='text-[10px] text-muted-foreground shrink-0'>
|
||||
迁移状态
|
||||
{t('migrationStatus')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -429,8 +444,8 @@ export function TaskManager() {
|
||||
className='text-[10px] h-5 px-1.5'
|
||||
>
|
||||
{logDbStatus.migration === 'migrating'
|
||||
? '迁移中'
|
||||
: '空闲'}
|
||||
? t('migrating')
|
||||
: t('idle')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -443,7 +458,7 @@ export function TaskManager() {
|
||||
onClick={() => openDispatchDialog(task.type)}
|
||||
>
|
||||
<Play className='size-3 mr-1' />
|
||||
立即执行
|
||||
{t('executeNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,15 +472,13 @@ export function TaskManager() {
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>下发任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置任务参数并启动,任务将在后台异步执行。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('dispatchTask')}</DialogTitle>
|
||||
<DialogDescription>{t('dispatchTaskDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>执行任务</Label>
|
||||
<Label>{t('executeTask')}</Label>
|
||||
<div className='flex items-center gap-2 p-2 rounded-lg bg-muted/50'>
|
||||
{(() => {
|
||||
const meta = getSelectedTaskMeta();
|
||||
@@ -495,20 +508,20 @@ export function TaskManager() {
|
||||
return (
|
||||
<>
|
||||
<div className='grid gap-2'>
|
||||
<Label>开始时间</Label>
|
||||
<Label>{t('startTime')}</Label>
|
||||
<DatePickerWithTime
|
||||
date={startTime}
|
||||
setDate={setStartTime}
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择同步的起始时间点
|
||||
{t('startTimeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>结束时间</Label>
|
||||
<Label>{t('endTime')}</Label>
|
||||
<DatePickerWithTime date={endTime} setDate={setEndTime} />
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
选择同步的结束时间点
|
||||
{t('endTimeDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -572,8 +585,8 @@ export function TaskManager() {
|
||||
/>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{paramValues[param.name] === 'true'
|
||||
? '开启'
|
||||
: '关闭'}
|
||||
? t('paramOn')
|
||||
: t('paramOff')}
|
||||
</span>
|
||||
</div>
|
||||
) : isSwitchTarget &&
|
||||
@@ -593,12 +606,14 @@ export function TaskManager() {
|
||||
className='w-full text-xs'
|
||||
size='sm'
|
||||
>
|
||||
<SelectValue placeholder='选择目标日志库...' />
|
||||
<SelectValue placeholder={t('selectLogDb')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableLogDbTargets.map((target) => (
|
||||
<SelectItem key={target} value={target}>
|
||||
{LOG_DATABASE_LABELS[target] || target}
|
||||
{LOG_DATABASE_LABEL_KEYS[target]
|
||||
? t(LOG_DATABASE_LABEL_KEYS[target])
|
||||
: target}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -639,7 +654,7 @@ export function TaskManager() {
|
||||
return (
|
||||
<div className='flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed'>
|
||||
<Info className='h-4 w-4' />
|
||||
<span>此任务无需额外配置参数,确认后将直接下发。</span>
|
||||
<span>{t('noParamsHint')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -654,7 +669,7 @@ export function TaskManager() {
|
||||
disabled={dispatching}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDispatch}
|
||||
@@ -666,7 +681,7 @@ export function TaskManager() {
|
||||
) : (
|
||||
<Play className='size-3' />
|
||||
)}
|
||||
{dispatching ? '下发中' : '启动'}
|
||||
{dispatching ? t('dispatching') : t('start')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -39,6 +40,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
|
||||
export function TaskSchedulesManager() {
|
||||
const t = useTranslations('admin.tasks');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
@@ -73,11 +75,11 @@ export function TaskSchedulesManager() {
|
||||
setSchedules(schedulesData || []);
|
||||
setTaskTypes(taskTypesData || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('加载定时任务配置失败'));
|
||||
setError(err instanceof Error ? err : new Error(t('loadSchedulesFailed')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -130,12 +132,12 @@ export function TaskSchedulesManager() {
|
||||
);
|
||||
toast.success(
|
||||
updated.is_active
|
||||
? `已启用定时任务:${updated.name}`
|
||||
: `已停用定时任务:${updated.name}`,
|
||||
? t('scheduleEnabled', { name: updated.name })
|
||||
: t('scheduleDisabled', { name: updated.name }),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error('修改任务状态失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('changeScheduleStatusFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -161,11 +163,11 @@ export function TaskSchedulesManager() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error('任务名称不能为空');
|
||||
toast.error(t('scheduleNameRequired'));
|
||||
return;
|
||||
}
|
||||
if (!cron.trim()) {
|
||||
toast.error('Cron 表达式不能为空');
|
||||
toast.error(t('cronRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,7 +205,7 @@ export function TaskSchedulesManager() {
|
||||
setSchedules((prev) =>
|
||||
prev.map((s) => (s.id === editingSchedule.id ? updated : s)),
|
||||
);
|
||||
toast.success('定时任务更新成功');
|
||||
toast.success(t('scheduleUpdateSuccess'));
|
||||
} else {
|
||||
const req: CreateScheduleRequest = {
|
||||
name,
|
||||
@@ -214,13 +216,13 @@ export function TaskSchedulesManager() {
|
||||
};
|
||||
const created = await services.adminTask.createSchedule(req);
|
||||
setSchedules((prev) => [created, ...prev]);
|
||||
toast.success('定时任务创建成功');
|
||||
toast.success(t('scheduleCreateSuccess'));
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
toast.error('提交失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('submitFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
@@ -238,11 +240,11 @@ export function TaskSchedulesManager() {
|
||||
setDeleteLoading(true);
|
||||
await services.adminTask.deleteSchedule(deletingId);
|
||||
setSchedules((prev) => prev.filter((s) => s.id !== deletingId));
|
||||
toast.success('定时任务删除成功');
|
||||
toast.success(t('scheduleDeleteSuccess'));
|
||||
setDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error('删除失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('deleteFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
@@ -274,7 +276,7 @@ export function TaskSchedulesManager() {
|
||||
) : (
|
||||
<RefreshCw className='size-4' />
|
||||
)}
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -283,7 +285,7 @@ export function TaskSchedulesManager() {
|
||||
variant={'secondary'}
|
||||
>
|
||||
<Plus className='size-4 mr-1' />
|
||||
新增定时任务
|
||||
{t('addSchedule')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,28 +301,34 @@ export function TaskSchedulesManager() {
|
||||
) : loading && schedules.length === 0 ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Clock}
|
||||
description='加载定时任务配置中...'
|
||||
description={t('loadingSchedules')}
|
||||
/>
|
||||
) : schedules.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Clock}
|
||||
description='暂无定时任务配置,点击上方按钮新增'
|
||||
description={t('noSchedules')}
|
||||
/>
|
||||
) : (
|
||||
<div className='rounded-lg border bg-card'>
|
||||
<Table className='min-w-[800px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[180px]'>任务名称</TableHead>
|
||||
<TableHead className='w-[160px]'>关联异步任务</TableHead>
|
||||
<TableHead className='w-[120px]'>Cron 表达式</TableHead>
|
||||
<TableHead className='w-[180px]'>
|
||||
{t('colScheduleName')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[160px]'>
|
||||
{t('colAssociatedTask')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px]'>{t('colCron')}</TableHead>
|
||||
<TableHead className='min-w-[200px]'>
|
||||
执行参数 (Payload)
|
||||
{t('colPayload')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] text-center'>
|
||||
启用状态
|
||||
{t('colEnabled')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px] text-right'>
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px] text-right'>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -385,19 +393,19 @@ export function TaskSchedulesManager() {
|
||||
<DialogContent className='sm:max-w-[500px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingSchedule ? '修改定时任务' : '新增定时任务'}
|
||||
{editingSchedule ? t('editSchedule') : t('addSchedule')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置定时任务的调度参数和运行载荷。
|
||||
{t('scheduleDialogDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='sched-name'>任务名称</Label>
|
||||
<Label htmlFor='sched-name'>{t('scheduleName')}</Label>
|
||||
<Input
|
||||
id='sched-name'
|
||||
placeholder='例如:每日数据清理'
|
||||
placeholder={t('scheduleNamePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className='text-xs'
|
||||
@@ -405,7 +413,7 @@ export function TaskSchedulesManager() {
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='sched-type'>关联异步任务</Label>
|
||||
<Label htmlFor='sched-type'>{t('associatedTask')}</Label>
|
||||
<select
|
||||
id='sched-type'
|
||||
value={selectedTaskType}
|
||||
@@ -422,24 +430,24 @@ export function TaskSchedulesManager() {
|
||||
</div>
|
||||
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='sched-cron'>Cron 表达式</Label>
|
||||
<Label htmlFor='sched-cron'>{t('cronExpression')}</Label>
|
||||
<Input
|
||||
id='sched-cron'
|
||||
placeholder='e.g. 0 */2 * * * 或 @daily'
|
||||
placeholder={t('cronPlaceholder')}
|
||||
value={cron}
|
||||
onChange={(e) => setCron(e.target.value)}
|
||||
className='text-xs font-mono'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。
|
||||
{t('cronHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border p-3'>
|
||||
<div className='space-y-0.5'>
|
||||
<Label htmlFor='sched-active'>启用状态</Label>
|
||||
<Label htmlFor='sched-active'>{t('scheduleActive')}</Label>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
决定此定时任务是否会定时触发执行。
|
||||
{t('scheduleActiveDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -459,14 +467,14 @@ export function TaskSchedulesManager() {
|
||||
return (
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed'>
|
||||
<Info className='h-4 w-4 shrink-0' />
|
||||
<span>所选任务类型不需要运行参数参数。</span>
|
||||
<span>{t('noParamsRequired')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='space-y-4 pt-2 border-t'>
|
||||
<Label className='text-xs font-semibold'>
|
||||
运行参数配置 (Payload)
|
||||
{t('payloadConfig')}
|
||||
</Label>
|
||||
{targetTask.params.map((param) => (
|
||||
<div
|
||||
@@ -509,8 +517,8 @@ export function TaskSchedulesManager() {
|
||||
/>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{paramValues[param.name] === 'true'
|
||||
? '开启'
|
||||
: '关闭'}
|
||||
? t('paramOn')
|
||||
: t('paramOff')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -547,7 +555,7 @@ export function TaskSchedulesManager() {
|
||||
disabled={submitLoading}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
@@ -555,7 +563,7 @@ export function TaskSchedulesManager() {
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
{submitLoading && <Spinner className='size-3 mr-1' />}
|
||||
{editingSchedule ? '保存修改' : '确认创建'}
|
||||
{editingSchedule ? t('saveChanges') : t('confirmCreate')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -565,9 +573,9 @@ export function TaskSchedulesManager() {
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className='sm:max-w-[400px]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除定时任务</DialogTitle>
|
||||
<DialogTitle>{t('deleteScheduleTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
|
||||
{t('deleteScheduleDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className='gap-2 sm:gap-0'>
|
||||
@@ -577,7 +585,7 @@ export function TaskSchedulesManager() {
|
||||
disabled={deleteLoading}
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -586,7 +594,7 @@ export function TaskSchedulesManager() {
|
||||
className='h-8 text-xs'
|
||||
>
|
||||
{deleteLoading && <Spinner className='size-3 mr-1' />}
|
||||
确认删除
|
||||
{t('confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { TaskManager } from './components/task-manager';
|
||||
import { TaskSchedulesManager } from './components/task-schedules';
|
||||
@@ -10,6 +11,7 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { Layers } from 'lucide-react';
|
||||
|
||||
function TasksPageContent() {
|
||||
const t = useTranslations('admin.tasks');
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const activeTab = searchParams.get('tab') || 'tasks';
|
||||
@@ -22,7 +24,9 @@ function TasksPageContent() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<Layers className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>任务管理</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,19 +40,19 @@ function TasksPageContent() {
|
||||
value='tasks'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
任务管理
|
||||
{t('tabTasks')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='schedules'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
定时任务
|
||||
{t('tabSchedules')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='executions'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
任务日志
|
||||
{t('tabExecutions')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='tasks' className='space-y-4 outline-none'>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -31,6 +32,7 @@ export function CreateUserModal({
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('admin.users');
|
||||
const { createUser } = useAdminUsers();
|
||||
const [form, setForm] = useState<CreateUserRequest>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -48,21 +50,21 @@ export function CreateUserModal({
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.username.trim()) {
|
||||
newErrors.username = '用户名不能为空';
|
||||
newErrors.username = t('validation.usernameRequired');
|
||||
} else if (form.username.trim().length < 3) {
|
||||
newErrors.username = '用户名长度不能少于 3 位';
|
||||
newErrors.username = t('validation.usernameTooShort');
|
||||
}
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = '邮箱不能为空';
|
||||
newErrors.email = t('validation.emailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = '邮箱格式不正确';
|
||||
newErrors.email = t('validation.emailInvalid');
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
newErrors.password = '密码不能为空';
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (form.password.length < 8) {
|
||||
newErrors.password = '密码长度不能少于 8 位';
|
||||
newErrors.password = t('validation.passwordTooShort');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -93,22 +95,20 @@ export function CreateUserModal({
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
直接创建一个本地密码登录的新账户。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('createTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('createDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className='space-y-4 pt-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='username'>用户名</Label>
|
||||
<Label htmlFor='username'>{t('username')}</Label>
|
||||
<Input
|
||||
id='username'
|
||||
value={form.username}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, username: e.target.value }))
|
||||
}
|
||||
placeholder='请输入用户名 (至少 3 位)'
|
||||
placeholder={t('usernamePlaceholder')}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className='text-xs text-destructive'>{errors.username}</p>
|
||||
@@ -116,19 +116,19 @@ export function CreateUserModal({
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='nickname'>昵称 (选填)</Label>
|
||||
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
|
||||
<Input
|
||||
id='nickname'
|
||||
value={form.nickname}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, nickname: e.target.value }))
|
||||
}
|
||||
placeholder='请输入昵称'
|
||||
placeholder={t('nicknamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='email'>邮箱</Label>
|
||||
<Label htmlFor='email'>{t('email')}</Label>
|
||||
<Input
|
||||
id='email'
|
||||
type='email'
|
||||
@@ -136,7 +136,7 @@ export function CreateUserModal({
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
placeholder='请输入邮箱地址'
|
||||
placeholder={t('emailPlaceholder')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className='text-xs text-destructive'>{errors.email}</p>
|
||||
@@ -144,7 +144,7 @@ export function CreateUserModal({
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='password'>密码</Label>
|
||||
<Label htmlFor='password'>{t('password')}</Label>
|
||||
<Input
|
||||
id='password'
|
||||
type='password'
|
||||
@@ -152,7 +152,7 @@ export function CreateUserModal({
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
placeholder='请输入登录密码 (至少 8 位)'
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className='text-xs text-destructive'>{errors.password}</p>
|
||||
@@ -161,9 +161,11 @@ export function CreateUserModal({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
|
||||
<div>
|
||||
<div className='font-medium text-sm'>启用账户</div>
|
||||
<div className='font-medium text-sm'>
|
||||
{t('enableAccountLabel')}
|
||||
</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
禁用后此账号将无法登录系统。
|
||||
{t('enableAccountDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -176,9 +178,9 @@ export function CreateUserModal({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
|
||||
<div>
|
||||
<div className='font-medium text-sm'>管理员权限</div>
|
||||
<div className='font-medium text-sm'>{t('adminPermission')}</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
开启后此账号将拥有后台管理权限。
|
||||
{t('adminPermissionDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -191,10 +193,10 @@ export function CreateUserModal({
|
||||
|
||||
<div className='flex justify-end gap-2 pt-2 border-t mt-2'>
|
||||
<Button variant='outline' type='button' onClick={onClose}>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={saving} variant='secondary'>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
{saving ? t('creating') : t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -43,6 +44,7 @@ export function EditUserModal({
|
||||
onClose: () => void;
|
||||
user: AdminUser | null;
|
||||
}) {
|
||||
const t = useTranslations('admin.users');
|
||||
const { updateUser, deleteUser, getUserDetail } = useAdminUsers();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
@@ -96,13 +98,13 @@ export function EditUserModal({
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!form.email.trim()) {
|
||||
newErrors.email = '邮箱不能为空';
|
||||
newErrors.email = t('validation.emailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||
newErrors.email = '邮箱格式不正确';
|
||||
newErrors.email = t('validation.emailInvalid');
|
||||
}
|
||||
|
||||
if (form.password && form.password.length < 8) {
|
||||
newErrors.password = '密码长度不能少于 8 位';
|
||||
newErrors.password = t('validation.passwordTooShort');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -150,10 +152,8 @@ export function EditUserModal({
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className='max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑用户</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改用户档案、授予/撤销管理权限或重置其密码。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('editTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('editDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSave} className='space-y-4 pt-2'>
|
||||
@@ -162,7 +162,7 @@ export function EditUserModal({
|
||||
htmlFor='username'
|
||||
className='text-xs text-muted-foreground'
|
||||
>
|
||||
用户名 (不可修改)
|
||||
{t('usernameImmutable')}
|
||||
</Label>
|
||||
<Input
|
||||
id='username'
|
||||
@@ -173,19 +173,19 @@ export function EditUserModal({
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='nickname'>昵称 (选填)</Label>
|
||||
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
|
||||
<Input
|
||||
id='nickname'
|
||||
value={form.nickname}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, nickname: e.target.value }))
|
||||
}
|
||||
placeholder='请输入昵称'
|
||||
placeholder={t('nicknamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='email'>邮箱</Label>
|
||||
<Label htmlFor='email'>{t('email')}</Label>
|
||||
<Input
|
||||
id='email'
|
||||
type='email'
|
||||
@@ -193,7 +193,7 @@ export function EditUserModal({
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, email: e.target.value }))
|
||||
}
|
||||
placeholder='请输入邮箱地址'
|
||||
placeholder={t('emailPlaceholder')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className='text-xs text-destructive'>{errors.email}</p>
|
||||
@@ -201,7 +201,7 @@ export function EditUserModal({
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='password'>重置密码 (选填)</Label>
|
||||
<Label htmlFor='password'>{t('resetPasswordOptional')}</Label>
|
||||
<Input
|
||||
id='password'
|
||||
type='password'
|
||||
@@ -209,7 +209,7 @@ export function EditUserModal({
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, password: e.target.value }))
|
||||
}
|
||||
placeholder='留空表示保持当前密码,输入则重置 (至少 8 位)'
|
||||
placeholder={t('resetPasswordPlaceholder')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className='text-xs text-destructive'>{errors.password}</p>
|
||||
@@ -218,11 +218,11 @@ export function EditUserModal({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
|
||||
<div>
|
||||
<div className='font-medium text-sm'>管理员权限</div>
|
||||
<div className='font-medium text-sm'>{t('adminPermission')}</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{isSelf
|
||||
? '不能撤销当前登录用户的管理员权限。'
|
||||
: '开启后此账号将拥有后台管理权限。'}
|
||||
? t('cannotRevokeSelfAdmin')
|
||||
: t('adminPermissionDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -255,7 +255,7 @@ export function EditUserModal({
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
删除用户
|
||||
{t('deleteUser')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -266,10 +266,10 @@ export function EditUserModal({
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={saving} variant='secondary'>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
{saving ? t('saving') : t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,14 +280,15 @@ export function EditUserModal({
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除用户 {user.nickname || user.username}{' '}
|
||||
吗?该操作会移除用户账号,删除后无法撤销。
|
||||
{t('deleteConfirmDesc', {
|
||||
name: user.nickname || user.username,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={saving}>{t('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -296,7 +297,7 @@ export function EditUserModal({
|
||||
disabled={saving}
|
||||
>
|
||||
{saving && <Loader2 className='size-3 animate-spin mr-1' />}
|
||||
确认删除
|
||||
{t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
Globe,
|
||||
Loader2,
|
||||
@@ -33,13 +34,14 @@ export function UserDetailSheet({
|
||||
detailLoading,
|
||||
onStatusToggle,
|
||||
}: UserDetailSheetProps) {
|
||||
const t = useTranslations('admin.users');
|
||||
const displayValue = (value?: string) =>
|
||||
value && value.trim() ? value : '-';
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='sm:max-w-[400px] w-full p-0 flex flex-col gap-0'>
|
||||
<SheetTitle className='px-5 py-3'>用户档案</SheetTitle>
|
||||
<SheetTitle className='px-5 py-3'>{t('userProfile')}</SheetTitle>
|
||||
|
||||
{selectedUser && (
|
||||
<>
|
||||
@@ -79,14 +81,14 @@ export function UserDetailSheet({
|
||||
{detailLoading && (
|
||||
<div className='flex items-center gap-1 text-[10px] text-muted-foreground'>
|
||||
<Loader2 className='size-3 animate-spin' />
|
||||
正在刷新详情
|
||||
{t('refreshingDetail')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50'>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='text-[9px] uppercase tracking-widest text-muted-foreground font-medium'>
|
||||
注册时间
|
||||
{t('registeredAt')}
|
||||
</span>
|
||||
<span className='font-mono text-xs font-semibold'>
|
||||
{
|
||||
@@ -103,13 +105,13 @@ export function UserDetailSheet({
|
||||
<div className='p-6 space-y-6'>
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
|
||||
个人资料
|
||||
{t('personalInfo')}
|
||||
</h4>
|
||||
<div className='rounded-lg border divide-y bg-background/50'>
|
||||
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
|
||||
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
|
||||
<Mail className='size-3' />
|
||||
邮箱
|
||||
{t('email')}
|
||||
</span>
|
||||
<span className='min-w-0 truncate text-right text-[10px]'>
|
||||
{displayValue(selectedUser.email)}
|
||||
@@ -118,7 +120,7 @@ export function UserDetailSheet({
|
||||
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
|
||||
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
|
||||
<Smartphone className='size-3' />
|
||||
手机
|
||||
{t('phone')}
|
||||
</span>
|
||||
<span className='min-w-0 truncate text-right text-[10px]'>
|
||||
{displayValue(selectedUser.phone)}
|
||||
@@ -129,7 +131,7 @@ export function UserDetailSheet({
|
||||
<span className='size-3 flex items-center justify-center font-bold text-[9px]'>
|
||||
⚧
|
||||
</span>
|
||||
性别
|
||||
{t('gender')}
|
||||
</span>
|
||||
<span className='min-w-0 truncate text-right text-[10px]'>
|
||||
{displayValue(selectedUser.gender)}
|
||||
@@ -138,7 +140,7 @@ export function UserDetailSheet({
|
||||
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
|
||||
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
|
||||
<MapPin className='size-3' />
|
||||
所在地
|
||||
{t('location')}
|
||||
</span>
|
||||
<span className='min-w-0 truncate text-right text-[10px]'>
|
||||
{displayValue(selectedUser.location)}
|
||||
@@ -147,7 +149,7 @@ export function UserDetailSheet({
|
||||
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
|
||||
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
|
||||
<Globe className='size-3' />
|
||||
网站
|
||||
{t('website')}
|
||||
</span>
|
||||
<span className='min-w-0 truncate text-right text-[10px]'>
|
||||
{displayValue(selectedUser.website)}
|
||||
@@ -155,7 +157,7 @@ export function UserDetailSheet({
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 p-3.5 text-sm'>
|
||||
<span className='text-[10px] text-muted-foreground'>
|
||||
简介
|
||||
{t('bio')}
|
||||
</span>
|
||||
<span className='break-words text-[10px] leading-5'>
|
||||
{displayValue(selectedUser.bio)}
|
||||
@@ -166,40 +168,42 @@ export function UserDetailSheet({
|
||||
|
||||
<div className='space-y-4'>
|
||||
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
|
||||
系统记录
|
||||
{t('systemRecords')}
|
||||
</h4>
|
||||
<div className='rounded-lg border divide-y bg-background/50'>
|
||||
<div className='flex items-center justify-between p-3.5 text-sm'>
|
||||
<span className='text-[10px]'>账户状态</span>
|
||||
<span className='text-[10px]'>{t('accountStatus')}</span>
|
||||
<Badge
|
||||
variant={
|
||||
selectedUser.is_active ? 'secondary' : 'outline'
|
||||
}
|
||||
className='text-[10px]'
|
||||
>
|
||||
{selectedUser.is_active ? '正常' : '禁用'}
|
||||
{selectedUser.is_active
|
||||
? t('statusActive')
|
||||
: t('statusDisabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='flex items-center justify-between p-3.5 text-sm'>
|
||||
<span className='text-[10px]'>管理员</span>
|
||||
<span className='text-[10px]'>{t('isAdmin')}</span>
|
||||
<span className='font-mono text-[10px]'>
|
||||
{selectedUser.is_admin ? '是' : '否'}
|
||||
{selectedUser.is_admin ? t('yes') : t('no')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between p-3.5 text-sm'>
|
||||
<span className='text-[10px]'>最后登录</span>
|
||||
<span className='text-[10px]'>{t('lastLogin')}</span>
|
||||
<span className='font-mono text-[10px]'>
|
||||
{formatDateTime(selectedUser.last_login_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between p-3.5 text-sm'>
|
||||
<span className='text-[10px]'>注册时间</span>
|
||||
<span className='text-[10px]'>{t('registeredAt')}</span>
|
||||
<span className='font-mono text-[10px]'>
|
||||
{formatDateTime(selectedUser.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center justify-between p-3.5 text-sm'>
|
||||
<span className='text-[10px]'>最后更新</span>
|
||||
<span className='text-[10px]'>{t('lastUpdate')}</span>
|
||||
<span className='font-mono text-[10px]'>
|
||||
{formatDateTime(selectedUser.updated_at)}
|
||||
</span>
|
||||
@@ -219,12 +223,12 @@ export function UserDetailSheet({
|
||||
{selectedUser.is_active ? (
|
||||
<>
|
||||
<ShieldCheck className='size-3 mr-1' />
|
||||
封禁账户
|
||||
{t('banAccount')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className='size-3 mr-1' />
|
||||
解除封禁
|
||||
{t('unbanAccount')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -24,6 +25,7 @@ import { useAdminUsers } from '@/contexts/admin-users-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function UserFilterBar() {
|
||||
const t = useTranslations('admin.users');
|
||||
const {
|
||||
total,
|
||||
loading,
|
||||
@@ -61,7 +63,7 @@ export function UserFilterBar() {
|
||||
)}
|
||||
>
|
||||
<Search className='size-3 mr-1' />
|
||||
搜索
|
||||
{t('search')}
|
||||
{hasSearchFilter && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-1' />
|
||||
@@ -79,19 +81,19 @@ export function UserFilterBar() {
|
||||
<div className='space-y-2.5'>
|
||||
<input
|
||||
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
|
||||
placeholder='输入用户 ID...'
|
||||
placeholder={t('searchUserIdPlaceholder')}
|
||||
value={searchUserId}
|
||||
onChange={(e) => setSearchUserId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
|
||||
placeholder='输入 username...'
|
||||
placeholder={t('searchUsernamePlaceholder')}
|
||||
value={searchUsername}
|
||||
onChange={(e) => setSearchUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
|
||||
placeholder='输入邮箱...'
|
||||
placeholder={t('searchEmailPlaceholder')}
|
||||
value={searchEmail}
|
||||
onChange={(e) => setSearchEmail(e.target.value)}
|
||||
/>
|
||||
@@ -106,7 +108,7 @@ export function UserFilterBar() {
|
||||
setSearchEmail('');
|
||||
}}
|
||||
>
|
||||
清除
|
||||
{t('clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -124,7 +126,7 @@ export function UserFilterBar() {
|
||||
)}
|
||||
>
|
||||
<Filter className='size-3' />
|
||||
状态
|
||||
{t('status')}
|
||||
{statusFilter !== 'all' && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-1' />
|
||||
@@ -153,7 +155,7 @@ export function UserFilterBar() {
|
||||
: 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
<span className='text-xs'>全部状态</span>
|
||||
<span className='text-xs'>{t('statusAll')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
@@ -169,7 +171,7 @@ export function UserFilterBar() {
|
||||
: 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
<span className='text-xs'>正常</span>
|
||||
<span className='text-xs'>{t('statusActive')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
@@ -185,7 +187,7 @@ export function UserFilterBar() {
|
||||
: 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
<span className='text-xs'>禁用</span>
|
||||
<span className='text-xs'>{t('statusDisabled')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -205,7 +207,7 @@ export function UserFilterBar() {
|
||||
className='h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<X className='size-3' />
|
||||
清空筛选
|
||||
{t('clearFilters')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -215,7 +217,7 @@ export function UserFilterBar() {
|
||||
|
||||
<div className='flex items-center gap-1.5 self-end lg:self-auto'>
|
||||
<span className='text-[10px] text-muted-foreground whitespace-nowrap'>
|
||||
{total} 条记录
|
||||
{t('totalRecords', { total })}
|
||||
</span>
|
||||
<div className='flex items-center border border-dashed rounded-md shadow-none'>
|
||||
<Button
|
||||
@@ -249,7 +251,7 @@ export function UserFilterBar() {
|
||||
className='h-6 border-dashed text-[10px] px-2 font-mono shadow-none'
|
||||
disabled={loading}
|
||||
>
|
||||
{pageSize}条/页
|
||||
{t('perPage', { count: pageSize })}
|
||||
<ChevronDown className='size-3 opacity-50' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -263,7 +265,7 @@ export function UserFilterBar() {
|
||||
pageSize === size && 'bg-accent',
|
||||
)}
|
||||
>
|
||||
{size}条/页
|
||||
{t('perPage', { count: size })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -275,7 +277,7 @@ export function UserFilterBar() {
|
||||
className='h-6 w-6 border-dashed shadow-none'
|
||||
onClick={() => fetchUsers(true)}
|
||||
disabled={loading}
|
||||
title='刷新数据'
|
||||
title={t('refreshData')}
|
||||
>
|
||||
<Loader2 className={cn('size-3', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import { UserDetailSheet } from './components/user-detail-sheet';
|
||||
import { EditUserModal } from './components/edit-user-modal';
|
||||
|
||||
export default function UsersPage() {
|
||||
const t = useTranslations('admin.users');
|
||||
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
|
||||
useAdminUsers();
|
||||
|
||||
@@ -78,7 +80,9 @@ export default function UsersPage() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<UserRound className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>用户管理</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -88,7 +92,7 @@ export default function UsersPage() {
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新增用户
|
||||
{t('createUser')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -104,9 +108,9 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
) : loading && users.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' />
|
||||
<LoadingStateWithBorder icon={Layers} description={t('loadingUsers')} />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' />
|
||||
<EmptyStateWithBorder icon={UserX} description={t('noUsers')} />
|
||||
) : (
|
||||
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -117,22 +121,22 @@ export default function UsersPage() {
|
||||
ID
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
|
||||
用户
|
||||
{t('colUser')}
|
||||
</TableHead>
|
||||
<TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'>
|
||||
邮箱
|
||||
{t('colEmail')}
|
||||
</TableHead>
|
||||
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
|
||||
上次登陆
|
||||
{t('colLastLogin')}
|
||||
</TableHead>
|
||||
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
|
||||
注册时间
|
||||
{t('colRegisteredAt')}
|
||||
</TableHead>
|
||||
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
|
||||
上次更新
|
||||
{t('colLastUpdate')}
|
||||
</TableHead>
|
||||
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
|
||||
操作
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -211,10 +215,10 @@ export default function UsersPage() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
{user.is_admin
|
||||
? '管理员账户'
|
||||
? t('adminAccount')
|
||||
: user.is_active
|
||||
? '禁用账户'
|
||||
: '启用账户'}
|
||||
? t('disableAccount')
|
||||
: t('enableAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -230,7 +234,7 @@ export default function UsersPage() {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看详情
|
||||
{t('viewDetail')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -246,7 +250,7 @@ export default function UsersPage() {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
编辑用户
|
||||
{t('editUser')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -19,7 +21,8 @@ interface LogDetailSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
function ResultBadge({ result }: { result: string }) {
|
||||
const t = useTranslations('applyLogs');
|
||||
if (result === 'success') {
|
||||
return (
|
||||
<Badge
|
||||
@@ -27,7 +30,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
成功
|
||||
{t('success')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +41,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
|
||||
警告
|
||||
{t('warning')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +51,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
|
||||
失败
|
||||
{t('failed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -58,58 +61,57 @@ export function LogDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LogDetailSheetProps) {
|
||||
const t = useTranslations('applyLogs');
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='sm:max-w-md w-full p-0 flex flex-col gap-0'>
|
||||
<SheetHeader className='px-5 py-4 border-b'>
|
||||
<SheetTitle>应用日志详情</SheetTitle>
|
||||
<SheetDescription>
|
||||
查看单条应用日志的完整结果与校验信息。
|
||||
</SheetDescription>
|
||||
<SheetTitle>{t('sheetTitle')}</SheetTitle>
|
||||
<SheetDescription>{t('sheetDesc')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{log ? (
|
||||
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{getResultBadge(log.result)}
|
||||
<ResultBadge result={log.result} />
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
Node: {log.node_id}
|
||||
</Badge>
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
版本: {log.version}
|
||||
{t('versionBadge', { version: log.version })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 text-xs'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>创建时间</p>
|
||||
<p className='text-muted-foreground'>{t('createdAt')}</p>
|
||||
<p className='font-medium'>{formatDateTime(log.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>目标 Checksum</p>
|
||||
<p className='text-muted-foreground'>{t('targetChecksum')}</p>
|
||||
<p className='font-mono break-all'>{log.checksum || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>主配置摘要</p>
|
||||
<p className='text-muted-foreground'>{t('mainChecksum')}</p>
|
||||
<p className='font-mono break-all'>
|
||||
{log.main_config_checksum || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>路由配置摘要</p>
|
||||
<p className='text-muted-foreground'>{t('routeChecksum')}</p>
|
||||
<p className='font-mono break-all'>
|
||||
{log.route_config_checksum || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>支持文件数</p>
|
||||
<p className='text-muted-foreground'>{t('supportFiles')}</p>
|
||||
<p className='font-medium'>{log.support_file_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground mb-2'>
|
||||
消息
|
||||
{t('message')}
|
||||
</p>
|
||||
<pre className='text-xs whitespace-pre-wrap break-words'>
|
||||
{log.message || '—'}
|
||||
@@ -120,7 +122,7 @@ export function LogDetailSheet({
|
||||
|
||||
<SheetFooter className='px-5 py-4 border-t'>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -49,7 +50,8 @@ function truncateHash(value: string) {
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
function ResultBadge({ result }: { result: string }) {
|
||||
const t = useTranslations('applyLogs');
|
||||
if (result === 'success') {
|
||||
return (
|
||||
<Badge
|
||||
@@ -57,7 +59,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
成功
|
||||
{t('success')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +70,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
|
||||
警告
|
||||
{t('warning')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -78,12 +80,14 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
|
||||
失败
|
||||
{t('failed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplyLogsPageClient() {
|
||||
const t = useTranslations('applyLogs');
|
||||
const tc = useTranslations('common');
|
||||
const searchParams = useSearchParams();
|
||||
const initialNodeId = searchParams.get('node_id')?.trim() ?? '';
|
||||
|
||||
@@ -108,12 +112,12 @@ export function ApplyLogsPageClient() {
|
||||
const summary = useMemo(() => {
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id));
|
||||
return [
|
||||
{ label: '总记录数', value: total },
|
||||
{ label: '当前页', value: current },
|
||||
{ label: '总页数', value: totalPage },
|
||||
{ label: '当前页节点数', value: nodeIds.size },
|
||||
{ label: t('totalRecords'), value: total },
|
||||
{ label: t('currentPage'), value: current },
|
||||
{ label: t('totalPages'), value: totalPage },
|
||||
{ label: t('pageNodes'), value: nodeIds.size },
|
||||
];
|
||||
}, [rows, total, current, totalPage]);
|
||||
}, [rows, total, current, totalPage, t]);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -129,11 +133,11 @@ export function ApplyLogsPageClient() {
|
||||
setTotal(data.total);
|
||||
setTotalPage(data.totalPage);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nodeFilter, pageNo, pageSize]);
|
||||
}, [nodeFilter, pageNo, pageSize, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs();
|
||||
@@ -162,15 +166,16 @@ export function ApplyLogsPageClient() {
|
||||
setCleaning(true);
|
||||
try {
|
||||
const result = await ApplyLogService.cleanup({ delete_all: true });
|
||||
toast.success('应用日志已清空', {
|
||||
description: `共删除 ${result.deleted_count} 条记录`,
|
||||
toast.success(t('cleared'), {
|
||||
description: t('clearedDesc', { count: result.deleted_count }),
|
||||
});
|
||||
setCleanupOpen(false);
|
||||
setPageNo(1);
|
||||
await fetchLogs();
|
||||
} catch (err) {
|
||||
toast.error('清空应用日志失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('clearFailed'), {
|
||||
description:
|
||||
err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
@@ -183,10 +188,10 @@ export function ApplyLogsPageClient() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<ClipboardList className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>应用日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +205,7 @@ export function ApplyLogsPageClient() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -209,10 +214,10 @@ export function ApplyLogsPageClient() {
|
||||
disabled={loading || cleaning || total === 0}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清空日志
|
||||
{t('clear')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href='/nodes'>返回节点</Link>
|
||||
<Link href='/nodes'>{t('backToNodes')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +239,9 @@ export function ApplyLogsPageClient() {
|
||||
<div className='flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between'>
|
||||
<div className='grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]'>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>Node ID</p>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
{t('nodeId')}
|
||||
</p>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
@@ -243,14 +250,14 @@ export function ApplyLogsPageClient() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}}
|
||||
placeholder='输入 node_id 过滤应用日志'
|
||||
placeholder={t('nodePlaceholder')}
|
||||
className='pl-8 h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
每页条数
|
||||
{t('pageSize')}
|
||||
</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
@@ -265,7 +272,7 @@ export function ApplyLogsPageClient() {
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} 条
|
||||
{t('pageSizeOption', { count: option })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -274,10 +281,10 @@ export function ApplyLogsPageClient() {
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button size='sm' onClick={handleSearch}>
|
||||
筛选
|
||||
{t('filter')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={handleResetFilters}>
|
||||
重置筛选
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,23 +298,33 @@ export function ApplyLogsPageClient() {
|
||||
<LoadingStateWithBorder />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无应用日志'
|
||||
description='当前筛选条件下没有可展示的应用记录。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs font-semibold'>Node ID</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>版本</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>结果</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
Checksum
|
||||
{t('colNodeId')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colVersion')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colResult')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colChecksum')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colTime')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colMessage')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>时间</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>消息</TableHead>
|
||||
<TableHead className='text-xs font-semibold text-right'>
|
||||
操作
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -323,7 +340,9 @@ export function ApplyLogsPageClient() {
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{log.version}
|
||||
</TableCell>
|
||||
<TableCell>{getResultBadge(log.result)}</TableCell>
|
||||
<TableCell>
|
||||
<ResultBadge result={log.result} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-xs font-mono text-muted-foreground'
|
||||
title={log.checksum}
|
||||
@@ -349,7 +368,7 @@ export function ApplyLogsPageClient() {
|
||||
}}
|
||||
>
|
||||
<Eye className='size-3 mr-1' />
|
||||
详情
|
||||
{t('detail')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -362,7 +381,11 @@ export function ApplyLogsPageClient() {
|
||||
{!loading && rows.length > 0 ? (
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
|
||||
{t('pageInfo', {
|
||||
current,
|
||||
total: Math.max(totalPage, 1),
|
||||
count: total,
|
||||
})}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
@@ -371,7 +394,7 @@ export function ApplyLogsPageClient() {
|
||||
disabled={current <= 1}
|
||||
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
上一页
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -383,7 +406,7 @@ export function ApplyLogsPageClient() {
|
||||
)
|
||||
}
|
||||
>
|
||||
下一页
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,14 +421,15 @@ export function ApplyLogsPageClient() {
|
||||
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认清空应用日志?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('clearTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
该操作将删除全部 {total}{' '}
|
||||
条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。
|
||||
{t('clearDesc', { count: total })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleaning}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={cleaning}>
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={cleaning}
|
||||
@@ -414,7 +438,7 @@ export function ApplyLogsPageClient() {
|
||||
void handleCleanupLogs();
|
||||
}}
|
||||
>
|
||||
{cleaning ? '清空中...' : '确认清空'}
|
||||
{cleaning ? t('clearing') : t('confirmClear')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -35,6 +35,8 @@ import { CertificateDetailDialog } from '../websites/components/certificate-deta
|
||||
import { CertificateEditorDialog } from '../websites/components/certificate-editor-dialog';
|
||||
import { CertificateImportDialog } from '../websites/components/certificate-import-dialog';
|
||||
import { WebsiteStatusBadge } from '../websites/components/status-badge';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
getCertificateStatus,
|
||||
getErrorMessage,
|
||||
@@ -45,6 +47,8 @@ const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
type CertificateApplyMode = 'edit-acme' | 'convert-upload';
|
||||
|
||||
export default function CertificatesPage() {
|
||||
const t = useTranslations('certificates');
|
||||
const tc = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [applyOpen, setApplyOpen] = useState(false);
|
||||
@@ -68,20 +72,20 @@ export default function CertificatesPage() {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('证书已删除');
|
||||
toast.success(t('deleted'));
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: (id: number) => TlsCertificateService.renew(id),
|
||||
onSuccess: async (cert) => {
|
||||
toast.success(`证书 ${cert.name} 续期任务已提交`);
|
||||
toast.success(t('renewSubmitted', { name: cert.name }));
|
||||
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const certificates = useMemo(
|
||||
@@ -105,7 +109,7 @@ export default function CertificatesPage() {
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileKey className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>证书</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
@@ -119,7 +123,7 @@ export default function CertificatesPage() {
|
||||
}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
刷新证书
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -127,7 +131,7 @@ export default function CertificatesPage() {
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
导入证书
|
||||
{t('importCert')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -135,28 +139,29 @@ export default function CertificatesPage() {
|
||||
onClick={() => setApplyOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
申请证书
|
||||
{t('applyTitle')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base font-semibold'>证书列表</CardTitle>
|
||||
<CardDescription>
|
||||
展示证书有效期、备注和状态,支持查看详情、编辑内容或删除证书。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-base font-semibold'>{t('listTitle')}</CardTitle>
|
||||
<CardDescription>{t('listDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{certificatesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={FileKey}
|
||||
description='加载证书列表中...'
|
||||
description={t('loadingList')}
|
||||
/>
|
||||
) : certificatesQuery.isError ? (
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(certificatesQuery.error)}
|
||||
message={getErrorMessage(
|
||||
certificatesQuery.error,
|
||||
t('requestFailed'),
|
||||
)}
|
||||
onRetry={() => void certificatesQuery.refetch()}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -164,12 +169,12 @@ export default function CertificatesPage() {
|
||||
) : certificates.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={FileKey}
|
||||
description='暂无证书,点击右上角「导入证书」或「申请证书」开始录入。'
|
||||
description={t('emptyList')}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{certificates.map((certificate) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
const status = getCertificateStatus(certificate, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -188,32 +193,50 @@ export default function CertificatesPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className='text-xs leading-5 text-muted-foreground space-y-0.5'>
|
||||
<p>生效:{formatDateTime(certificate.not_before)}</p>
|
||||
<p>到期:{formatDateTime(certificate.not_after)}</p>
|
||||
<p>
|
||||
来源:
|
||||
{certificate.provider === 'acme'
|
||||
? 'ACME 申请'
|
||||
: '手动上传'}
|
||||
{t('notBeforeLabel', {
|
||||
value: formatDateTime(certificate.not_before),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('notAfterLabel', {
|
||||
value: formatDateTime(certificate.not_after),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('sourceLabel', {
|
||||
value:
|
||||
certificate.provider === 'acme'
|
||||
? t('sourceAcme')
|
||||
: t('sourceUpload'),
|
||||
})}
|
||||
</p>
|
||||
{certificate.apply_status === 'applying' ? (
|
||||
<p className='text-blue-600'>
|
||||
状态:
|
||||
{certificate.provider === 'upload'
|
||||
? '转换申请中...'
|
||||
: '申请中...'}
|
||||
{t('statusLabel', {
|
||||
value:
|
||||
certificate.provider === 'upload'
|
||||
? t('converting')
|
||||
: t('applying'),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{certificate.apply_status === 'error' ? (
|
||||
<p className='text-destructive'>
|
||||
状态:
|
||||
{certificate.provider === 'upload'
|
||||
? '转换失败'
|
||||
: '申请失败'}
|
||||
({certificate.apply_message})
|
||||
{t('statusError', {
|
||||
value:
|
||||
certificate.provider === 'upload'
|
||||
? t('convertFailed')
|
||||
: t('applyFailed'),
|
||||
message: certificate.apply_message,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p>备注:{certificate.remark || '暂无备注'}</p>
|
||||
<p>
|
||||
{t('remarkLabel', {
|
||||
value: certificate.remark || t('noRemark'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +250,7 @@ export default function CertificatesPage() {
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
{t('view')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -235,7 +258,7 @@ export default function CertificatesPage() {
|
||||
className='h-7 text-xs'
|
||||
onClick={() => handleOpenEditor(certificate)}
|
||||
>
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
{certificate.provider === 'acme' ? (
|
||||
<Button
|
||||
@@ -245,7 +268,7 @@ export default function CertificatesPage() {
|
||||
disabled={renewMutation.isPending}
|
||||
onClick={() => renewMutation.mutate(certificate.id)}
|
||||
>
|
||||
续期
|
||||
{t('renew')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -270,7 +293,7 @@ export default function CertificatesPage() {
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={(certificate) =>
|
||||
toast.success(`证书 ${certificate.name} 已导入`)
|
||||
toast.success(t('imported', { name: certificate.name }))
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -278,7 +301,7 @@ export default function CertificatesPage() {
|
||||
open={applyOpen && !applyCertificate}
|
||||
onOpenChange={setApplyOpen}
|
||||
onApplied={(certificate) =>
|
||||
toast.success(`证书 ${certificate.name} 申请任务已提交`)
|
||||
toast.success(t('applySubmitted', { name: certificate.name }))
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -295,8 +318,8 @@ export default function CertificatesPage() {
|
||||
setApplyCertificate(null);
|
||||
toast.success(
|
||||
applyMode === 'convert-upload'
|
||||
? `证书 ${certificate.name} 转换申请已提交`
|
||||
: `证书 ${certificate.name} 配置已更新,重新申请中...`,
|
||||
? t('convertSubmitted', { name: certificate.name })
|
||||
: t('reapplySubmitted', { name: certificate.name }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -326,7 +349,7 @@ export default function CertificatesPage() {
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
onSaved={(certificate) =>
|
||||
toast.success(`证书 ${certificate.name} 已更新`)
|
||||
toast.success(t('updated', { name: certificate.name }))
|
||||
}
|
||||
onConvert={(certificate) => {
|
||||
setEditorOpen(false);
|
||||
@@ -342,20 +365,20 @@ export default function CertificatesPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除证书 {deleteTarget?.name} 吗?
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
删除
|
||||
{tc('delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -43,6 +44,8 @@ export function GroupDialog({
|
||||
pending: boolean;
|
||||
onSubmit: (payload: CloudflareGroupPayload) => void;
|
||||
}) {
|
||||
const t = useTranslations('cloudflare.groupDialog');
|
||||
const tCommon = useTranslations('common');
|
||||
const edgeNodes = useMemo(
|
||||
() => nodes.filter((node) => node.node_type === 'edge_node'),
|
||||
[nodes],
|
||||
@@ -66,14 +69,14 @@ export function GroupDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{group ? '编辑指向分组' : '新增指向分组'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
一期使用主节点作为生效节点;备用节点仅保存,不会自动切换。
|
||||
</DialogDescription>
|
||||
<DialogTitle>
|
||||
{group ? t('editTitle') : t('createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-group-name'>分组名称</FieldLabel>
|
||||
<FieldLabel htmlFor='cf-group-name'>{t('name')}</FieldLabel>
|
||||
<Input
|
||||
id='cf-group-name'
|
||||
value={name}
|
||||
@@ -81,10 +84,10 @@ export function GroupDialog({
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-primary-node'>主节点</FieldLabel>
|
||||
<FieldLabel htmlFor='cf-primary-node'>{t('primary')}</FieldLabel>
|
||||
<Select value={primaryNodeID} onValueChange={setPrimaryNodeID}>
|
||||
<SelectTrigger id='cf-primary-node' className='w-full'>
|
||||
<SelectValue placeholder='选择带 IPv4 的边缘节点' />
|
||||
<SelectValue placeholder={t('primaryPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -94,7 +97,7 @@ export function GroupDialog({
|
||||
value={String(node.id)}
|
||||
disabled={!node.ip}
|
||||
>
|
||||
{node.name} · {node.ip || '未配置 IP'}
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -102,19 +105,19 @@ export function GroupDialog({
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-backup-node'>备用节点</FieldLabel>
|
||||
<FieldLabel htmlFor='cf-backup-node'>{t('backup')}</FieldLabel>
|
||||
<Select value={backupNodeID} onValueChange={setBackupNodeID}>
|
||||
<SelectTrigger id='cf-backup-node' className='w-full'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='none'>不设置</SelectItem>
|
||||
<SelectItem value='none'>{t('none')}</SelectItem>
|
||||
{edgeNodes
|
||||
.filter((node) => String(node.id) !== primaryNodeID)
|
||||
.map((node) => (
|
||||
<SelectItem key={node.id} value={String(node.id)}>
|
||||
{node.name} · {node.ip || '未配置 IP'}
|
||||
{node.name} · {node.ip || t('noIp')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -123,7 +126,7 @@ export function GroupDialog({
|
||||
</Field>
|
||||
<Field orientation='horizontal'>
|
||||
<FieldLabel htmlFor='cf-default-proxied'>
|
||||
新成员默认开启橙云
|
||||
{t('defaultProxied')}
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
id='cf-default-proxied'
|
||||
@@ -132,7 +135,7 @@ export function GroupDialog({
|
||||
/>
|
||||
</Field>
|
||||
<Field orientation='horizontal'>
|
||||
<FieldLabel htmlFor='cf-enabled'>启用同步</FieldLabel>
|
||||
<FieldLabel htmlFor='cf-enabled'>{t('enableSync')}</FieldLabel>
|
||||
<Switch
|
||||
id='cf-enabled'
|
||||
checked={enabled}
|
||||
@@ -142,7 +145,7 @@ export function GroupDialog({
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending || !name.trim() || !primaryNodeID}
|
||||
@@ -157,7 +160,7 @@ export function GroupDialog({
|
||||
})
|
||||
}
|
||||
>
|
||||
保存
|
||||
{t('save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
@@ -72,6 +73,8 @@ export function MemberAddDialog({
|
||||
pending: boolean;
|
||||
onSubmit: (zoneDomainIDs: number[], proxied: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations('cloudflare.memberDialog');
|
||||
const tCommon = useTranslations('common');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
|
||||
const [proxied, setProxied] = useState(defaultProxied);
|
||||
@@ -157,15 +160,14 @@ export function MemberAddDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加域名成员</DialogTitle>
|
||||
<DialogDescription>
|
||||
支持搜索筛选与批量勾选;加入后会创建或接管唯一同名 A 记录,多条同名
|
||||
A 会拒绝同步。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-domain-search'>Zone 域名</FieldLabel>
|
||||
<FieldLabel htmlFor='cf-domain-search'>
|
||||
{t('zoneDomain')}
|
||||
</FieldLabel>
|
||||
<div className='space-y-2'>
|
||||
<div className='relative'>
|
||||
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||
@@ -173,7 +175,7 @@ export function MemberAddDialog({
|
||||
id='cf-domain-search'
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder='搜索域名或顶级域…'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className='pl-8'
|
||||
disabled={pending}
|
||||
/>
|
||||
@@ -182,9 +184,9 @@ export function MemberAddDialog({
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
<Badge variant='secondary' className='font-normal'>
|
||||
已选 {selectedIDs.length}
|
||||
{t('selected', { count: selectedIDs.length })}
|
||||
</Badge>
|
||||
<span>可见 {visibleIDs.length}</span>
|
||||
<span>{t('visible', { count: visibleIDs.length })}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
@@ -195,7 +197,7 @@ export function MemberAddDialog({
|
||||
disabled={pending || visibleIDs.length === 0}
|
||||
onClick={toggleAllVisible}
|
||||
>
|
||||
{allVisibleSelected ? '取消全选' : '全选可见'}
|
||||
{allVisibleSelected ? t('unselectAll') : t('selectVisible')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -205,18 +207,18 @@ export function MemberAddDialog({
|
||||
disabled={pending || selectedIDs.length === 0}
|
||||
onClick={() => setSelectedIDs([])}
|
||||
>
|
||||
清空
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{domains.length === 0 ? (
|
||||
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
|
||||
暂无可用域名(均已加入分组,或尚未在网站管理中添加)。
|
||||
{t('noAvailable')}
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className='rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground'>
|
||||
没有匹配的域名
|
||||
{t('noMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-72 space-y-1 overflow-y-auto rounded-lg border p-2'>
|
||||
@@ -233,7 +235,7 @@ export function MemberAddDialog({
|
||||
disabled={pending}
|
||||
onCheckedChange={toggleAllVisible}
|
||||
/>
|
||||
<span>按顶级域分组</span>
|
||||
<span>{t('groupByZone')}</span>
|
||||
</label>
|
||||
|
||||
{filteredGroups.map((group) => {
|
||||
@@ -270,7 +272,9 @@ export function MemberAddDialog({
|
||||
}
|
||||
disabled={pending}
|
||||
onCheckedChange={() => toggleGroup(group)}
|
||||
aria-label={`选择顶级域 ${group.zoneDomain}`}
|
||||
aria-label={t('selectZone', {
|
||||
name: group.zoneDomain,
|
||||
})}
|
||||
className='ml-1'
|
||||
/>
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -330,7 +334,7 @@ export function MemberAddDialog({
|
||||
variant='secondary'
|
||||
className='shrink-0 text-[10px] font-normal'
|
||||
>
|
||||
顶级域
|
||||
{t('zone')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{checked ? (
|
||||
@@ -350,7 +354,7 @@ export function MemberAddDialog({
|
||||
</div>
|
||||
</Field>
|
||||
<Field orientation='horizontal'>
|
||||
<FieldLabel htmlFor='cf-member-proxied'>开启橙云代理</FieldLabel>
|
||||
<FieldLabel htmlFor='cf-member-proxied'>{t('proxied')}</FieldLabel>
|
||||
<Switch
|
||||
id='cf-member-proxied'
|
||||
checked={proxied}
|
||||
@@ -365,7 +369,7 @@ export function MemberAddDialog({
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={pending || selectedIDs.length === 0}
|
||||
@@ -377,8 +381,8 @@ export function MemberAddDialog({
|
||||
}
|
||||
>
|
||||
{selectedIDs.length > 1
|
||||
? `添加并同步(${selectedIDs.length})`
|
||||
: '添加并同步'}
|
||||
? t('submitCount', { count: selectedIDs.length })
|
||||
: t('submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -109,20 +110,6 @@ async function listCloudflareSyncExecutions(options: {
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
|
||||
pending: '等待中',
|
||||
running: '执行中',
|
||||
succeeded: '成功',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
system: '系统',
|
||||
manual: '手动',
|
||||
retry: '重试',
|
||||
schedule: '定时',
|
||||
};
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
@@ -143,6 +130,7 @@ function statusVariant(status: TaskExecutionStatus) {
|
||||
}
|
||||
|
||||
export function SyncTasksPanel() {
|
||||
const t = useTranslations('cloudflare.tasks');
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<TaskExecutionStatus | 'all'>('all');
|
||||
@@ -175,16 +163,16 @@ export function SyncTasksPanel() {
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (id: string) => AdminTaskService.retryTaskExecution(id),
|
||||
onSuccess: (taskID) => {
|
||||
toast.success('同步任务已重新下发', {
|
||||
description: `新任务 ID:${taskID}`,
|
||||
toast.success(t('retried'), {
|
||||
description: t('newTaskId', { id: taskID }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [...cloudflareQueryKey, 'sync-executions'],
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error('任务重试失败', {
|
||||
description: err.message || '未知错误',
|
||||
toast.error(t('retryFailed'), {
|
||||
description: err.message || t('unknownError'),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -197,6 +185,18 @@ export function SyncTasksPanel() {
|
||||
() => Math.max(1, Math.ceil(total / PAGE_SIZE)),
|
||||
[total],
|
||||
);
|
||||
const statusLabels: Record<TaskExecutionStatus, string> = {
|
||||
pending: t('pending'),
|
||||
running: t('running'),
|
||||
succeeded: t('succeeded'),
|
||||
failed: t('failed'),
|
||||
};
|
||||
const triggerLabels: Record<string, string> = {
|
||||
system: t('system'),
|
||||
manual: t('manual'),
|
||||
retry: t('retry'),
|
||||
schedule: t('schedule'),
|
||||
};
|
||||
|
||||
const openDetail = (execution: TaskExecution) => {
|
||||
setPreview(execution);
|
||||
@@ -210,12 +210,9 @@ export function SyncTasksPanel() {
|
||||
<div className='space-y-1'>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Activity className='size-4 text-primary' />
|
||||
同步任务
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
仅展示 Cloudflare
|
||||
域名同步(sync_member)与分组同步(sync_group)的执行记录。
|
||||
</CardDescription>
|
||||
<CardDescription>{t('description')}</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Select
|
||||
@@ -226,14 +223,14 @@ export function SyncTasksPanel() {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size='sm' className='w-[120px]'>
|
||||
<SelectValue placeholder='状态' />
|
||||
<SelectValue placeholder={t('statusPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>全部状态</SelectItem>
|
||||
<SelectItem value='pending'>等待中</SelectItem>
|
||||
<SelectItem value='running'>执行中</SelectItem>
|
||||
<SelectItem value='succeeded'>成功</SelectItem>
|
||||
<SelectItem value='failed'>失败</SelectItem>
|
||||
<SelectItem value='all'>{t('allStatus')}</SelectItem>
|
||||
<SelectItem value='pending'>{t('pending')}</SelectItem>
|
||||
<SelectItem value='running'>{t('running')}</SelectItem>
|
||||
<SelectItem value='succeeded'>{t('succeeded')}</SelectItem>
|
||||
<SelectItem value='failed'>{t('failed')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
@@ -247,7 +244,7 @@ export function SyncTasksPanel() {
|
||||
) : (
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
)}
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -257,32 +254,44 @@ export function SyncTasksPanel() {
|
||||
message={
|
||||
executionsQuery.error instanceof Error
|
||||
? executionsQuery.error.message
|
||||
: '加载同步任务失败'
|
||||
: t('loadFailed')
|
||||
}
|
||||
onRetry={() => void executionsQuery.refetch()}
|
||||
/>
|
||||
) : loading && executions.length === 0 ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Activity}
|
||||
description='加载同步任务中...'
|
||||
description={t('loading')}
|
||||
/>
|
||||
) : executions.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Activity}
|
||||
title='暂无同步任务'
|
||||
description='添加域名或同步分组后,这里会显示 Cloudflare 域名 / 分组同步执行记录。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div className='rounded-lg border'>
|
||||
<Table className='min-w-[720px]'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='w-[200px]'>任务</TableHead>
|
||||
<TableHead className='w-[100px]'>状态</TableHead>
|
||||
<TableHead className='w-[100px]'>触发</TableHead>
|
||||
<TableHead className='w-[100px]'>耗时</TableHead>
|
||||
<TableHead className='min-w-[180px]'>结果/错误</TableHead>
|
||||
<TableHead className='w-[160px]'>创建时间</TableHead>
|
||||
<TableHead className='w-[200px]'>
|
||||
{t('columns.task')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px]'>
|
||||
{t('columns.status')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px]'>
|
||||
{t('columns.trigger')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px]'>
|
||||
{t('columns.duration')}
|
||||
</TableHead>
|
||||
<TableHead className='min-w-[180px]'>
|
||||
{t('columns.result')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[160px]'>
|
||||
{t('columns.createdAt')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -304,12 +313,12 @@ export function SyncTasksPanel() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(execution.status)}>
|
||||
{STATUS_LABELS[execution.status] || execution.status}
|
||||
{statusLabels[execution.status] || execution.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>
|
||||
{TRIGGER_LABELS[execution.triggered_by] ||
|
||||
{triggerLabels[execution.triggered_by] ||
|
||||
execution.triggered_by}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -331,7 +340,7 @@ export function SyncTasksPanel() {
|
||||
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
共 {total} 条,第 {page}/{totalPages} 页
|
||||
{t('pageSummary', { total, page, pages: totalPages })}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
@@ -341,7 +350,7 @@ export function SyncTasksPanel() {
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className='size-4' />
|
||||
上一页
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -349,7 +358,7 @@ export function SyncTasksPanel() {
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
下一页
|
||||
{t('next')}
|
||||
<ChevronRight className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -359,43 +368,51 @@ export function SyncTasksPanel() {
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className='w-full p-0 sm:max-w-[560px]'>
|
||||
<SheetHeader className='border-b'>
|
||||
<SheetTitle>同步任务详情</SheetTitle>
|
||||
<SheetTitle>{t('detailTitle')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{selected?.task_name || selected?.task_type || 'Cloudflare 同步'}
|
||||
{selected?.task_name || selected?.task_type || t('defaultName')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
|
||||
{detailQuery.isFetching && !selected ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Activity}
|
||||
description='加载任务详情中...'
|
||||
description={t('loadingDetail')}
|
||||
/>
|
||||
) : selected ? (
|
||||
<>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>状态</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('status')}
|
||||
</div>
|
||||
<div className='mt-2'>
|
||||
<Badge variant={statusVariant(selected.status)}>
|
||||
{STATUS_LABELS[selected.status] || selected.status}
|
||||
{statusLabels[selected.status] || selected.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>触发</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('trigger')}
|
||||
</div>
|
||||
<div className='mt-2 text-sm font-medium'>
|
||||
{TRIGGER_LABELS[selected.triggered_by] ||
|
||||
{triggerLabels[selected.triggered_by] ||
|
||||
selected.triggered_by}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>重试</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('retries')}
|
||||
</div>
|
||||
<div className='mt-2 font-mono text-sm'>
|
||||
{selected.retry_count}/{selected.max_retry}
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='text-xs text-muted-foreground'>耗时</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('duration')}
|
||||
</div>
|
||||
<div className='mt-2 font-mono text-sm'>
|
||||
{formatDuration(selected.duration)}
|
||||
</div>
|
||||
@@ -403,7 +420,9 @@ export function SyncTasksPanel() {
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground'>任务 ID</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('taskId')}
|
||||
</div>
|
||||
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
|
||||
{selected.task_id}
|
||||
</div>
|
||||
@@ -412,7 +431,7 @@ export function SyncTasksPanel() {
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
创建时间
|
||||
{t('createdAt')}
|
||||
</div>
|
||||
<div className='font-mono text-xs'>
|
||||
{formatDateTime(selected.created_at)}
|
||||
@@ -420,7 +439,7 @@ export function SyncTasksPanel() {
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
结束时间
|
||||
{t('finishedAt')}
|
||||
</div>
|
||||
<div className='font-mono text-xs'>
|
||||
{formatDateTime(selected.finished_at)}
|
||||
@@ -429,7 +448,9 @@ export function SyncTasksPanel() {
|
||||
</div>
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground'>执行结果</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
{t('result')}
|
||||
</div>
|
||||
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
|
||||
{selected.result || '-'}
|
||||
</div>
|
||||
@@ -438,7 +459,7 @@ export function SyncTasksPanel() {
|
||||
{selected.error_message ? (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
错误信息
|
||||
{t('error')}
|
||||
</div>
|
||||
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
|
||||
{selected.error_message}
|
||||
@@ -456,7 +477,7 @@ export function SyncTasksPanel() {
|
||||
{selected.log ? (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
执行日志
|
||||
{t('logs')}
|
||||
</div>
|
||||
<pre className='max-h-48 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
|
||||
{selected.log}
|
||||
@@ -478,11 +499,11 @@ export function SyncTasksPanel() {
|
||||
) : (
|
||||
<RotateCcw data-icon='inline-start' />
|
||||
)}
|
||||
重试
|
||||
{t('retry')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant='outline' onClick={() => setDetailOpen(false)}>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -46,6 +47,7 @@ import { GroupDialog } from '../../components/group-dialog';
|
||||
import { MemberAddDialog } from '../../components/member-add-dialog';
|
||||
|
||||
export function CloudflareGroupDetailPageClient() {
|
||||
const t = useTranslations('cloudflare');
|
||||
const params = useParams<{ id: string }>();
|
||||
const groupID = Number(params.id);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -72,7 +74,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
mutationFn: (payload: CloudflareGroupPayload) =>
|
||||
CloudflareService.updateGroup(groupID, payload),
|
||||
onSuccess: async () => {
|
||||
toast.success('分组配置已更新');
|
||||
toast.success(t('groupUpdated'));
|
||||
setEditOpen(false);
|
||||
await invalidate();
|
||||
},
|
||||
@@ -102,13 +104,13 @@ export function CloudflareGroupDetailPageClient() {
|
||||
if (failed === 0) {
|
||||
toast.success(
|
||||
total === 1
|
||||
? '域名已加入并排队同步'
|
||||
: `已添加 ${succeeded} 个域名并排队同步`,
|
||||
? t('memberAddedOne')
|
||||
: t('memberAddedMany', { count: succeeded }),
|
||||
);
|
||||
} else if (succeeded === 0) {
|
||||
toast.error(`添加失败:${failed} 个域名未能加入`);
|
||||
toast.error(t('memberAddFailed', { count: failed }));
|
||||
} else {
|
||||
toast.warning(`部分成功:${succeeded} 个已加入,${failed} 个失败`);
|
||||
toast.warning(t('memberPartial', { succeeded, failed }));
|
||||
}
|
||||
setAddOpen(false);
|
||||
await invalidate();
|
||||
@@ -124,7 +126,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
proxied: boolean;
|
||||
}) => CloudflareService.updateMember(groupID, memberID, proxied),
|
||||
onSuccess: async () => {
|
||||
toast.success('橙云设置已更新并排队同步');
|
||||
toast.success(t('proxyUpdated'));
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
@@ -132,14 +134,14 @@ export function CloudflareGroupDetailPageClient() {
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (memberID: number) =>
|
||||
CloudflareService.syncMember(groupID, memberID),
|
||||
onSuccess: () => toast.success('成员同步任务已入队'),
|
||||
onSuccess: () => toast.success(t('memberSyncQueued')),
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (memberID: number) =>
|
||||
CloudflareService.removeMember(groupID, memberID),
|
||||
onSuccess: async () => {
|
||||
toast.success('成员及远端 A 记录已删除');
|
||||
toast.success(t('memberDeleted'));
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
@@ -148,7 +150,10 @@ export function CloudflareGroupDetailPageClient() {
|
||||
if (detailQuery.isLoading)
|
||||
return (
|
||||
<div className='w-full py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={Cloud} description='加载分组详情中...' />
|
||||
<LoadingStateWithBorder
|
||||
icon={Cloud}
|
||||
description={t('loadingDetail')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (detailQuery.isError || !detailQuery.data)
|
||||
@@ -168,7 +173,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
<Button variant='ghost' size='sm' className='self-start' asChild>
|
||||
<Link href='/cloudflare'>
|
||||
<ArrowLeft data-icon='inline-start' />
|
||||
返回列表
|
||||
{t('back')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
@@ -190,7 +195,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
) : (
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
)}
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -198,11 +203,11 @@ export function CloudflareGroupDetailPageClient() {
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
<Settings data-icon='inline-start' />
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button size='sm' onClick={() => setAddOpen(true)}>
|
||||
<Plus data-icon='inline-start' />
|
||||
添加域名
|
||||
{t('addDomain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,41 +215,50 @@ export function CloudflareGroupDetailPageClient() {
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>当前指向</CardTitle>
|
||||
<CardTitle className='text-base'>{t('currentPoint')}</CardTitle>
|
||||
<CardDescription>
|
||||
生效节点 {group.active_node.name} · {group.active_node.ip}
|
||||
{t('activeNode', {
|
||||
name: group.active_node.name,
|
||||
ip: group.active_node.ip,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-wrap gap-2'>
|
||||
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||
{group.enabled ? '同步已启用' : '同步已停用'}
|
||||
{group.enabled ? t('syncEnabled') : t('syncDisabled')}
|
||||
</Badge>
|
||||
<Badge variant='outline'>主节点 {group.primary_node.name}</Badge>
|
||||
<Badge variant='outline'>
|
||||
备用节点 {group.backup_node?.name ?? '未设置'}
|
||||
{t('primaryNode', { name: group.primary_node.name })}
|
||||
</Badge>
|
||||
<Badge variant='outline'>
|
||||
{t('backupNode', {
|
||||
name: group.backup_node?.name ?? t('backupUnset'),
|
||||
})}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>域名成员</CardTitle>
|
||||
<CardDescription>成员级橙云是同步时的唯一依据。</CardDescription>
|
||||
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
||||
<CardDescription>{t('membersDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{members.length === 0 ? (
|
||||
<p className='py-8 text-center text-sm text-muted-foreground'>
|
||||
暂无域名成员。
|
||||
{t('noMembers')}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>域名</TableHead>
|
||||
<TableHead>期望 IP</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>橙云</TableHead>
|
||||
<TableHead className='text-right'>操作</TableHead>
|
||||
<TableHead>{t('columns.domain')}</TableHead>
|
||||
<TableHead>{t('columns.desiredIp')}</TableHead>
|
||||
<TableHead>{t('columns.status')}</TableHead>
|
||||
<TableHead>{t('columns.proxied')}</TableHead>
|
||||
<TableHead className='text-right'>
|
||||
{t('columns.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -258,7 +272,9 @@ export function CloudflareGroupDetailPageClient() {
|
||||
</p>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{member.desired_ip || '待同步'}</TableCell>
|
||||
<TableCell>
|
||||
{member.desired_ip || t('pendingSync')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -291,7 +307,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
onClick={() => syncMutation.mutate(member.id)}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
同步
|
||||
{t('sync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -299,7 +315,7 @@ export function CloudflareGroupDetailPageClient() {
|
||||
onClick={() => removeMutation.mutate(member.id)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
移出
|
||||
{t('remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense } from 'react';
|
||||
import { Cloud } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
|
||||
@@ -11,13 +12,14 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export default async function CloudflareGroupDetailPage() {
|
||||
const t = await getTranslations('cloudflare');
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='w-full py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={Cloud}
|
||||
description='加载分组详情中...'
|
||||
description={t('loadingDetail')}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -48,6 +49,8 @@ import {
|
||||
import { getErrorMessage } from '../websites/components/website-utils';
|
||||
|
||||
export default function CloudflarePage() {
|
||||
const t = useTranslations('cloudflare');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudflareGroup | null>(
|
||||
@@ -72,7 +75,7 @@ export default function CloudflarePage() {
|
||||
mutationFn: (payload: CloudflareGroupPayload) =>
|
||||
CloudflareService.createGroup(payload),
|
||||
onSuccess: async () => {
|
||||
toast.success('指向分组已创建');
|
||||
toast.success(t('created'));
|
||||
setCreateOpen(false);
|
||||
await invalidate();
|
||||
},
|
||||
@@ -81,7 +84,7 @@ export default function CloudflarePage() {
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (id: number) => CloudflareService.syncGroup(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('分组同步任务已入队');
|
||||
toast.success(t('syncQueued'));
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [...cloudflareQueryKey, 'sync-executions'],
|
||||
});
|
||||
@@ -91,7 +94,7 @@ export default function CloudflarePage() {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => CloudflareService.deleteGroup(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('指向分组及远端记录已删除');
|
||||
toast.success(t('deleted'));
|
||||
setDeleteTarget(null);
|
||||
await invalidate();
|
||||
},
|
||||
@@ -105,18 +108,20 @@ export default function CloudflarePage() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Cloud className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>Cloudflare</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild variant='outline' size='sm'>
|
||||
<Link href='/cloudflare/settings'>
|
||||
<Settings data-icon='inline-start' />
|
||||
连接设置
|
||||
{t('connectionSettings')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size='sm' onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon='inline-start' />
|
||||
新增分组
|
||||
{t('addGroup')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +129,7 @@ export default function CloudflarePage() {
|
||||
{overviewQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Cloud}
|
||||
description='加载 Cloudflare 总览中...'
|
||||
description={t('loadingOverview')}
|
||||
/>
|
||||
) : overviewQuery.isError ? (
|
||||
<ErrorInline
|
||||
@@ -134,14 +139,11 @@ export default function CloudflarePage() {
|
||||
) : !overview?.connection.ready ? (
|
||||
<Alert>
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Cloudflare 连接尚未就绪</AlertTitle>
|
||||
<AlertTitle>{t('notReadyTitle')}</AlertTitle>
|
||||
<AlertDescription className='flex flex-col items-start gap-3'>
|
||||
<span>
|
||||
请先导入现有 Cloudflare DNS 账号,或录入独立 API Token
|
||||
并完成连接测试。
|
||||
</span>
|
||||
<span>{t('notReadyDesc')}</span>
|
||||
<Button asChild size='sm'>
|
||||
<Link href='/cloudflare/settings'>配置连接</Link>
|
||||
<Link href='/cloudflare/settings'>{t('configure')}</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -149,16 +151,14 @@ export default function CloudflarePage() {
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold'>指向分组</h2>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
管理 Cloudflare A 记录对应的节点与域名成员。
|
||||
</p>
|
||||
<h2 className='text-lg font-semibold'>{t('groupsTitle')}</h2>
|
||||
<p className='text-sm text-muted-foreground'>{t('groupsDesc')}</p>
|
||||
</div>
|
||||
|
||||
{groupsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Cloud}
|
||||
description='加载指向分组中...'
|
||||
description={t('loadingGroups')}
|
||||
/>
|
||||
) : groupsQuery.isError ? (
|
||||
<ErrorInline
|
||||
@@ -168,9 +168,9 @@ export default function CloudflarePage() {
|
||||
) : (groupsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Cloud}
|
||||
title='暂无指向分组'
|
||||
description='创建分组后,可将域名成员同步到指定的边缘节点。'
|
||||
actionText='新增分组'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
actionText={t('addGroup')}
|
||||
onAction={() => setCreateOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
@@ -186,20 +186,22 @@ export default function CloudflarePage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={group.enabled ? 'default' : 'secondary'}>
|
||||
{group.enabled ? '已启用' : '已停用'}
|
||||
{group.enabled ? t('enabled') : t('disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-4'>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
成员 {group.member_count} 个 · 新成员默认
|
||||
{group.default_proxied ? '开启' : '关闭'}橙云
|
||||
{t('memberSummary', {
|
||||
count: group.member_count,
|
||||
proxy: group.default_proxied ? t('proxyOn') : t('proxyOff'),
|
||||
})}
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button asChild size='sm'>
|
||||
<Link href={`/cloudflare/groups/${group.id}`}>
|
||||
<Settings data-icon='inline-start' />
|
||||
管理
|
||||
{t('manage')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -208,7 +210,7 @@ export default function CloudflarePage() {
|
||||
onClick={() => syncMutation.mutate(group.id)}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
同步
|
||||
{t('sync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -216,7 +218,7 @@ export default function CloudflarePage() {
|
||||
onClick={() => setDeleteTarget(group)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -242,20 +244,19 @@ export default function CloudflarePage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除指向分组</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将删除 {deleteTarget?.name} 的全部成员及本模块管理的远端 A
|
||||
记录。此操作不可撤销。
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
确认删除
|
||||
{t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Cloud, Save, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -42,6 +43,7 @@ import { getErrorMessage } from '../../websites/components/website-utils';
|
||||
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'] as const;
|
||||
|
||||
export default function CloudflareSettingsPage() {
|
||||
const t = useTranslations('cloudflare.settings');
|
||||
const queryClient = useQueryClient();
|
||||
const [source, setSource] =
|
||||
useState<CloudflareConnectionSource>('dns_account');
|
||||
@@ -85,7 +87,7 @@ export default function CloudflareSettingsPage() {
|
||||
api_token: source === 'standalone' ? apiToken : '',
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Cloudflare 连接配置已保存');
|
||||
toast.success(t('saved'));
|
||||
setAPIToken('');
|
||||
await refresh();
|
||||
},
|
||||
@@ -95,7 +97,7 @@ export default function CloudflareSettingsPage() {
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: () => CloudflareService.verifyConnection(),
|
||||
onSuccess: async () => {
|
||||
toast.success('Cloudflare 连接验证成功');
|
||||
toast.success(t('verified'));
|
||||
await refresh();
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
@@ -104,7 +106,7 @@ export default function CloudflareSettingsPage() {
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => CloudflareService.clearConnection(),
|
||||
onSuccess: async () => {
|
||||
toast.success('Cloudflare 连接已清除');
|
||||
toast.success(t('cleared'));
|
||||
setDNSAccountID('');
|
||||
setAPIToken('');
|
||||
await refresh();
|
||||
@@ -118,13 +120,13 @@ export default function CloudflareSettingsPage() {
|
||||
<Button variant='ghost' size='sm' className='self-start' asChild>
|
||||
<Link href='/cloudflare'>
|
||||
<ArrowLeft data-icon='inline-start' />
|
||||
返回列表
|
||||
{t('back')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Cloud className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
Cloudflare 连接设置
|
||||
{t('title')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,16 +140,15 @@ export default function CloudflareSettingsPage() {
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>凭据来源</CardTitle>
|
||||
<CardTitle className='text-base'>{t('sourceTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
Token 建议授予 Zone:Read 与 DNS:Edit 权限;Token 不会在 API
|
||||
或页面中回显。
|
||||
{t('sourceDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-source'>连接来源</FieldLabel>
|
||||
<FieldLabel htmlFor='cf-source'>{t('source')}</FieldLabel>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(value) =>
|
||||
@@ -160,9 +161,11 @@ export default function CloudflareSettingsPage() {
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value='dns_account'>
|
||||
导入现有 DNS 账号
|
||||
{t('importDns')}
|
||||
</SelectItem>
|
||||
<SelectItem value='standalone'>
|
||||
{t('standalone')}
|
||||
</SelectItem>
|
||||
<SelectItem value='standalone'>独立 API Token</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -171,11 +174,11 @@ export default function CloudflareSettingsPage() {
|
||||
{source === 'dns_account' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor='cf-dns-account'>
|
||||
Cloudflare DNS 账号
|
||||
{t('dnsAccount')}
|
||||
</FieldLabel>
|
||||
<Select value={dnsAccountID} onValueChange={setDNSAccountID}>
|
||||
<SelectTrigger id='cf-dns-account' className='w-full'>
|
||||
<SelectValue placeholder='选择 DNS 账号' />
|
||||
<SelectValue placeholder={t('selectAccount')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -188,8 +191,9 @@ export default function CloudflareSettingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldDescription>
|
||||
仅显示类型为 cloudflare 的 DNS 账号。可前往{' '}
|
||||
<Link href='/dns-accounts'>DNS 账号</Link> 新增。
|
||||
{t('dnsHint')}{' '}
|
||||
<Link href='/dns-accounts'>{t('dnsLink')}</Link>
|
||||
{t('dnsHintAfter')}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
) : (
|
||||
@@ -203,8 +207,8 @@ export default function CloudflareSettingsPage() {
|
||||
onChange={(event) => setAPIToken(event.target.value)}
|
||||
placeholder={
|
||||
connectionQuery.data?.configured
|
||||
? '留空不会回显现有 Token;保存将替换'
|
||||
: '输入 Cloudflare API Token'
|
||||
? t('tokenKeep')
|
||||
: t('tokenEnter')
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
@@ -219,7 +223,7 @@ export default function CloudflareSettingsPage() {
|
||||
}
|
||||
>
|
||||
<Save data-icon='inline-start' />
|
||||
保存配置
|
||||
{t('save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -229,7 +233,7 @@ export default function CloudflareSettingsPage() {
|
||||
}
|
||||
>
|
||||
<ShieldCheck data-icon='inline-start' />
|
||||
测试连接
|
||||
{t('test')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -239,7 +243,7 @@ export default function CloudflareSettingsPage() {
|
||||
}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
清除连接
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
@@ -248,11 +252,11 @@ export default function CloudflareSettingsPage() {
|
||||
|
||||
<Alert>
|
||||
<ShieldCheck />
|
||||
<AlertTitle>当前状态</AlertTitle>
|
||||
<AlertTitle>{t('statusTitle')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{connectionQuery.data?.ready
|
||||
? 'Token 已验证,Cloudflare 指向同步可以执行。'
|
||||
: '保存配置后仍需测试连接;未验证状态下同步任务会被拒绝。'}
|
||||
? t('ready')
|
||||
: t('notReady')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,18 +16,23 @@ import { formatPercent, formatTrendHour } from './dashboard-utils';
|
||||
|
||||
export function CapacityTrendChart({
|
||||
points,
|
||||
title = '24 小时容量趋势',
|
||||
description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。',
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
points: CapacityTrendPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const t = useTranslations('dashboard.capacityTrend');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{title ?? t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{description ?? t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
@@ -35,7 +42,7 @@ export function CapacityTrendChart({
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
label: t('avgCpu'),
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
@@ -43,7 +50,7 @@ export function CapacityTrendChart({
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
label: t('avgMemory'),
|
||||
color: '#2563eb',
|
||||
values: points.map((point) => point.average_memory_usage_percent),
|
||||
valueFormatter: formatPercent,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -25,19 +27,20 @@ export function SourceDistributionChart({
|
||||
}: {
|
||||
items: TrafficDistributions['source_countries'];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.distributions');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>来源分布</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
聚合最近 24 小时主要来源国家。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('sourceTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('sourceDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items)}
|
||||
color='#38bdf8'
|
||||
emptyMessage='暂无来源分布数据'
|
||||
emptyMessage={t('sourceEmpty')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -49,22 +52,23 @@ export function StatusCodeDistributionChart({
|
||||
}: {
|
||||
items: TrafficDistributions['status_codes'];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.distributions');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>状态码分布</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
快速判断成功响应是否仍是主流,以及错误码是否有抬升。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('statusTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('statusDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items).map((item) => ({
|
||||
...item,
|
||||
label: `HTTP ${item.label}`,
|
||||
label: t('httpLabel', { code: item.label }),
|
||||
}))}
|
||||
color='#f59e0b'
|
||||
emptyMessage='暂无状态码分布'
|
||||
emptyMessage={t('statusEmpty')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -76,19 +80,20 @@ export function TopDomainChart({
|
||||
}: {
|
||||
items: TrafficDistributions['top_domains'];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.distributions');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>Top Domain</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
观察主要流量集中在哪些域名。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{t('domainTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('domainDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RankChart
|
||||
items={toRankItems(items)}
|
||||
color='#34d399'
|
||||
emptyMessage='暂无域名分布'
|
||||
emptyMessage={t('domainEmpty')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
import { formatCompactNumber, formatPercent } from './dashboard-utils';
|
||||
|
||||
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
const t = useTranslations('dashboard.nodeHealth');
|
||||
const tn = useTranslations('nodes');
|
||||
const sortedNodes = [...nodes].sort((left, right) => {
|
||||
if (right.active_event_count !== left.active_event_count) {
|
||||
return right.active_event_count - left.active_event_count;
|
||||
@@ -54,33 +57,33 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle className='text-sm font-semibold'>节点健康列表</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
按异常数量和资源压力排序。
|
||||
</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
|
||||
<CardDescription className='text-xs'>{t('description')}</CardDescription>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' className='h-8 text-xs' asChild>
|
||||
<Link href='/nodes'>进入节点页</Link>
|
||||
<Link href='/nodes'>{t('openNodes')}</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedNodes.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无节点'
|
||||
description='节点接入后,这里会展示系统健康与容量摘要。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div className='border border-dashed rounded-lg overflow-hidden'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='py-2 h-8'>节点</TableHead>
|
||||
<TableHead className='py-2 h-8'>状态</TableHead>
|
||||
<TableHead className='py-2 h-8'>CPU / 内存</TableHead>
|
||||
<TableHead className='py-2 h-8'>请求 / 错误</TableHead>
|
||||
<TableHead className='py-2 h-8'>活动异常</TableHead>
|
||||
<TableHead className='py-2 h-8'>最近心跳</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colNode')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colStatus')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colCpuMem')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colReqErr')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colEvents')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('colHeartbeat')}</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -96,18 +99,21 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
{node.node_id}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{node.geo_name || '未配置地图点位'}
|
||||
{node.geo_name || t('noGeo')}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='py-3'>
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
label={getNodeStatusLabel(node.status, tn)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
<NodeStatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
label={getOpenrestyStatusLabel(
|
||||
node.openresty_status,
|
||||
tn,
|
||||
)}
|
||||
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||
/>
|
||||
</div>
|
||||
@@ -130,10 +136,10 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
</TableCell>
|
||||
<TableCell className='py-3 text-xs text-muted-foreground'>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
? t('wsConnected')
|
||||
: node.last_seen_at
|
||||
? formatRelativeTime(node.last_seen_at)
|
||||
: '暂无'}
|
||||
? formatRelativeTime(node.last_seen_at, tn)
|
||||
: t('na')}
|
||||
</TableCell>
|
||||
<TableCell className='py-3 text-right'>
|
||||
<Button
|
||||
@@ -142,7 +148,9 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
className='h-8 text-xs'
|
||||
asChild
|
||||
>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>
|
||||
{t('detail')}
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { DashboardNodeHealth } from '@/lib/services/openflare';
|
||||
@@ -27,25 +29,26 @@ function buildNodeRankItems(
|
||||
}
|
||||
|
||||
export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
const t = useTranslations('dashboard.nodeRank');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>Top 节点榜单</CardTitle>
|
||||
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-6'>
|
||||
<div>
|
||||
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
|
||||
流量最高节点
|
||||
{t('traffic')}
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(nodes, (node) => node.request_count)}
|
||||
color='#38bdf8'
|
||||
emptyMessage='暂无流量榜单'
|
||||
emptyMessage={t('trafficEmpty')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
|
||||
容量压力节点
|
||||
{t('pressure')}
|
||||
</p>
|
||||
<RankChart
|
||||
items={buildNodeRankItems(nodes, (node) =>
|
||||
@@ -59,7 +62,7 @@ export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
|
||||
)}
|
||||
color='#ef4444'
|
||||
valueFormatter={(value) => `${value}%`}
|
||||
emptyMessage='暂无容量压力数据'
|
||||
emptyMessage={t('pressureEmpty')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
@@ -21,6 +22,7 @@ export function DashboardStatCards({
|
||||
traffic: DashboardTraffic;
|
||||
capacity: DashboardCapacity;
|
||||
}) {
|
||||
const t = useTranslations('dashboard.stats');
|
||||
const onlineRate =
|
||||
summary.total_nodes > 0
|
||||
? (summary.online_nodes / summary.total_nodes) * 100
|
||||
@@ -31,7 +33,7 @@ export function DashboardStatCards({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
24h 请求量
|
||||
{t('requests24h')}
|
||||
</span>
|
||||
<Activity className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -40,9 +42,11 @@ export function DashboardStatCards({
|
||||
{formatCompactNumber(traffic.request_count)}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
24h 独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
|
||||
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
|
||||
{traffic.estimated_qps.toFixed(2)}
|
||||
{t('requestsMeta', {
|
||||
visitors: formatCompactNumber(traffic.unique_visitors),
|
||||
errors: formatCompactNumber(traffic.error_count),
|
||||
qps: traffic.estimated_qps.toFixed(2),
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -50,7 +54,7 @@ export function DashboardStatCards({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
集群容量
|
||||
{t('clusterCapacity')}
|
||||
</span>
|
||||
<Cpu className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -66,9 +70,12 @@ export function DashboardStatCards({
|
||||
className='h-1.5'
|
||||
/>
|
||||
<p className='text-[10px] text-muted-foreground'>
|
||||
平均内存 {formatPercent(capacity.average_memory_usage_percent)} ·
|
||||
高负载节点 CPU {capacity.high_cpu_nodes} / 内存{' '}
|
||||
{capacity.high_memory_nodes} / 存储 {capacity.high_storage_nodes}
|
||||
{t('avgMemoryHighLoad', {
|
||||
memory: formatPercent(capacity.average_memory_usage_percent),
|
||||
cpu: capacity.high_cpu_nodes,
|
||||
memoryNodes: capacity.high_memory_nodes,
|
||||
storage: capacity.high_storage_nodes,
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -76,7 +83,7 @@ export function DashboardStatCards({
|
||||
<Card className='border-dashed shadow-none md:col-span-2 xl:col-span-1'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<span className='text-xs font-medium text-muted-foreground'>
|
||||
节点概览
|
||||
{t('nodeOverview')}
|
||||
</span>
|
||||
<Server className='size-4 text-primary' />
|
||||
</CardHeader>
|
||||
@@ -85,18 +92,21 @@ export function DashboardStatCards({
|
||||
{summary.online_nodes}
|
||||
<span className='text-sm font-normal text-muted-foreground'>
|
||||
{' '}
|
||||
/ {summary.total_nodes} 在线
|
||||
{t('onlineOf', { total: summary.total_nodes })}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={onlineRate} className='h-1.5' />
|
||||
<p className='text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1'>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Users className='size-3' />
|
||||
待接入 {summary.pending_nodes}
|
||||
{t('pending', { count: summary.pending_nodes })}
|
||||
</span>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<Gauge className='size-3' />
|
||||
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes}
|
||||
{t('unhealthyOffline', {
|
||||
unhealthy: summary.unhealthy_nodes,
|
||||
offline: summary.offline_nodes,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type {
|
||||
@@ -17,11 +19,13 @@ export function TrafficCapacityTrendChart({
|
||||
networkPoints: NetworkTrendPoint[];
|
||||
capacityPoints: CapacityTrendPoint[];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.trafficCapacity');
|
||||
const tc = useTranslations('dashboard.capacityTrend');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
24 小时业务流量与容量趋势
|
||||
{t('title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
@@ -31,11 +35,11 @@ export function TrafficCapacityTrendChart({
|
||||
)}
|
||||
height={180}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时 · 来自访问日志'
|
||||
summaryHint={t('networkHint')}
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
label: '接收数据',
|
||||
label: t('received'),
|
||||
color: '#22c55e',
|
||||
fillColor: 'rgba(34, 197, 94, 0.14)',
|
||||
variant: 'area',
|
||||
@@ -43,7 +47,7 @@ export function TrafficCapacityTrendChart({
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
{
|
||||
label: '已提供数据',
|
||||
label: t('provided'),
|
||||
color: '#38bdf8',
|
||||
values: networkPoints.map((point) => point.bytes_provided),
|
||||
valueFormatter: formatBytes,
|
||||
@@ -57,11 +61,11 @@ export function TrafficCapacityTrendChart({
|
||||
)}
|
||||
height={180}
|
||||
summaryScope='average'
|
||||
summaryHint='近 24 小时 · 宿主机容量 · 平均值'
|
||||
summaryHint={t('capacityHint')}
|
||||
yAxisValueFormatter={formatPercent}
|
||||
series={[
|
||||
{
|
||||
label: '平均 CPU',
|
||||
label: tc('avgCpu'),
|
||||
color: '#0f766e',
|
||||
fillColor: 'rgba(15, 118, 110, 0.15)',
|
||||
variant: 'area',
|
||||
@@ -71,7 +75,7 @@ export function TrafficCapacityTrendChart({
|
||||
valueFormatter: formatPercent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
label: tc('avgMemory'),
|
||||
color: '#2563eb',
|
||||
values: capacityPoints.map(
|
||||
(point) => point.average_memory_usage_percent,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -14,18 +16,23 @@ import { formatTrendHour } from './dashboard-utils';
|
||||
|
||||
export function TrafficTrendChart({
|
||||
points,
|
||||
title = '24 小时请求趋势',
|
||||
description = '按小时拆分请求总量与 2xx/4xx/5xx 状态码请求量,判断各状态是否异常抬升。',
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
points: TrafficTrendPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const t = useTranslations('dashboard.trafficTrend');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{title ?? t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{description ?? t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
@@ -33,27 +40,27 @@ export function TrafficTrendChart({
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
summaryHint={t('summaryHint')}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
label: t('requests'),
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values: points.map((point) => point.request_count),
|
||||
},
|
||||
{
|
||||
label: '2xx 请求',
|
||||
label: t('status2xx'),
|
||||
color: '#22c55e',
|
||||
values: points.map((point) => point.status_2xx_count),
|
||||
},
|
||||
{
|
||||
label: '4xx 请求',
|
||||
label: t('status4xx'),
|
||||
color: '#f97316',
|
||||
values: points.map((point) => point.status_4xx_count),
|
||||
},
|
||||
{
|
||||
label: '5xx 请求',
|
||||
label: t('status5xx'),
|
||||
color: '#ef4444',
|
||||
values: points.map((point) => point.status_5xx_count),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MapChart, ScatterChart } from 'echarts/charts';
|
||||
import { GeoComponent, TooltipComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -276,6 +277,8 @@ export function WorldStageMap({
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.map');
|
||||
const tn = useTranslations('nodes');
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
const router = useRouter();
|
||||
@@ -503,7 +506,7 @@ export function WorldStageMap({
|
||||
) {
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
|
||||
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
|
||||
].join('');
|
||||
}
|
||||
|
||||
@@ -513,14 +516,22 @@ export function WorldStageMap({
|
||||
|
||||
const locationLine = data.derivedFromGeo
|
||||
? data.geoName
|
||||
: `${data.geoName} · 预设落点`;
|
||||
: `${data.geoName} · ${t('presetPin')}`;
|
||||
|
||||
return [
|
||||
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
|
||||
`<div>${locationLine}</div>`,
|
||||
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
|
||||
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
|
||||
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
|
||||
`<div>${t('requestsErrors', {
|
||||
requests: data.requestCount.toLocaleString('zh-CN'),
|
||||
errors: data.errorCount.toLocaleString('zh-CN'),
|
||||
})}</div>`,
|
||||
`<div>${t('eventsStatus', {
|
||||
events: data.activeEventCount,
|
||||
status: getNodeStatusLabel(data.status, tn),
|
||||
})}</div>`,
|
||||
`<div>${t('openrestyStatus', {
|
||||
status: getOpenrestyStatusLabel(data.openrestyStatus, tn),
|
||||
})}</div>`,
|
||||
].join('');
|
||||
},
|
||||
},
|
||||
@@ -610,6 +621,8 @@ export function WorldStageMap({
|
||||
mapNodes,
|
||||
mapPalette,
|
||||
responsiveMapScale,
|
||||
t,
|
||||
tn,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -617,12 +630,8 @@ export function WorldStageMap({
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<EmptyState
|
||||
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
|
||||
description={
|
||||
mapFailed
|
||||
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
|
||||
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
|
||||
}
|
||||
title={mapFailed ? t('loadFailed') : t('loading')}
|
||||
description={mapFailed ? t('loadFailedDesc') : t('loadingDesc')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -654,8 +663,8 @@ export function WorldStageMap({
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<EmptyState
|
||||
title='全球地图加载中'
|
||||
description='正在测量地图容器尺寸...'
|
||||
title={t('loading')}
|
||||
description={t('measuring')}
|
||||
iconSize='sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { type ComponentType, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import {
|
||||
Activity,
|
||||
Cpu,
|
||||
@@ -40,10 +41,10 @@ const WorldStageMap = dynamic(
|
||||
);
|
||||
|
||||
const LEGEND_ITEMS = [
|
||||
{ dot: 'bg-blue-500', label: '来源' },
|
||||
{ dot: 'bg-emerald-500', label: '正常' },
|
||||
{ dot: 'bg-amber-500', label: '承压' },
|
||||
{ dot: 'bg-destructive', label: '异常' },
|
||||
{ dot: 'bg-blue-500', key: 'legendSource' },
|
||||
{ dot: 'bg-emerald-500', key: 'legendHealthy' },
|
||||
{ dot: 'bg-amber-500', key: 'legendPressure' },
|
||||
{ dot: 'bg-destructive', key: 'legendUnhealthy' },
|
||||
] as const;
|
||||
|
||||
function SummaryMetric({
|
||||
@@ -128,6 +129,7 @@ export function WorldStage({
|
||||
nodes: DashboardNodeHealth[];
|
||||
sourceCountries: DistributionItem[];
|
||||
}) {
|
||||
const t = useTranslations('dashboard.world');
|
||||
const mapViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [shouldRenderMap, setShouldRenderMap] = useState(false);
|
||||
|
||||
@@ -177,62 +179,71 @@ export function WorldStage({
|
||||
|
||||
const mapModeLabel =
|
||||
sourceCountries.length > 0
|
||||
? '访客来源'
|
||||
? t('modeVisitorSource')
|
||||
: geoConfiguredNodes > 0
|
||||
? '节点坐标'
|
||||
: '覆盖信号';
|
||||
? t('modeNodeCoords')
|
||||
: t('modeCoverage');
|
||||
|
||||
const headlineMetrics = [
|
||||
{
|
||||
label: '在线覆盖',
|
||||
label: t('onlineCoverage'),
|
||||
value: formatPercent(onlineRate),
|
||||
hint: `${summary.online_nodes}/${summary.total_nodes} 在线`,
|
||||
hint: t('onlineHint', {
|
||||
online: summary.online_nodes,
|
||||
total: summary.total_nodes,
|
||||
}),
|
||||
icon: Server,
|
||||
progress: onlineRate,
|
||||
},
|
||||
{
|
||||
label: '运行健康',
|
||||
label: t('runHealth'),
|
||||
value: formatPercent(healthyRate),
|
||||
hint: `${summary.unhealthy_nodes} 个异常`,
|
||||
hint: t('unhealthyCount', { count: summary.unhealthy_nodes }),
|
||||
icon: ShieldCheck,
|
||||
progress: healthyRate,
|
||||
},
|
||||
{
|
||||
label: '24小时请求',
|
||||
label: t('requests24h'),
|
||||
value: formatCompactNumber(traffic.request_count),
|
||||
hint: `QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点上报`,
|
||||
hint: t('qpsReported', {
|
||||
qps: traffic.estimated_qps.toFixed(1),
|
||||
count: traffic.reported_nodes,
|
||||
}),
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
label: '节点坐标',
|
||||
label: t('nodeCoords'),
|
||||
value: formatCompactNumber(geoConfiguredNodes),
|
||||
hint:
|
||||
geoConfiguredNodes > 0
|
||||
? `${sourceCountries.length} 个来源国家`
|
||||
: '未配置时使用预设落点',
|
||||
? t('sourceCountries', { count: sourceCountries.length })
|
||||
: t('fallbackPins'),
|
||||
icon: Network,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const detailMetrics = [
|
||||
{
|
||||
label: '平均 CPU',
|
||||
label: t('avgCpu'),
|
||||
value: formatPercent(capacity.average_cpu_usage_percent),
|
||||
hint: `${capacity.high_cpu_nodes} 个偏高`,
|
||||
hint: t('highCount', { count: capacity.high_cpu_nodes }),
|
||||
icon: Cpu,
|
||||
progress: capacity.average_cpu_usage_percent,
|
||||
},
|
||||
{
|
||||
label: '平均内存',
|
||||
label: t('avgMemory'),
|
||||
value: formatPercent(capacity.average_memory_usage_percent),
|
||||
hint: `${capacity.high_memory_nodes} 个偏高`,
|
||||
hint: t('highCount', { count: capacity.high_memory_nodes }),
|
||||
icon: MemoryStick,
|
||||
progress: capacity.average_memory_usage_percent,
|
||||
},
|
||||
{
|
||||
label: '高存储',
|
||||
label: t('highStorage'),
|
||||
value: formatCompactNumber(capacity.high_storage_nodes),
|
||||
hint: `${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`,
|
||||
hint: t('offlinePending', {
|
||||
offline: summary.offline_nodes,
|
||||
pending: summary.pending_nodes,
|
||||
}),
|
||||
icon: HardDrive,
|
||||
},
|
||||
] as const;
|
||||
@@ -244,7 +255,7 @@ export function WorldStage({
|
||||
<div className='min-w-0'>
|
||||
<CardTitle className='flex items-center gap-1.5 text-sm font-semibold'>
|
||||
<Globe2 className='size-4 shrink-0 text-primary' />
|
||||
全球态势板
|
||||
{t('title')}
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='ml-1 text-[10px] font-normal text-muted-foreground'
|
||||
@@ -253,17 +264,17 @@ export function WorldStage({
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className='mt-1 text-xs'>
|
||||
汇总节点分布、来源国家热度与最近窗口运行状态。
|
||||
{t('description')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5'>
|
||||
{LEGEND_ITEMS.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
key={item.key}
|
||||
className='inline-flex items-center gap-1 text-[10px] text-muted-foreground'
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', item.dot)} />
|
||||
{item.label}
|
||||
{t(item.key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -291,8 +302,8 @@ export function WorldStage({
|
||||
) : (
|
||||
<div className='flex h-full items-center justify-center px-4'>
|
||||
<EmptyState
|
||||
title='地图准备中'
|
||||
description='进入可视区域后加载'
|
||||
title={t('mapPreparing')}
|
||||
description={t('mapPreparingDesc')}
|
||||
iconSize='sm'
|
||||
/>
|
||||
</div>
|
||||
@@ -301,7 +312,7 @@ export function WorldStage({
|
||||
{shouldRenderMap && nodes.length === 0 ? (
|
||||
<div className='pointer-events-none absolute inset-x-3 bottom-2 z-10'>
|
||||
<p className='rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm'>
|
||||
暂无节点,接入后将展示地理分布
|
||||
{t('noNodesOverlay')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -19,11 +20,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const cleanupSchema = z.object({
|
||||
keepCount: z.number().int().min(3, '最少保留 3 个历史快照'),
|
||||
});
|
||||
function buildCleanupSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
keepCount: z.number().int().min(3, t('keepMin')),
|
||||
});
|
||||
}
|
||||
|
||||
type CleanupFormValues = z.infer<typeof cleanupSchema>;
|
||||
type CleanupFormValues = z.infer<ReturnType<typeof buildCleanupSchema>>;
|
||||
|
||||
interface CleanupDialogProps {
|
||||
open: boolean;
|
||||
@@ -38,8 +41,10 @@ export function CleanupDialog({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const t = useTranslations('configVersions');
|
||||
const tc = useTranslations('common');
|
||||
const form = useForm<CleanupFormValues>({
|
||||
resolver: zodResolver(cleanupSchema),
|
||||
resolver: zodResolver(buildCleanupSchema(t)),
|
||||
defaultValues: { keepCount: 10 },
|
||||
});
|
||||
|
||||
@@ -57,14 +62,12 @@ export function CleanupDialog({
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理历史快照</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除旧的历史快照配置,系统将始终保护当前已激活的版本不被删除。
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('cleanupTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('cleanupDesc')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='keepCount'>保留最近快照个数</Label>
|
||||
<Label htmlFor='keepCount'>{t('keepCount')}</Label>
|
||||
<Input
|
||||
id='keepCount'
|
||||
type='number'
|
||||
@@ -73,7 +76,7 @@ export function CleanupDialog({
|
||||
{...form.register('keepCount', { valueAsNumber: true })}
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
默认为 10 个,最少需保留 3 个。
|
||||
{t('keepHint')}
|
||||
</p>
|
||||
{form.formState.errors.keepCount ? (
|
||||
<p className='text-xs text-destructive'>
|
||||
@@ -83,7 +86,7 @@ export function CleanupDialog({
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={loading}>{tc('cancel')}</AlertDialogCancel>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={() => void handleConfirm()}
|
||||
@@ -92,10 +95,10 @@ export function CleanupDialog({
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
清理中...
|
||||
{t('cleaning')}
|
||||
</>
|
||||
) : (
|
||||
'确认清理'
|
||||
t('confirmCleanup')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -29,12 +30,13 @@ interface DiffDialogProps {
|
||||
}
|
||||
|
||||
function DiffChipList({ title, items }: { title: string; items: string[] }) {
|
||||
const t = useTranslations('configVersions');
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<p className='text-xs font-semibold'>{title}</p>
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
{items.length} 项
|
||||
{t('itemCount', { count: items.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
@@ -50,16 +52,12 @@ function DiffChipList({ title, items }: { title: string; items: string[] }) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>无变更</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('noChange')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionValue(value: string) {
|
||||
return value === '' ? '空' : value;
|
||||
}
|
||||
|
||||
export function DiffDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -67,20 +65,19 @@ export function DiffDialog({
|
||||
loading,
|
||||
error,
|
||||
}: DiffDialogProps) {
|
||||
const t = useTranslations('configVersions');
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-3xl max-h-[85vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>配置差异</DialogTitle>
|
||||
<DialogDescription>
|
||||
对比当前待发布配置与已激活版本之间的差异。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('diffTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('diffDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
正在加载差异数据...
|
||||
{t('loadingDiff')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className='text-sm text-destructive'>{error}</p>
|
||||
@@ -89,15 +86,15 @@ export function DiffDialog({
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
激活版本
|
||||
{t('activeVersion')}
|
||||
</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{diff.active_version || '无'}
|
||||
{diff.active_version || t('none')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
新增域名
|
||||
{t('addedDomains')}
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold text-emerald-600'>
|
||||
{diff.added_domains.length}
|
||||
@@ -105,7 +102,7 @@ export function DiffDialog({
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
删除域名
|
||||
{t('removedDomains')}
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold text-amber-600'>
|
||||
{diff.removed_domains.length}
|
||||
@@ -113,7 +110,7 @@ export function DiffDialog({
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
修改域名
|
||||
{t('modifiedDomains')}
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold text-blue-600'>
|
||||
{diff.modified_domains.length}
|
||||
@@ -122,25 +119,38 @@ export function DiffDialog({
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<DiffChipList title='新增域名' items={diff.added_domains} />
|
||||
<DiffChipList title='删除域名' items={diff.removed_domains} />
|
||||
<DiffChipList title='修改域名' items={diff.modified_domains} />
|
||||
<DiffChipList
|
||||
title={t('addedDomains')}
|
||||
items={diff.added_domains}
|
||||
/>
|
||||
<DiffChipList
|
||||
title={t('removedDomains')}
|
||||
items={diff.removed_domains}
|
||||
/>
|
||||
<DiffChipList
|
||||
title={t('modifiedDomains')}
|
||||
items={diff.modified_domains}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge
|
||||
variant={diff.main_config_changed ? 'destructive' : 'outline'}
|
||||
>
|
||||
主配置 {diff.main_config_changed ? '已变化' : '无变化'}
|
||||
{diff.main_config_changed
|
||||
? t('mainChanged')
|
||||
: t('mainUnchanged')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={diff.waf_config_changed ? 'destructive' : 'outline'}
|
||||
>
|
||||
WAF {diff.waf_config_changed ? '已变化' : '无变化'}
|
||||
{diff.waf_config_changed ? t('wafChanged') : t('wafUnchanged')}
|
||||
</Badge>
|
||||
<Badge variant='outline'>
|
||||
网站数 {diff.active_website_count} →{' '}
|
||||
{diff.current_website_count}
|
||||
{t('websiteDelta', {
|
||||
from: diff.active_website_count,
|
||||
to: diff.current_website_count,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -150,13 +160,13 @@ export function DiffDialog({
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
参数
|
||||
{t('optionKey')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
激活值
|
||||
{t('activeValue')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
待发布值
|
||||
{t('pendingValue')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -167,10 +177,14 @@ export function DiffDialog({
|
||||
{item.key}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs font-mono text-muted-foreground'>
|
||||
{renderOptionValue(item.previous_value)}
|
||||
{item.previous_value === ''
|
||||
? t('emptyValue')
|
||||
: item.previous_value}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs font-mono text-muted-foreground'>
|
||||
{renderOptionValue(item.current_value)}
|
||||
{item.current_value === ''
|
||||
? t('emptyValue')
|
||||
: item.current_value}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -179,7 +193,7 @@ export function DiffDialog({
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前无 OpenResty 参数变化。
|
||||
{t('noOptionChanges')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,21 +37,20 @@ export function PreviewSheet({
|
||||
canPublish,
|
||||
onPublish,
|
||||
}: PreviewSheetProps) {
|
||||
const t = useTranslations('configVersions');
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
|
||||
<SheetHeader className='px-5 py-4 border-b'>
|
||||
<SheetTitle>发布预览</SheetTitle>
|
||||
<SheetDescription>
|
||||
查看待发布配置的渲染结果与支持文件。
|
||||
</SheetDescription>
|
||||
<SheetTitle>{t('previewTitle')}</SheetTitle>
|
||||
<SheetDescription>{t('previewDesc')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
正在加载预览...
|
||||
{t('loadingPreview')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className='text-sm text-destructive'>{error}</p>
|
||||
@@ -58,10 +58,10 @@ export function PreviewSheet({
|
||||
<div className='space-y-5 pb-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge variant='outline' className='rounded-full text-[10px]'>
|
||||
规则 {preview.route_count} 条
|
||||
{t('routeCount', { count: preview.route_count })}
|
||||
</Badge>
|
||||
<Badge variant='outline' className='rounded-full text-[10px]'>
|
||||
网站 {preview.website_count} 个
|
||||
{t('websiteCount', { count: preview.website_count })}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant='outline'
|
||||
@@ -72,7 +72,7 @@ export function PreviewSheet({
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>主配置</p>
|
||||
<p className='text-xs font-semibold'>{t('mainConfig')}</p>
|
||||
<CodeBlock
|
||||
code={preview.main_config}
|
||||
language='nginx'
|
||||
@@ -81,7 +81,7 @@ export function PreviewSheet({
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>路由配置</p>
|
||||
<p className='text-xs font-semibold'>{t('routeConfig')}</p>
|
||||
<CodeBlock
|
||||
code={preview.rendered_config}
|
||||
language='nginx'
|
||||
@@ -92,7 +92,9 @@ export function PreviewSheet({
|
||||
{preview.support_files.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>
|
||||
支持文件 ({preview.support_files.length})
|
||||
{t('supportFiles', {
|
||||
count: preview.support_files.length,
|
||||
})}
|
||||
</p>
|
||||
{preview.support_files.map((file) => (
|
||||
<details
|
||||
@@ -112,13 +114,13 @@ export function PreviewSheet({
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前发布不需要额外支持文件。
|
||||
{t('noSupportFiles')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canPublish ? (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前规则与已激活版本一致,无法重复发布。
|
||||
{t('noChanges')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -131,7 +133,7 @@ export function PreviewSheet({
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={publishing}
|
||||
>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onPublish}
|
||||
@@ -140,10 +142,10 @@ export function PreviewSheet({
|
||||
{publishing ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
发布中...
|
||||
{t('publishing')}
|
||||
</>
|
||||
) : (
|
||||
'确认发布'
|
||||
t('confirmPublish')
|
||||
)}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -33,6 +34,7 @@ export function VersionSnapshotSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: VersionSnapshotSheetProps) {
|
||||
const t = useTranslations('configVersions');
|
||||
const [detail, setDetail] = useState<ConfigVersionDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -51,8 +53,9 @@ export function VersionSnapshotSheet({
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
toast.error('加载版本快照失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('snapshotLoadFailed'), {
|
||||
description:
|
||||
err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -63,32 +66,36 @@ export function VersionSnapshotSheet({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, version]);
|
||||
}, [open, version, t]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='sm:max-w-2xl w-full p-0 flex h-svh flex-col gap-0'>
|
||||
<SheetHeader className='px-5 py-4 border-b'>
|
||||
<SheetTitle>
|
||||
{version ? `版本 ${version.version}` : '版本快照'}
|
||||
{version
|
||||
? t('snapshotVersion', { version: version.version })
|
||||
: t('snapshotTitle')}
|
||||
</SheetTitle>
|
||||
<SheetDescription>查看历史配置版本的完整快照内容。</SheetDescription>
|
||||
<SheetDescription>{t('snapshotDesc')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 min-h-0 overflow-y-auto px-5 py-4'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
正在加载快照...
|
||||
{t('loadingSnapshot')}
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className='space-y-5 pb-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{detail.is_active ? (
|
||||
<Badge className='rounded-full text-[10px]'>当前激活</Badge>
|
||||
<Badge className='rounded-full text-[10px]'>
|
||||
{t('active')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant='outline' className='rounded-full text-[10px]'>
|
||||
历史版本
|
||||
{t('history')}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
@@ -101,11 +108,13 @@ export function VersionSnapshotSheet({
|
||||
|
||||
<div className='grid gap-3 sm:grid-cols-2 text-xs'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>创建人</p>
|
||||
<p className='font-medium'>{detail.created_by || '系统'}</p>
|
||||
<p className='text-muted-foreground'>{t('createdBy')}</p>
|
||||
<p className='font-medium'>
|
||||
{detail.created_by || t('system')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>创建时间</p>
|
||||
<p className='text-muted-foreground'>{t('createdAt')}</p>
|
||||
<p className='font-medium'>
|
||||
{formatDateTime(detail.created_at)}
|
||||
</p>
|
||||
@@ -113,7 +122,7 @@ export function VersionSnapshotSheet({
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>主配置</p>
|
||||
<p className='text-xs font-semibold'>{t('mainConfig')}</p>
|
||||
<CodeBlock
|
||||
code={detail.main_config}
|
||||
language='nginx'
|
||||
@@ -122,7 +131,7 @@ export function VersionSnapshotSheet({
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold'>路由配置</p>
|
||||
<p className='text-xs font-semibold'>{t('routeConfig')}</p>
|
||||
<CodeBlock
|
||||
code={detail.rendered_config}
|
||||
language='nginx'
|
||||
@@ -135,7 +144,7 @@ export function VersionSnapshotSheet({
|
||||
|
||||
<SheetFooter className='px-5 py-4 border-t'>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -68,6 +69,8 @@ function hasConfigDiff(diff: ConfigDiffResult) {
|
||||
}
|
||||
|
||||
export default function ConfigVersionsPage() {
|
||||
const t = useTranslations('configVersions');
|
||||
const tc = useTranslations('common');
|
||||
const [versions, setVersions] = useState<ConfigVersionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -121,11 +124,11 @@ export default function ConfigVersionsPage() {
|
||||
const data = await ConfigVersionService.list();
|
||||
setVersions(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchVersions();
|
||||
@@ -145,7 +148,7 @@ export default function ConfigVersionsPage() {
|
||||
setPreview(previewData);
|
||||
setDiff(diffData);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '加载预览失败';
|
||||
const message = err instanceof Error ? err.message : t('previewLoadFailed');
|
||||
setPreviewError(message);
|
||||
setDiffError(message);
|
||||
} finally {
|
||||
@@ -170,7 +173,7 @@ export default function ConfigVersionsPage() {
|
||||
const diffData = await ConfigVersionService.diff();
|
||||
setDiff(diffData);
|
||||
} catch (err) {
|
||||
setDiffError(err instanceof Error ? err.message : '加载差异失败');
|
||||
setDiffError(err instanceof Error ? err.message : t('diffLoadFailed'));
|
||||
} finally {
|
||||
setDiffLoading(false);
|
||||
}
|
||||
@@ -181,7 +184,9 @@ export default function ConfigVersionsPage() {
|
||||
setPublishing(true);
|
||||
try {
|
||||
const version = await ConfigVersionService.publish(force);
|
||||
toast.success('发布成功', { description: `版本 ${version.version}` });
|
||||
toast.success(t('publishSuccess'), {
|
||||
description: t('publishSuccessDesc', { version: version.version }),
|
||||
});
|
||||
setPreviewOpen(false);
|
||||
setPublishConfirmOpen(false);
|
||||
setForcePublishConfirmOpen(false);
|
||||
@@ -189,8 +194,8 @@ export default function ConfigVersionsPage() {
|
||||
setDiff(null);
|
||||
await fetchVersions();
|
||||
} catch (err) {
|
||||
toast.error('发布失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('publishFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
@@ -203,12 +208,14 @@ export default function ConfigVersionsPage() {
|
||||
setActivating(true);
|
||||
try {
|
||||
const version = await ConfigVersionService.activate(activateTarget.id);
|
||||
toast.success('激活成功', { description: `版本 ${version.version}` });
|
||||
toast.success(t('activateSuccess'), {
|
||||
description: t('activateSuccessDesc', { version: version.version }),
|
||||
});
|
||||
setActivateTarget(null);
|
||||
await fetchVersions();
|
||||
} catch (err) {
|
||||
toast.error('激活失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('activateFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setActivating(false);
|
||||
@@ -221,14 +228,14 @@ export default function ConfigVersionsPage() {
|
||||
const result = await ConfigVersionService.cleanup({
|
||||
keep_count: keepCount,
|
||||
});
|
||||
toast.success('清理完成', {
|
||||
description: `已删除 ${result.deleted_count} 个历史快照`,
|
||||
toast.success(t('cleanupDone'), {
|
||||
description: t('cleanupDoneDesc', { count: result.deleted_count }),
|
||||
});
|
||||
setCleanupOpen(false);
|
||||
await fetchVersions();
|
||||
} catch (err) {
|
||||
toast.error('清理失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('cleanupFailed'), {
|
||||
description: err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
@@ -241,10 +248,10 @@ export default function ConfigVersionsPage() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<History className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>配置版本</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看历史快照、预览待发布配置差异,并在需要时重新激活旧版本。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +265,7 @@ export default function ConfigVersionsPage() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -266,7 +273,7 @@ export default function ConfigVersionsPage() {
|
||||
onClick={() => setCleanupOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理旧版本
|
||||
{t('cleanupOld')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -274,18 +281,18 @@ export default function ConfigVersionsPage() {
|
||||
onClick={() => void handleOpenDiff()}
|
||||
>
|
||||
<GitCompare className='size-3.5 mr-1' />
|
||||
查看差异
|
||||
{t('viewDiff')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setForcePublishConfirmOpen(true)}
|
||||
>
|
||||
强制发布
|
||||
{t('forcePublish')}
|
||||
</Button>
|
||||
<Button size='sm' onClick={() => void handleOpenPreview()}>
|
||||
<Eye className='size-3.5 mr-1' />
|
||||
预览并发布
|
||||
{t('previewPublish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,24 +306,30 @@ export default function ConfigVersionsPage() {
|
||||
<LoadingStateWithBorder />
|
||||
) : sortedVersions.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无历史版本'
|
||||
description='当前还没有可查看的发布记录,请先触发一次配置发布。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs font-semibold'>版本号</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>状态</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>创建人</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
Checksum
|
||||
{t('colVersion')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
创建时间
|
||||
{t('colStatus')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colAuthor')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colChecksum')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colCreatedAt')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold text-right'>
|
||||
操作
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -336,19 +349,19 @@ export default function ConfigVersionsPage() {
|
||||
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
当前激活
|
||||
{t('active')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[10px] rounded-full py-0 px-2'
|
||||
>
|
||||
历史版本
|
||||
{t('history')}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{version.created_by || '系统'}
|
||||
{version.created_by || t('system')}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-xs font-mono text-muted-foreground'
|
||||
@@ -371,7 +384,7 @@ export default function ConfigVersionsPage() {
|
||||
}}
|
||||
>
|
||||
<Eye className='size-3 mr-1' />
|
||||
快照
|
||||
{t('snapshot')}
|
||||
</Button>
|
||||
{!version.is_active ? (
|
||||
<Button
|
||||
@@ -381,7 +394,7 @@ export default function ConfigVersionsPage() {
|
||||
onClick={() => setActivateTarget(version)}
|
||||
>
|
||||
<Play className='size-3 mr-1' />
|
||||
激活
|
||||
{t('activate')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -431,13 +444,15 @@ export default function ConfigVersionsPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认发布配置</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('confirmPublishTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将把当前待发布配置生成新版本并设为激活版本,节点将随后拉取更新。
|
||||
{t('confirmPublishDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={publishing}>
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
onClick={() => void handlePublish(false)}
|
||||
disabled={publishing}
|
||||
@@ -445,7 +460,7 @@ export default function ConfigVersionsPage() {
|
||||
{publishing ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'确认发布'
|
||||
t('confirmPublish')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
@@ -458,13 +473,15 @@ export default function ConfigVersionsPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认强制发布</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('confirmForceTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将忽略配置变化检查并立即生成一个新版本,请确认你确实需要重新发布。
|
||||
{t('confirmForceDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={publishing}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={publishing}>
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={() => void handlePublish(true)}
|
||||
@@ -473,7 +490,7 @@ export default function ConfigVersionsPage() {
|
||||
{publishing ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'强制发布'
|
||||
t('forcePublish')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
@@ -488,20 +505,22 @@ export default function ConfigVersionsPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认激活版本</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('confirmActivateTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{activateTarget
|
||||
? `确认将版本 ${activateTarget.version} 设为当前激活版本吗?`
|
||||
? t('confirmActivateDesc', { version: activateTarget.version })
|
||||
: ''}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={activating}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={activating}>
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button onClick={() => void handleActivate()} disabled={activating}>
|
||||
{activating ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'确认激活'
|
||||
t('confirmActivate')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -30,12 +30,16 @@ import type { DnsAccountItem } from '@/lib/services/openflare';
|
||||
import { DnsAccountService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { DnsAccountCreateDialog } from '../websites/components/dns-account-create-dialog';
|
||||
import { getErrorMessage } from '../websites/components/website-utils';
|
||||
|
||||
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
|
||||
|
||||
export default function DnsAccountsPage() {
|
||||
const t = useTranslations('dnsAccounts');
|
||||
const tc = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DnsAccountItem | null>(null);
|
||||
@@ -48,11 +52,11 @@ export default function DnsAccountsPage() {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => DnsAccountService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('DNS 账号已删除');
|
||||
toast.success(t('deleted'));
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: dnsAccountsQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const accounts = useMemo(
|
||||
@@ -65,7 +69,7 @@ export default function DnsAccountsPage() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Cloud className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>DNS 账号</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
@@ -74,7 +78,7 @@ export default function DnsAccountsPage() {
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
添加账号
|
||||
{t('addAccount')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,22 +86,23 @@ export default function DnsAccountsPage() {
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
DNS 账号列表
|
||||
{t('listTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
|
||||
</CardDescription>
|
||||
<CardDescription>{t('listDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dnsAccountsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Cloud}
|
||||
description='加载 DNS 账号中...'
|
||||
description={t('loadingList')}
|
||||
/>
|
||||
) : dnsAccountsQuery.isError ? (
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(dnsAccountsQuery.error)}
|
||||
message={getErrorMessage(
|
||||
dnsAccountsQuery.error,
|
||||
t('requestFailed'),
|
||||
)}
|
||||
onRetry={() => void dnsAccountsQuery.refetch()}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -105,7 +110,7 @@ export default function DnsAccountsPage() {
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Cloud}
|
||||
description='暂无 DNS 账号,点击右上角「添加账号」开始录入。'
|
||||
description={t('emptyList')}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
@@ -122,7 +127,9 @@ export default function DnsAccountsPage() {
|
||||
</span>
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
创建于:{formatDateTime(account.created_at)}
|
||||
{t('createdAt', {
|
||||
date: formatDateTime(account.created_at),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -143,7 +150,7 @@ export default function DnsAccountsPage() {
|
||||
<DnsAccountCreateDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={() => toast.success('DNS 账号已添加')}
|
||||
onCreated={() => toast.success(t('created'))}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
@@ -152,20 +159,20 @@ export default function DnsAccountsPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除 DNS 账号</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 DNS 账号 {deleteTarget?.name} 吗?
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() =>
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
删除
|
||||
{tc('delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { motion } from 'motion/react';
|
||||
import { RequireAuth } from '@/components/auth/require-auth';
|
||||
import { UserFileManager } from '@/components/common/user/file-manager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function UserFilesPage() {
|
||||
const t = useTranslations('files');
|
||||
return (
|
||||
<RequireAuth>
|
||||
<motion.div
|
||||
@@ -22,7 +24,7 @@ export default function UserFilesPage() {
|
||||
<FolderOpen className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
我的文件
|
||||
{t('myFiles')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Network, Plus, RefreshCw } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -46,6 +47,8 @@ import { IPGroupsTable } from '../waf/components/ip-groups-table';
|
||||
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
|
||||
|
||||
export default function WafIPGroupsPage() {
|
||||
const t = useTranslations('ipGroups');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<WAFIPGroup | null>(null);
|
||||
@@ -90,25 +93,25 @@ export default function WafIPGroupsPage() {
|
||||
return WafService.createIPGroup(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(editingGroup ? 'IP 组已更新' : 'IP 组已创建');
|
||||
toast.success(editingGroup ? t('updated') : t('created'));
|
||||
setEditingGroup(null);
|
||||
setEditorOpen(false);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('operationFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => WafService.deleteIPGroup(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('IP 组已删除');
|
||||
toast.success(t('deleted'));
|
||||
setDeleteTarget(null);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('operationFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,11 +121,11 @@ export default function WafIPGroupsPage() {
|
||||
setSyncingId(id);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
toast.success(result.message || '同步完成');
|
||||
toast.success(result.message || t('synced'));
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('operationFailed')));
|
||||
},
|
||||
onSettled: () => {
|
||||
setSyncingId(null);
|
||||
@@ -147,12 +150,12 @@ export default function WafIPGroupsPage() {
|
||||
setRemovingIp(ip);
|
||||
},
|
||||
onSuccess: async (updatedGroup) => {
|
||||
toast.success('IP 已移除');
|
||||
toast.success(t('ipRemoved'));
|
||||
setViewingGroup(updatedGroup);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('operationFailed')));
|
||||
},
|
||||
onSettled: () => {
|
||||
setRemovingIp(null);
|
||||
@@ -164,19 +167,20 @@ export default function WafIPGroupsPage() {
|
||||
WafService.testIPGroup({
|
||||
auto_config: parseAutomaticConfig(
|
||||
JSON.stringify(group.auto_config ?? {}, null, 2),
|
||||
t('autoConfigMustBeObject'),
|
||||
),
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setTestResult(result);
|
||||
toast.success(
|
||||
result.matched_count > 0
|
||||
? `规则测试完成,命中 ${result.matched_count} 个 IP`
|
||||
: '规则测试完成,当前未命中任何 IP',
|
||||
? t('testHit', { count: result.matched_count })
|
||||
: t('testMiss'),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
setTestResult(null);
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('operationFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -219,7 +223,7 @@ export default function WafIPGroupsPage() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Network className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>IP 组</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
@@ -229,7 +233,7 @@ export default function WafIPGroupsPage() {
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新建 IP 组
|
||||
{t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,11 +243,9 @@ export default function WafIPGroupsPage() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
IP 组列表
|
||||
{t('listTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
|
||||
</CardDescription>
|
||||
<CardDescription>{t('listDesc')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -257,7 +259,7 @@ export default function WafIPGroupsPage() {
|
||||
) : (
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
)}
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -265,12 +267,12 @@ export default function WafIPGroupsPage() {
|
||||
{groupsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Network}
|
||||
description='加载 IP 组中...'
|
||||
description={t('loading')}
|
||||
/>
|
||||
) : groupsQuery.isError ? (
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(groupsQuery.error)}
|
||||
message={getErrorMessage(groupsQuery.error, t('operationFailed'))}
|
||||
onRetry={handleRefresh}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -278,7 +280,7 @@ export default function WafIPGroupsPage() {
|
||||
) : groups.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Network}
|
||||
description='暂无 IP 组,请先创建一个。'
|
||||
description={t('empty')}
|
||||
/>
|
||||
) : (
|
||||
<IPGroupsTable
|
||||
@@ -334,14 +336,14 @@ export default function WafIPGroupsPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除 IP 组</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 IP 组「{deleteTarget?.name}」吗?删除后无法恢复。
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
@@ -350,7 +352,7 @@ export default function WafIPGroupsPage() {
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -40,6 +41,7 @@ export function AgentUpdateDialog({
|
||||
channel: ReleaseChannel,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
const [channel, setChannel] = useState<ReleaseChannel>('stable');
|
||||
|
||||
const releaseQuery = useQuery({
|
||||
@@ -54,36 +56,42 @@ export function AgentUpdateDialog({
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className='sm:max-w-xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Agent 升级</DialogTitle>
|
||||
<DialogDescription>
|
||||
检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('update.title')}</DialogTitle>
|
||||
<DialogDescription>{t('update.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-xs text-muted-foreground'>当前版本</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('update.currentVersion')}
|
||||
</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{node.version || 'unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-xs text-muted-foreground'>检查通道</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('update.channel')}
|
||||
</p>
|
||||
<p className='mt-1 text-sm font-medium'>
|
||||
{channel === 'preview' ? '预览版' : '正式版'}
|
||||
{channel === 'preview'
|
||||
? t('channel.preview')
|
||||
: t('channel.stable')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-xs text-muted-foreground'>更新状态</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('update.updateStatus')}
|
||||
</p>
|
||||
<div className='mt-1'>
|
||||
<NodeStatusBadge
|
||||
label={
|
||||
node.update_requested
|
||||
? node.update_channel === 'preview'
|
||||
? '等待预览更新'
|
||||
: '等待更新'
|
||||
: '未下发'
|
||||
? t('update.waitingPreview')
|
||||
: t('update.waitingUpdate')
|
||||
: t('update.notRequested')
|
||||
}
|
||||
tone={node.update_requested ? 'warning' : 'info'}
|
||||
/>
|
||||
@@ -94,45 +102,57 @@ export function AgentUpdateDialog({
|
||||
{releaseQuery.isFetching ? (
|
||||
<div className='flex items-center justify-center py-8 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 mr-2 animate-spin' />
|
||||
检查发布版本中...
|
||||
{t('update.checking')}
|
||||
</div>
|
||||
) : releaseQuery.isError ? (
|
||||
<p className='text-sm text-destructive'>
|
||||
{getErrorMessage(releaseQuery.error)}
|
||||
{getErrorMessage(releaseQuery.error, t('requestFailed'))}
|
||||
</p>
|
||||
) : release ? (
|
||||
<div className='space-y-3 rounded-lg border p-4'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<NodeStatusBadge
|
||||
label={
|
||||
release.has_update ? '发现可升级版本' : '当前已是最新版本'
|
||||
release.has_update
|
||||
? t('update.hasUpdate')
|
||||
: t('update.upToDate')
|
||||
}
|
||||
tone={release.has_update ? 'warning' : 'success'}
|
||||
/>
|
||||
{release.prerelease ? (
|
||||
<NodeStatusBadge label='Preview 发布' tone='warning' />
|
||||
<NodeStatusBadge
|
||||
label={t('update.previewRelease')}
|
||||
tone='warning'
|
||||
/>
|
||||
) : (
|
||||
<NodeStatusBadge label='正式发布' tone='info' />
|
||||
<NodeStatusBadge
|
||||
label={t('update.stableRelease')}
|
||||
tone='info'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='grid gap-3 sm:grid-cols-2 text-sm'>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground'>目标版本</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('update.targetVersion')}
|
||||
</p>
|
||||
<p className='mt-1 font-medium'>
|
||||
{release.tag_name || '未找到'}
|
||||
{release.tag_name || t('update.notFound')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground'>发布时间</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('update.publishedAt')}
|
||||
</p>
|
||||
<p className='mt-1'>
|
||||
{release.published_at
|
||||
? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
|
||||
? `${formatRelativeTime(release.published_at, t)} · ${formatDateTime(release.published_at)}`
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground whitespace-pre-wrap'>
|
||||
{release.body || '暂无更新说明'}
|
||||
{release.body || t('update.noNotes')}
|
||||
</p>
|
||||
{release.html_url ? (
|
||||
<a
|
||||
@@ -141,7 +161,7 @@ export function AgentUpdateDialog({
|
||||
rel='noreferrer'
|
||||
className='inline-flex items-center text-sm text-primary hover:underline'
|
||||
>
|
||||
查看发布详情
|
||||
{t('update.viewRelease')}
|
||||
<ExternalLink className='size-3 ml-1' />
|
||||
</a>
|
||||
) : null}
|
||||
@@ -156,7 +176,7 @@ export function AgentUpdateDialog({
|
||||
disabled={submitting || releaseQuery.isFetching}
|
||||
onClick={() => setChannel('stable')}
|
||||
>
|
||||
检查正式版
|
||||
{t('update.checkStable')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -164,7 +184,7 @@ export function AgentUpdateDialog({
|
||||
disabled={submitting || releaseQuery.isFetching}
|
||||
onClick={() => setChannel('preview')}
|
||||
>
|
||||
检查预览版
|
||||
{t('update.checkPreview')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -177,10 +197,10 @@ export function AgentUpdateDialog({
|
||||
onClick={() => void onConfirm(release, channel)}
|
||||
>
|
||||
{submitting
|
||||
? '下发中...'
|
||||
? t('dispatching')
|
||||
: channel === 'preview'
|
||||
? '升级到预览版'
|
||||
: '升级到正式版'}
|
||||
? t('update.upgradePreview')
|
||||
: t('update.upgradeStable')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -26,18 +28,23 @@ function formatDiskRate(bytesPerSecond: number) {
|
||||
|
||||
export function DiskIOTrendChart({
|
||||
points,
|
||||
title = '24 小时磁盘 IO 趋势',
|
||||
description = '按小时展示磁盘读写速率(B/s),辅助判断日志放大、缓存抖动或磁盘压力。',
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
points: DiskIOTrendPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const t = useTranslations('nodes.diskIo');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{title ?? t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{description ?? t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
@@ -45,11 +52,11 @@ export function DiskIOTrendChart({
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
summaryScope='average'
|
||||
summaryHint='近 24 小时 · 平均速率'
|
||||
summaryHint={t('summaryHint')}
|
||||
yAxisValueFormatter={formatDiskRate}
|
||||
series={[
|
||||
{
|
||||
label: '磁盘读',
|
||||
label: t('read'),
|
||||
color: '#a78bfa',
|
||||
fillColor: 'rgba(167, 139, 250, 0.14)',
|
||||
variant: 'area',
|
||||
@@ -59,7 +66,7 @@ export function DiskIOTrendChart({
|
||||
valueFormatter: formatDiskRate,
|
||||
},
|
||||
{
|
||||
label: '磁盘写',
|
||||
label: t('write'),
|
||||
color: '#fb7185',
|
||||
values: points.map((point) =>
|
||||
diskBytesToRate(point.disk_write_bytes),
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
import { formatCompactNumber } from '../../components/dashboard/dashboard-utils';
|
||||
|
||||
export function DistributionList({
|
||||
items,
|
||||
emptyMessage = '暂无分布数据',
|
||||
emptyMessage,
|
||||
}: {
|
||||
items: Array<{ label: string; value: number }>;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
const t = useTranslations('nodes.obs');
|
||||
const resolvedEmpty = emptyMessage ?? t('distEmpty');
|
||||
const sortedItems = [...items]
|
||||
.sort((left, right) => right.value - left.value)
|
||||
.slice(0, 8);
|
||||
@@ -19,7 +23,7 @@ export function DistributionList({
|
||||
if (sortedItems.length === 0) {
|
||||
return (
|
||||
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
|
||||
{emptyMessage}
|
||||
{resolvedEmpty}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +36,7 @@ export function DistributionList({
|
||||
<div key={item.label} className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between gap-3 text-xs'>
|
||||
<span className='truncate font-medium'>
|
||||
{item.label || '未知'}
|
||||
{item.label || t('unknownLabel')}
|
||||
</span>
|
||||
<span className='shrink-0 font-mono tabular-nums text-muted-foreground'>
|
||||
{formatCompactNumber(item.value)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -62,6 +63,8 @@ import {
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -72,29 +75,29 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
|
||||
NodeService.updateNode(node.id, payload),
|
||||
onSuccess: async () => {
|
||||
toast.success('节点已更新');
|
||||
toast.success(t('edge.toastUpdated'));
|
||||
setEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const forceSyncMutation = useMutation({
|
||||
mutationFn: () => NodeService.requestForceSync(node.id),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(`已向节点 ${updated.name} 下发强制同步指令`);
|
||||
toast.success(t('edge.toastForceSync', { name: updated.name }));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: () => NodeService.requestOpenrestyRestart(node.id),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(`已向节点 ${updated.name} 下发 OpenResty 重启指令`);
|
||||
toast.success(t('edge.toastRestart', { name: updated.name }));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
@@ -114,22 +117,28 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(
|
||||
`已向节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
|
||||
t('edge.toastUpgrade', {
|
||||
name: updated.name,
|
||||
channel:
|
||||
updated.update_channel === 'preview'
|
||||
? t('channel.preview')
|
||||
: t('channel.stable'),
|
||||
}),
|
||||
);
|
||||
setUpgradeOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => NodeService.deleteNode(node.id),
|
||||
onSuccess: async () => {
|
||||
toast.success('节点已删除');
|
||||
toast.success(t('edge.toastDeleted'));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
router.push('/nodes');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
@@ -149,7 +158,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
className='h-8'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -158,7 +167,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1.5' />
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -168,7 +177,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => forceSyncMutation.mutate()}
|
||||
>
|
||||
<RotateCcw className='size-3.5 mr-1.5' />
|
||||
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
|
||||
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -177,7 +186,9 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
disabled={restartMutation.isPending}
|
||||
onClick={() => restartMutation.mutate()}
|
||||
>
|
||||
{restartMutation.isPending ? '下发中...' : '重启 OpenResty'}
|
||||
{restartMutation.isPending
|
||||
? t('dispatching')
|
||||
: t('edge.restartOpenresty')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -186,7 +197,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => setUpgradeOpen(true)}
|
||||
>
|
||||
<Upload className='size-3.5 mr-1.5' />
|
||||
{node.update_requested ? '查看升级' : '升级 Agent'}
|
||||
{node.update_requested ? t('viewUpgrade') : t('edge.upgrade')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -195,7 +206,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1.5' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -206,74 +217,74 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<NodeSectionCard
|
||||
title='运行状态'
|
||||
description='节点在线情况与 OpenResty 健康'
|
||||
title={t('edge.runStatus')}
|
||||
description={t('edge.runStatusDesc')}
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='运行状态'>
|
||||
<NodeInfoRow label={t('edge.runStatus')}>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
label={getNodeStatusLabel(node.status, t)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='OpenResty 健康'>
|
||||
<NodeInfoRow label={t('edge.openrestyHealth')}>
|
||||
<NodeStatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
label={getOpenrestyStatusLabel(node.openresty_status, t)}
|
||||
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近心跳'>
|
||||
<NodeInfoRow label={t('edge.lastSeen')}>
|
||||
{isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: t('na')}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='IP 地址'>
|
||||
<NodeInfoRow label={t('edge.ip')}>
|
||||
{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
{node.ip_manual_override ? t('lockedSuffix') : ''}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='地图点位'>
|
||||
{node.geo_name || '未配置'}
|
||||
<NodeInfoRow label={t('edge.geo')}>
|
||||
{node.geo_name || t('notConfigured')}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<NodeSectionCard
|
||||
title='配置同步'
|
||||
description='版本追平与应用结果摘要'
|
||||
title={t('edge.syncTitle')}
|
||||
description={t('edge.syncDesc')}
|
||||
action={
|
||||
<Button variant='outline' size='sm' className='h-8' asChild>
|
||||
<Link
|
||||
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
|
||||
>
|
||||
<FileText className='size-3.5 mr-1.5' />
|
||||
应用记录
|
||||
{t('applyLogs')}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='当前配置版本'>
|
||||
{node.current_version || '未应用'}
|
||||
<NodeInfoRow label={t('edge.currentVersion')}>
|
||||
{node.current_version || t('notApplied')}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用'>
|
||||
<NodeInfoRow label={t('edge.latestApply')}>
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
label={getApplyLabel(node.latest_apply_result, t)}
|
||||
tone={getApplyTone(node.latest_apply_result)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用时间'>
|
||||
<NodeInfoRow label={t('edge.latestApplyAt')}>
|
||||
{isMeaningfulTime(node.latest_apply_at)
|
||||
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
|
||||
: '暂无'}
|
||||
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
|
||||
: t('na')}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='Agent 版本'>
|
||||
<NodeInfoRow label={t('edge.agentVersion')}>
|
||||
{node.version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='Nginx 版本'>
|
||||
<NodeInfoRow label={t('edge.nginxVersion')}>
|
||||
{node.ext_version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='自动更新'>
|
||||
{node.auto_update_enabled ? '已启用' : '手动'}
|
||||
<NodeInfoRow label={t('edge.autoUpdate')}>
|
||||
{node.auto_update_enabled ? t('enabled') : t('manual')}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
@@ -282,22 +293,22 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
<InstallCommand node={node} variant='edge' />
|
||||
|
||||
<NodeSectionCard
|
||||
title='运行消息'
|
||||
description='OpenResty 与 Agent 上报的状态说明'
|
||||
title={t('edge.runtimeMessages')}
|
||||
description={t('edge.runtimeMessagesDesc')}
|
||||
>
|
||||
<div className='space-y-4 text-sm'>
|
||||
<div>
|
||||
<p className='mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground'>
|
||||
OpenResty 状态消息
|
||||
{t('edge.openrestyMessage')}
|
||||
</p>
|
||||
<p className='rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap'>
|
||||
{node.openresty_message || '当前未上报额外错误。'}
|
||||
{node.openresty_message || t('edge.noExtraError')}
|
||||
</p>
|
||||
</div>
|
||||
{node.restart_openresty_requested ? (
|
||||
<div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'>
|
||||
<Activity className='size-4' />
|
||||
已等待节点在下一次心跳后执行 OpenResty 重启。
|
||||
{t('edge.restartPending')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -313,28 +324,32 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
typeTone='info'
|
||||
statusBadges={[
|
||||
{
|
||||
label: getNodeStatusLabel(node.status),
|
||||
label: getNodeStatusLabel(node.status, t),
|
||||
tone: getNodeStatusTone(node.status),
|
||||
},
|
||||
{
|
||||
label: getOpenrestyStatusLabel(node.openresty_status),
|
||||
label: getOpenrestyStatusLabel(node.openresty_status, t),
|
||||
tone: getOpenrestyStatusTone(node.openresty_status),
|
||||
},
|
||||
]}
|
||||
actions={headerActions}
|
||||
kpis={[
|
||||
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
|
||||
{ label: 'Agent 版本', value: node.version || 'unknown', icon: Cpu },
|
||||
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
|
||||
{
|
||||
label: '当前配置',
|
||||
value: node.current_version || '未应用',
|
||||
label: t('edge.kpiAgent'),
|
||||
value: node.version || 'unknown',
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
label: t('kpiCurrentConfig'),
|
||||
value: node.current_version || t('notApplied'),
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
label: '最近心跳',
|
||||
label: t('kpiLastSeen'),
|
||||
value: isMeaningfulTime(node.last_seen_at)
|
||||
? formatRelativeTime(node.last_seen_at)
|
||||
: '暂无',
|
||||
? formatRelativeTime(node.last_seen_at, t)
|
||||
: t('na'),
|
||||
icon: Activity,
|
||||
},
|
||||
]}
|
||||
@@ -343,7 +358,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
nodeId={node.id}
|
||||
node={node}
|
||||
variant='edge'
|
||||
connectionHint='OpenResty 当前连接'
|
||||
connectionHint={t('edge.connectionHint')}
|
||||
/>
|
||||
}
|
||||
manage={manageTab}
|
||||
@@ -373,22 +388,21 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除节点「{node.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
{t('deleteDesc', { name: node.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -28,38 +29,6 @@ import {
|
||||
|
||||
type InstallVariant = 'edge' | 'relay' | 'tunnel';
|
||||
|
||||
const variantMeta: Record<
|
||||
InstallVariant,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
tokenLabel: string;
|
||||
scriptLabel?: string;
|
||||
dockerLabel: string;
|
||||
}
|
||||
> = {
|
||||
edge: {
|
||||
title: '边缘节点部署',
|
||||
description: '使用 Agent Token 将边缘节点接入控制端。',
|
||||
tokenLabel: 'Agent Token',
|
||||
dockerLabel: 'Docker 容器部署',
|
||||
},
|
||||
relay: {
|
||||
title: '中继部署命令',
|
||||
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
|
||||
tokenLabel: 'Discovery Token',
|
||||
scriptLabel: '一键脚本部署 (Linux / macOS)',
|
||||
dockerLabel: 'Docker 容器部署',
|
||||
},
|
||||
tunnel: {
|
||||
title: '隧道部署命令',
|
||||
description: '使用 Tunnel Token 将 openflared 客户端接入控制端。',
|
||||
tokenLabel: 'Tunnel Token',
|
||||
scriptLabel: '一键脚本部署 (Linux / macOS)',
|
||||
dockerLabel: 'Docker 容器部署',
|
||||
},
|
||||
};
|
||||
|
||||
export function InstallCommand({
|
||||
node,
|
||||
variant,
|
||||
@@ -67,7 +36,40 @@ export function InstallCommand({
|
||||
node: NodeItem;
|
||||
variant: InstallVariant;
|
||||
}) {
|
||||
const t = useTranslations('nodes.install');
|
||||
const tn = useTranslations('nodes');
|
||||
const [serverUrl, setServerUrl] = useState('');
|
||||
const variantMeta: Record<
|
||||
InstallVariant,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
tokenLabel: string;
|
||||
scriptLabel?: string;
|
||||
dockerLabel: string;
|
||||
}
|
||||
> = {
|
||||
edge: {
|
||||
title: t('edgeTitle'),
|
||||
description: t('edgeDesc'),
|
||||
tokenLabel: t('agentToken'),
|
||||
dockerLabel: t('dockerLabel'),
|
||||
},
|
||||
relay: {
|
||||
title: t('relayTitle'),
|
||||
description: t('relayDesc'),
|
||||
tokenLabel: t('discoveryToken'),
|
||||
scriptLabel: t('scriptLabel'),
|
||||
dockerLabel: t('dockerLabel'),
|
||||
},
|
||||
tunnel: {
|
||||
title: t('tunnelTitle'),
|
||||
description: t('tunnelDesc'),
|
||||
tokenLabel: t('tunnelToken'),
|
||||
scriptLabel: t('scriptLabel'),
|
||||
dockerLabel: t('dockerLabel'),
|
||||
},
|
||||
};
|
||||
const meta = variantMeta[variant];
|
||||
|
||||
const statusQuery = useQuery({
|
||||
@@ -123,7 +125,7 @@ export function InstallCommand({
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(message);
|
||||
} catch {
|
||||
toast.error('复制失败,请手动选择命令文本。');
|
||||
toast.error(t('copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,11 +145,11 @@ export function InstallCommand({
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
void handleCopy(scriptCommand, '部署脚本命令已复制')
|
||||
void handleCopy(scriptCommand, t('copiedScript'))
|
||||
}
|
||||
>
|
||||
<Copy className='size-3.5 mr-1' />
|
||||
复制脚本
|
||||
{t('copyScript')}
|
||||
</Button>
|
||||
) : null}
|
||||
{dockerCommand ? (
|
||||
@@ -156,11 +158,11 @@ export function InstallCommand({
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
void handleCopy(dockerCommand, 'Docker 部署命令已复制')
|
||||
void handleCopy(dockerCommand, t('copiedDocker'))
|
||||
}
|
||||
>
|
||||
<Copy className='size-3.5 mr-1' />
|
||||
复制 Docker
|
||||
{t('copyDocker')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -168,20 +170,20 @@ export function InstallCommand({
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground'>Node ID</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('nodeId')}</p>
|
||||
<p className='mt-1 text-sm break-all font-medium'>{node.node_id}</p>
|
||||
</div>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground'>{meta.tokenLabel}</p>
|
||||
<p className='mt-1 text-sm break-all font-medium'>
|
||||
{node.access_token || '暂无'}
|
||||
{node.access_token || tn('na')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor={`server-url-${variant}`}>
|
||||
Server URL(控制端地址)
|
||||
{t('serverUrl')}
|
||||
</Label>
|
||||
<Input
|
||||
id={`server-url-${variant}`}
|
||||
@@ -190,17 +192,17 @@ export function InstallCommand({
|
||||
placeholder='https://openflare.example.com'
|
||||
/>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
部署脚本和容器将使用此地址连接 OpenFlare 控制端。
|
||||
{t('serverUrlHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!node.access_token ? (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点尚未生成接入 Token,请稍后刷新。
|
||||
{t('noToken')}
|
||||
</p>
|
||||
) : !normalizedServerUrl ? (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
请填写有效的 Server URL 以生成部署命令。
|
||||
{t('needServerUrl')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import {
|
||||
Card,
|
||||
@@ -17,18 +19,23 @@ import {
|
||||
|
||||
export function NetworkTrendChart({
|
||||
points,
|
||||
title = '24 小时网络趋势',
|
||||
description = '按小时展示已提供/接收数据(访问日志);摘要为近 24 小时总量。',
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
points: NetworkTrendPoint[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const t = useTranslations('nodes.networkTrend');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
|
||||
<CardDescription className='text-xs'>{description}</CardDescription>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
{title ?? t('title')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
{description ?? t('description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
@@ -36,11 +43,11 @@ export function NetworkTrendChart({
|
||||
formatTrendHour(point.bucket_started_at),
|
||||
)}
|
||||
summaryScope='total'
|
||||
summaryHint='近 24 小时'
|
||||
summaryHint={t('summaryHint')}
|
||||
yAxisValueFormatter={formatBytes}
|
||||
series={[
|
||||
{
|
||||
label: '接收数据',
|
||||
label: t('received'),
|
||||
color: '#22c55e',
|
||||
fillColor: 'rgba(34, 197, 94, 0.14)',
|
||||
variant: 'area',
|
||||
@@ -48,7 +55,7 @@ export function NetworkTrendChart({
|
||||
valueFormatter: formatBytes,
|
||||
},
|
||||
{
|
||||
label: '已提供数据',
|
||||
label: t('provided'),
|
||||
color: '#38bdf8',
|
||||
values: points.map((point) => point.bytes_provided),
|
||||
valueFormatter: formatBytes,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { type ReactNode, useCallback, useMemo } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -28,16 +29,7 @@ export type NodeDetailKpi = {
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
const TAB_CONFIGS: NodeDetailTabConfig[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: '概览',
|
||||
},
|
||||
{
|
||||
id: 'manage',
|
||||
label: '状态与部署',
|
||||
},
|
||||
];
|
||||
const TAB_IDS: NodeDetailTabId[] = ['overview', 'manage'];
|
||||
|
||||
function resolveTab(value: string | null): NodeDetailTabId | null {
|
||||
if (value === 'overview' || value === 'dashboard') {
|
||||
@@ -70,6 +62,7 @@ export function NodeDetailShell({
|
||||
manage: ReactNode;
|
||||
defaultTab?: NodeDetailTabId;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -110,7 +103,7 @@ export function NodeDetailShell({
|
||||
className='mt-0.5 h-8 w-8 shrink-0 p-0'
|
||||
asChild
|
||||
>
|
||||
<Link href='/nodes' aria-label='返回节点列表'>
|
||||
<Link href='/nodes' aria-label={t('backToList')}>
|
||||
<ArrowLeft className='size-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -162,16 +155,16 @@ export function NodeDetailShell({
|
||||
variant='line'
|
||||
className='h-auto w-full justify-start gap-6 bg-transparent p-0'
|
||||
>
|
||||
{TAB_CONFIGS.map((tab) => (
|
||||
{TAB_IDS.map((tabId) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
key={tabId}
|
||||
value={tabId}
|
||||
className={cn(
|
||||
'h-auto flex-none rounded-none px-0 pb-3 pt-1 text-sm font-semibold',
|
||||
'data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tabId === 'overview' ? t('tabOverview') : t('tabManage')}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -30,49 +31,51 @@ import type {
|
||||
NodeType,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
const nodeSchema = z
|
||||
.object({
|
||||
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z.string().trim().min(1, '请输入节点名称').max(255),
|
||||
ip: z.string(),
|
||||
ip_manual_override: z.boolean(),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_name: z.string(),
|
||||
geo_latitude: z.string(),
|
||||
geo_longitude: z.string(),
|
||||
relay_bind_port: z.string(),
|
||||
relay_vhost_http_port: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.ip_manual_override && !value.ip.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['ip'],
|
||||
message: '锁定 IP 时必须填写节点 IP',
|
||||
});
|
||||
}
|
||||
if (value.node_type === 'tunnel_relay') {
|
||||
const bindPort = Number(value.relay_bind_port);
|
||||
const vhostPort = Number(value.relay_vhost_http_port);
|
||||
if (!Number.isFinite(bindPort) || bindPort <= 0) {
|
||||
function buildNodeSchema(t: (key: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
|
||||
name: z.string().trim().min(1, t('editor.errName')).max(255),
|
||||
ip: z.string(),
|
||||
ip_manual_override: z.boolean(),
|
||||
auto_update_enabled: z.boolean(),
|
||||
geo_manual_override: z.boolean(),
|
||||
geo_name: z.string(),
|
||||
geo_latitude: z.string(),
|
||||
geo_longitude: z.string(),
|
||||
relay_bind_port: z.string(),
|
||||
relay_vhost_http_port: z.string(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.ip_manual_override && !value.ip.trim()) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_bind_port'],
|
||||
message: '请输入有效的绑定端口',
|
||||
path: ['ip'],
|
||||
message: t('editor.errLockIp'),
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_vhost_http_port'],
|
||||
message: '请输入有效的 VHost 端口',
|
||||
});
|
||||
if (value.node_type === 'tunnel_relay') {
|
||||
const bindPort = Number(value.relay_bind_port);
|
||||
const vhostPort = Number(value.relay_vhost_http_port);
|
||||
if (!Number.isFinite(bindPort) || bindPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_bind_port'],
|
||||
message: t('editor.errBindPort'),
|
||||
});
|
||||
}
|
||||
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['relay_vhost_http_port'],
|
||||
message: t('editor.errVhostPort'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type NodeFormValues = z.infer<typeof nodeSchema>;
|
||||
type NodeFormValues = z.infer<ReturnType<typeof buildNodeSchema>>;
|
||||
|
||||
const defaultForm: NodeFormValues = {
|
||||
node_type: 'edge_node',
|
||||
@@ -156,8 +159,10 @@ export function NodeEditorDialog({
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const form = useForm<NodeFormValues>({
|
||||
resolver: zodResolver(nodeSchema),
|
||||
resolver: zodResolver(buildNodeSchema(t)),
|
||||
defaultValues: defaultForm,
|
||||
});
|
||||
|
||||
@@ -179,10 +184,10 @@ export function NodeEditorDialog({
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
预创建节点后可在详情页查看专属 Token 与部署命令。
|
||||
</DialogDescription>
|
||||
<DialogTitle>
|
||||
{node ? t('editor.editTitle') : t('editor.createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t('editor.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
@@ -190,7 +195,7 @@ export function NodeEditorDialog({
|
||||
className='space-y-4'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<Label>节点类型</Label>
|
||||
<Label>{t('editor.nodeType')}</Label>
|
||||
<Select
|
||||
value={nodeType}
|
||||
disabled={Boolean(node)}
|
||||
@@ -202,18 +207,22 @@ export function NodeEditorDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='edge_node'>Edge 节点</SelectItem>
|
||||
<SelectItem value='tunnel_relay'>Relay 节点</SelectItem>
|
||||
<SelectItem value='tunnel_client'>Tunnel 节点</SelectItem>
|
||||
<SelectItem value='edge_node'>{t('editor.typeEdge')}</SelectItem>
|
||||
<SelectItem value='tunnel_relay'>
|
||||
{t('editor.typeRelay')}
|
||||
</SelectItem>
|
||||
<SelectItem value='tunnel_client'>
|
||||
{t('editor.typeTunnel')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='node-name'>节点名称</Label>
|
||||
<Label htmlFor='node-name'>{t('editor.name')}</Label>
|
||||
<Input
|
||||
id='node-name'
|
||||
placeholder='例如 edge-hk-01'
|
||||
placeholder={t('editor.namePlaceholder')}
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
@@ -224,10 +233,10 @@ export function NodeEditorDialog({
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='node-ip'>节点 IP</Label>
|
||||
<Label htmlFor='node-ip'>{t('editor.ip')}</Label>
|
||||
<Input
|
||||
id='node-ip'
|
||||
placeholder='可选,接入后自动上报'
|
||||
placeholder={t('editor.ipPlaceholder')}
|
||||
{...form.register('ip')}
|
||||
/>
|
||||
{form.formState.errors.ip ? (
|
||||
@@ -239,9 +248,9 @@ export function NodeEditorDialog({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>锁定 IP</p>
|
||||
<p className='text-sm font-medium'>{t('editor.lockIp')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
启用后管理端不再自动覆盖 IP
|
||||
{t('editor.lockIpDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -254,9 +263,9 @@ export function NodeEditorDialog({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>自动更新 Agent</p>
|
||||
<p className='text-sm font-medium'>{t('editor.autoUpdate')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
启用后节点将自动拉取正式版更新
|
||||
{t('editor.autoUpdateDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -270,7 +279,7 @@ export function NodeEditorDialog({
|
||||
{nodeType === 'tunnel_relay' ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='relay-bind-port'>绑定端口</Label>
|
||||
<Label htmlFor='relay-bind-port'>{t('editor.bindPort')}</Label>
|
||||
<Input
|
||||
id='relay-bind-port'
|
||||
{...form.register('relay_bind_port')}
|
||||
@@ -282,7 +291,9 @@ export function NodeEditorDialog({
|
||||
) : null}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='relay-vhost-port'>VHost 端口</Label>
|
||||
<Label htmlFor='relay-vhost-port'>
|
||||
{t('editor.vhostPort')}
|
||||
</Label>
|
||||
<Input
|
||||
id='relay-vhost-port'
|
||||
{...form.register('relay_vhost_http_port')}
|
||||
@@ -298,8 +309,10 @@ export function NodeEditorDialog({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>手动地图点位</p>
|
||||
<p className='text-xs text-muted-foreground'>用于总览地图展示</p>
|
||||
<p className='text-sm font-medium'>{t('editor.manualGeo')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('editor.manualGeoDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={geoManualOverride}
|
||||
@@ -312,15 +325,15 @@ export function NodeEditorDialog({
|
||||
{geoManualOverride ? (
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
<div className='space-y-2 sm:col-span-3'>
|
||||
<Label htmlFor='geo-name'>位置名称</Label>
|
||||
<Label htmlFor='geo-name'>{t('editor.geoName')}</Label>
|
||||
<Input id='geo-name' {...form.register('geo_name')} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='geo-lat'>纬度</Label>
|
||||
<Label htmlFor='geo-lat'>{t('editor.latitude')}</Label>
|
||||
<Input id='geo-lat' {...form.register('geo_latitude')} />
|
||||
</div>
|
||||
<div className='space-y-2 sm:col-span-2'>
|
||||
<Label htmlFor='geo-lng'>经度</Label>
|
||||
<Label htmlFor='geo-lng'>{t('editor.longitude')}</Label>
|
||||
<Input id='geo-lng' {...form.register('geo_longitude')} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,10 +346,14 @@ export function NodeEditorDialog({
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</Button>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
|
||||
{submitting
|
||||
? t('editor.saving')
|
||||
: node
|
||||
? t('editor.saveEdit')
|
||||
: t('editor.createAction')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -120,18 +121,19 @@ function SystemProfileCard({
|
||||
profile: NodeSystemProfile;
|
||||
nodeName: string;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-4 rounded-lg border px-4 py-4'>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
主机名
|
||||
{t('obs.hostname')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{profile.hostname || nodeName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
操作系统
|
||||
{t('obs.os')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{profile.os_name || 'unknown'}
|
||||
@@ -140,7 +142,7 @@ function SystemProfileCard({
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
内核 / 架构
|
||||
{t('obs.kernelArch')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{profile.kernel_version || 'unknown'} ·{' '}
|
||||
@@ -149,25 +151,27 @@ function SystemProfileCard({
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
在线时长
|
||||
{t('obs.uptime')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{formatUptime(profile.uptime_seconds, t)}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{formatUptime(profile.uptime_seconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 rounded-lg border px-4 py-4'>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
CPU
|
||||
{t('obs.cpu')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{profile.cpu_model || 'unknown'}</p>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
{profile.cpu_cores || 0} 核
|
||||
{t('obs.cpuCores', { count: profile.cpu_cores || 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
总内存
|
||||
{t('obs.totalMemory')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{formatBytes(profile.total_memory_bytes)}
|
||||
@@ -175,7 +179,7 @@ function SystemProfileCard({
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
总存储
|
||||
{t('obs.totalStorage')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{formatBytes(profile.total_disk_bytes)}
|
||||
@@ -183,7 +187,7 @@ function SystemProfileCard({
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
上报时间
|
||||
{t('obs.reportedAt')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>
|
||||
{isMeaningfulTime(profile.reported_at)
|
||||
@@ -211,16 +215,15 @@ function HealthEventTimeline({
|
||||
onCleanup: () => void;
|
||||
cleanupPending: boolean;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='flex-row items-center justify-between space-y-0'>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
健康事件时间线
|
||||
{t('obs.eventsTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
保留活动与已恢复事件,帮助判断运行状态。
|
||||
</CardDescription>
|
||||
<CardDescription>{t('obs.eventsDesc')}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -230,7 +233,7 @@ function HealthEventTimeline({
|
||||
onClick={onCleanup}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理日志
|
||||
{t('obs.cleanupLogs')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
@@ -239,9 +242,9 @@ function HealthEventTimeline({
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{(
|
||||
[
|
||||
['all', '全部事件'],
|
||||
['active', '活动中'],
|
||||
['resolved', '已恢复'],
|
||||
['all', t('obs.filterAll')],
|
||||
['active', t('obs.filterActive')],
|
||||
['resolved', t('obs.filterResolved')],
|
||||
] as const
|
||||
).map(([filter, label]) => (
|
||||
<Button
|
||||
@@ -261,7 +264,7 @@ function HealthEventTimeline({
|
||||
|
||||
{events.length === 0 ? (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
当前筛选下没有健康事件。
|
||||
{t('obs.filterEmpty')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -276,28 +279,32 @@ function HealthEventTimeline({
|
||||
tone={getHealthEventTone(event)}
|
||||
/>
|
||||
<NodeStatusBadge
|
||||
label={event.status === 'active' ? '活动中' : '已恢复'}
|
||||
label={
|
||||
event.status === 'active'
|
||||
? t('obs.filterActive')
|
||||
: t('obs.filterResolved')
|
||||
}
|
||||
tone={event.status === 'active' ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{event.message || '暂无详细消息'}
|
||||
{event.message || t('obs.noMessage')}
|
||||
</p>
|
||||
<div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'>
|
||||
<p>
|
||||
首次触发:
|
||||
{t('obs.firstTriggered')}
|
||||
{isMeaningfulTime(event.first_triggered_at)
|
||||
? ` ${formatDateTime(event.first_triggered_at)}`
|
||||
: ' —'}
|
||||
</p>
|
||||
<p>
|
||||
最近触发:
|
||||
{t('obs.lastTriggered')}
|
||||
{isMeaningfulTime(event.last_triggered_at)
|
||||
? ` ${formatDateTime(event.last_triggered_at)}`
|
||||
: ' —'}
|
||||
</p>
|
||||
<p>
|
||||
恢复时间:
|
||||
{t('obs.resolvedAt')}
|
||||
{isMeaningfulTime(event.resolved_at) && event.resolved_at
|
||||
? ` ${formatDateTime(event.resolved_at)}`
|
||||
: ' —'}
|
||||
@@ -308,7 +315,7 @@ function HealthEventTimeline({
|
||||
</>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点当前还没有上报健康事件记录。
|
||||
{t('obs.noEvents')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -320,13 +327,16 @@ export function NodeObservability({
|
||||
nodeId,
|
||||
node,
|
||||
variant = 'edge',
|
||||
connectionHint = 'OpenResty 当前连接',
|
||||
connectionHint,
|
||||
}: {
|
||||
nodeId: number;
|
||||
node?: NodeItem;
|
||||
variant?: NodeObservabilityVariant;
|
||||
connectionHint?: string;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const resolvedConnectionHint = connectionHint ?? t('edge.connectionHint');
|
||||
const queryClient = useQueryClient();
|
||||
const [healthEventFilter, setHealthEventFilter] =
|
||||
useState<HealthEventFilter>('all');
|
||||
@@ -344,8 +354,8 @@ export function NodeObservability({
|
||||
onSuccess: async (result) => {
|
||||
toast.success(
|
||||
result.deleted_count > 0
|
||||
? `已清理 ${result.deleted_count} 条健康事件日志`
|
||||
: '当前没有可清理的健康事件日志',
|
||||
? t('obs.cleaned', { count: result.deleted_count })
|
||||
: t('obs.nothingToClean'),
|
||||
);
|
||||
setCleanupOpen(false);
|
||||
await Promise.all([
|
||||
@@ -357,7 +367,7 @@ export function NodeObservability({
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const observability = observabilityQuery.data ?? null;
|
||||
@@ -432,7 +442,7 @@ export function NodeObservability({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardContent className='flex items-center justify-center py-10 text-sm text-muted-foreground'>
|
||||
<Loader2 className='size-4 mr-2 animate-spin' />
|
||||
加载运行观测数据中...
|
||||
{t('obs.loading')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -443,7 +453,7 @@ export function NodeObservability({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardContent className='py-6'>
|
||||
<p className='text-sm text-destructive'>
|
||||
{getErrorMessage(observabilityQuery.error)}
|
||||
{getErrorMessage(observabilityQuery.error, t('requestFailed'))}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -455,20 +465,20 @@ export function NodeObservability({
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<SummaryStat
|
||||
label='运行诊断'
|
||||
label={t('obs.diag')}
|
||||
value={
|
||||
activeHealthEvents.length
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
: '运行稳定'
|
||||
? t('obs.activeIssues', { count: activeHealthEvents.length })
|
||||
: t('obs.stable')
|
||||
}
|
||||
hint={
|
||||
latestHealthEvent
|
||||
? getHealthEventLabel(latestHealthEvent)
|
||||
: '当前没有活动中的健康事件'
|
||||
: t('obs.noActiveEvents')
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label='系统核心'
|
||||
label={t('obs.systemCore')}
|
||||
value={profile?.hostname || '—'}
|
||||
hint={
|
||||
profile
|
||||
@@ -477,9 +487,9 @@ export function NodeObservability({
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label='在线时长'
|
||||
value={formatUptime(profile?.uptime_seconds)}
|
||||
hint='来自节点系统画像上报'
|
||||
label={t('obs.uptime')}
|
||||
value={formatUptime(profile?.uptime_seconds, t)}
|
||||
hint={t('obs.uptimeFromProfile')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -487,16 +497,16 @@ export function NodeObservability({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
系统画像
|
||||
{t('obs.profileTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>节点上报的主机与硬件信息</CardDescription>
|
||||
<CardDescription>{t('obs.profileDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{profile ? (
|
||||
<SystemProfileCard profile={profile} nodeName={nodeName} />
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已接入,但尚未上报完整系统画像。
|
||||
{t('obs.noProfile')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -505,45 +515,50 @@ export function NodeObservability({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
实时资源快照
|
||||
{t('obs.snapshotTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>最近一次 metrics 上报</CardDescription>
|
||||
<CardDescription>{t('obs.snapshotDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{latestMetric ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<MetricBar
|
||||
label='CPU'
|
||||
label={t('obs.cpu')}
|
||||
value={formatPercent(latestMetric.cpu_usage_percent)}
|
||||
progress={latestMetric.cpu_usage_percent}
|
||||
hint={
|
||||
isMeaningfulTime(latestMetric.captured_at)
|
||||
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
|
||||
? t('obs.snapshotAgo', {
|
||||
time: formatRelativeTime(
|
||||
latestMetric.captured_at,
|
||||
t,
|
||||
),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricBar
|
||||
label='内存'
|
||||
label={t('obs.memory')}
|
||||
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
|
||||
progress={memoryUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label='存储'
|
||||
label={t('obs.storage')}
|
||||
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
|
||||
progress={storageUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label='连接数'
|
||||
label={t('obs.connections')}
|
||||
value={formatMetricCount(
|
||||
latestMetric.openresty_connections,
|
||||
)}
|
||||
progress={null}
|
||||
hint={connectionHint}
|
||||
hint={resolvedConnectionHint}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已接入,但尚未上报资源快照。
|
||||
{t('obs.noSnapshot')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -562,21 +577,23 @@ export function NodeObservability({
|
||||
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
|
||||
{t('obs.cleanupDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={() => cleanupMutation.mutate()}
|
||||
>
|
||||
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
|
||||
{cleanupMutation.isPending
|
||||
? t('obs.cleaning')
|
||||
: t('obs.confirmCleanup')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -589,43 +606,57 @@ export function NodeObservability({
|
||||
<div className='space-y-6'>
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<SummaryStat
|
||||
label='运行诊断'
|
||||
label={t('obs.diag')}
|
||||
value={
|
||||
activeHealthEvents.length
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
: '运行稳定'
|
||||
? t('obs.activeIssues', { count: activeHealthEvents.length })
|
||||
: t('obs.stable')
|
||||
}
|
||||
hint={
|
||||
latestHealthEvent
|
||||
? getHealthEventLabel(latestHealthEvent)
|
||||
: '当前没有活动中的健康事件'
|
||||
: t('obs.noActiveEvents')
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label='查询窗口请求'
|
||||
label={t('obs.windowRequests')}
|
||||
value={formatMetricCount(trafficSummary?.request_count)}
|
||||
hint={
|
||||
trafficSummary
|
||||
? `查询窗口独立访客 ${formatMetricCount(trafficSummary.unique_visitor_count)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
|
||||
: '当前没有可展示的请求窗口摘要'
|
||||
? t('obs.windowVisitorsHint', {
|
||||
visitors: formatMetricCount(
|
||||
trafficSummary.unique_visitor_count,
|
||||
),
|
||||
rate: trafficSummary.error_rate_percent.toFixed(1),
|
||||
})
|
||||
: t('obs.noWindowSummary')
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label='容量压力'
|
||||
value={healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'}
|
||||
label={t('obs.capacityPressure')}
|
||||
value={
|
||||
healthSummary?.has_capacity_risk
|
||||
? t('obs.needsAttention')
|
||||
: t('obs.normalRange')
|
||||
}
|
||||
hint={
|
||||
latestMetric
|
||||
? `CPU ${formatPercent(latestMetric.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
|
||||
: '当前没有资源快照'
|
||||
? t('obs.cpuStorage', {
|
||||
cpu: formatPercent(latestMetric.cpu_usage_percent),
|
||||
storage: formatPercent(storageUsageRatio),
|
||||
})
|
||||
: t('obs.noResourceSnapshot')
|
||||
}
|
||||
/>
|
||||
<SummaryStat
|
||||
label='来源信号'
|
||||
label={t('obs.sourceSignal')}
|
||||
value={topSourceCountry?.key ?? '—'}
|
||||
hint={
|
||||
topSourceCountry
|
||||
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
|
||||
: '当前没有来源分布数据'
|
||||
? t('obs.requestTimes', {
|
||||
count: topSourceCountry.value.toLocaleString('zh-CN'),
|
||||
})
|
||||
: t('obs.noSourceDist')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -633,14 +664,16 @@ export function NodeObservability({
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-semibold'>系统信息</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
{t('obs.systemInfo')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{profile ? (
|
||||
<SystemProfileCard profile={profile} nodeName={nodeName} />
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已经接入,但还没有上报完整系统画像。
|
||||
{t('obs.noProfileLong')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -648,41 +681,45 @@ export function NodeObservability({
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-semibold'>实时资源</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
{t('obs.realtimeResources')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{latestMetric ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<MetricBar
|
||||
label='CPU'
|
||||
label={t('obs.cpu')}
|
||||
value={formatPercent(latestMetric.cpu_usage_percent)}
|
||||
progress={latestMetric.cpu_usage_percent}
|
||||
hint={
|
||||
isMeaningfulTime(latestMetric.captured_at)
|
||||
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
|
||||
? t('obs.snapshotAgo', {
|
||||
time: formatRelativeTime(latestMetric.captured_at, t),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricBar
|
||||
label='内存'
|
||||
label={t('obs.memory')}
|
||||
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
|
||||
progress={memoryUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label='存储'
|
||||
label={t('obs.storage')}
|
||||
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
|
||||
progress={storageUsageRatio}
|
||||
/>
|
||||
<MetricBar
|
||||
label='连接数'
|
||||
label={t('obs.connections')}
|
||||
value={formatMetricCount(latestMetric.openresty_connections)}
|
||||
progress={null}
|
||||
hint={connectionHint}
|
||||
hint={resolvedConnectionHint}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已经接入,但还没有上报资源快照。
|
||||
{t('obs.noSnapshotLong')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -690,7 +727,9 @@ export function NodeObservability({
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-semibold'>网络流量</CardTitle>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
{t('obs.networkTraffic')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{latestMetric ? (
|
||||
@@ -698,18 +737,20 @@ export function NodeObservability({
|
||||
{node ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
label={getNodeStatusLabel(node.status, t)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
<NodeStatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
label={getOpenrestyStatusLabel(node.openresty_status, t)}
|
||||
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||
/>
|
||||
<NodeStatusBadge
|
||||
label={
|
||||
activeHealthEvents.length
|
||||
? `${activeHealthEvents.length} 个活动异常`
|
||||
: '无活动异常'
|
||||
? t('obs.activeIssues', {
|
||||
count: activeHealthEvents.length,
|
||||
})
|
||||
: t('obs.noActiveIssue')
|
||||
}
|
||||
tone={activeHealthEvents.length ? 'warning' : 'success'}
|
||||
/>
|
||||
@@ -719,16 +760,14 @@ export function NodeObservability({
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border px-3 py-3 sm:col-span-2'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
OpenResty 连接
|
||||
{t('obs.openrestyConn')}
|
||||
</p>
|
||||
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
|
||||
<p>
|
||||
当前连接:
|
||||
{t('obs.currentConn')}
|
||||
{latestMetric.openresty_connections ?? 0}
|
||||
</p>
|
||||
<p className='text-xs'>
|
||||
业务吞吐请看访问日志趋势(已提供/接收数据)
|
||||
</p>
|
||||
<p className='text-xs'>{t('obs.throughputHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -736,35 +775,42 @@ export function NodeObservability({
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
查询窗口独立访客
|
||||
{t('obs.windowVisitors')}
|
||||
</p>
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{formatMetricCount(trafficSummary?.unique_visitor_count)}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{trafficSummary
|
||||
? `请求 ${formatMetricCount(trafficSummary.request_count)} · 估算 QPS ${trafficSummary.estimated_qps.toFixed(2)}`
|
||||
: '暂无窗口流量摘要'}
|
||||
? t('obs.reqQps', {
|
||||
requests: formatMetricCount(
|
||||
trafficSummary.request_count,
|
||||
),
|
||||
qps: trafficSummary.estimated_qps.toFixed(2),
|
||||
})
|
||||
: t('obs.noTrafficSummary')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border px-3 py-3'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
查询窗口错误
|
||||
{t('obs.windowErrors')}
|
||||
</p>
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{formatMetricCount(trafficSummary?.error_count)}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{trafficSummary
|
||||
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
|
||||
: '暂无错误率摘要'}
|
||||
? t('obs.errorRate', {
|
||||
rate: trafficSummary.error_rate_percent.toFixed(1),
|
||||
})
|
||||
: t('obs.noErrorSummary')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
节点已经接入,但还没有上报网络流量相关快照。
|
||||
{t('obs.noNetworkSnapshot')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -774,11 +820,11 @@ export function NodeObservability({
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<TrafficTrendChart
|
||||
points={trends?.traffic_24h ?? []}
|
||||
description='按小时聚合该节点的请求量和错误量。'
|
||||
description={t('obs.trafficTrendDesc')}
|
||||
/>
|
||||
<CapacityTrendChart
|
||||
points={trends?.capacity_24h ?? []}
|
||||
description='观察该节点 CPU 与内存使用率在 24 小时内的变化。'
|
||||
description={t('obs.capacityTrendDesc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -791,50 +837,47 @@ export function NodeObservability({
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
请求结构分布
|
||||
{t('obs.structureTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
聚合最近 24
|
||||
小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
|
||||
</CardDescription>
|
||||
<CardDescription>{t('obs.structureDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='mb-6 grid gap-4 md:grid-cols-3'>
|
||||
<div className='rounded-lg border px-4 py-4'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
主状态码
|
||||
{t('obs.mainStatus')}
|
||||
</p>
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{dominantStatusCode?.label ?? '—'}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{dominantStatusCode
|
||||
? `${dominantStatusCode.value} 次`
|
||||
: '暂无状态码分布'}
|
||||
? t('obs.times', { count: dominantStatusCode.value })
|
||||
: t('obs.noStatusDist')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border px-4 py-4'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
Top Domain
|
||||
{t('obs.topDomain')}
|
||||
</p>
|
||||
<p className='mt-3 truncate text-2xl font-semibold'>
|
||||
{dominantDomain?.label ?? '—'}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{dominantDomain
|
||||
? `${dominantDomain.value} 次`
|
||||
: '暂无域名分布'}
|
||||
? t('obs.times', { count: dominantDomain.value })
|
||||
: t('obs.noDomainDist')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border px-4 py-4'>
|
||||
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
已恢复事件
|
||||
{t('obs.resolvedEvents')}
|
||||
</p>
|
||||
<p className='mt-3 text-2xl font-semibold'>
|
||||
{resolvedHealthEvents.length}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
最近 24 小时已恢复健康事件
|
||||
{t('obs.resolvedHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -842,20 +885,20 @@ export function NodeObservability({
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<div>
|
||||
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
状态码分布
|
||||
{t('obs.statusDist')}
|
||||
</p>
|
||||
<DistributionList
|
||||
items={statusCodeDistribution}
|
||||
emptyMessage='暂无状态码分布'
|
||||
emptyMessage={t('obs.noStatusDist')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
|
||||
Top Domain
|
||||
{t('obs.topDomain')}
|
||||
</p>
|
||||
<DistributionList
|
||||
items={topDomains}
|
||||
emptyMessage='暂无域名分布'
|
||||
emptyMessage={t('obs.noDomainDist')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -875,21 +918,23 @@ export function NodeObservability({
|
||||
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
|
||||
{t('obs.cleanupDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleanupMutation.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={cleanupMutation.isPending}
|
||||
onClick={() => cleanupMutation.mutate()}
|
||||
>
|
||||
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
|
||||
{cleanupMutation.isPending
|
||||
? t('obs.cleaning')
|
||||
: t('obs.confirmCleanup')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
|
||||
|
||||
const filters: Array<{ key: NodeFilter; label: string; href: string }> = [
|
||||
{ key: 'all', label: '全部节点', href: '/nodes' },
|
||||
{ key: 'edge', label: 'Edge', href: '/nodes?filter=edge' },
|
||||
{ key: 'relay', label: 'Relay', href: '/nodes?filter=relay' },
|
||||
{ key: 'tunnel', label: 'Tunnel', href: '/nodes?filter=tunnel' },
|
||||
const filters: Array<{ key: NodeFilter; href: string }> = [
|
||||
{ key: 'all', href: '/nodes' },
|
||||
{ key: 'edge', href: '/nodes?filter=edge' },
|
||||
{ key: 'relay', href: '/nodes?filter=relay' },
|
||||
{ key: 'tunnel', href: '/nodes?filter=tunnel' },
|
||||
];
|
||||
|
||||
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter {
|
||||
@@ -44,23 +45,33 @@ export function filterNodesByType<T extends { node_type: string }>(
|
||||
}
|
||||
}
|
||||
|
||||
export function getFilterDescription(filter: NodeFilter) {
|
||||
export function getFilterDescription(
|
||||
filter: NodeFilter,
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
switch (filter) {
|
||||
case 'relay':
|
||||
return '当前仅展示 Relay 节点。';
|
||||
return t('filter.descRelay');
|
||||
case 'tunnel':
|
||||
return '当前仅展示 Tunnel 节点。';
|
||||
return t('filter.descTunnel');
|
||||
case 'edge':
|
||||
return '当前仅展示 Edge 节点。';
|
||||
return t('filter.descEdge');
|
||||
case 'all':
|
||||
default:
|
||||
return '当前展示全部节点。';
|
||||
return t('filter.descAll');
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeTypeFilter() {
|
||||
const t = useTranslations('nodes');
|
||||
const searchParams = useSearchParams();
|
||||
const activeFilter = getNodeFilter(searchParams);
|
||||
const filterLabels: Record<NodeFilter, string> = {
|
||||
all: t('filter.all'),
|
||||
edge: 'Edge',
|
||||
relay: 'Relay',
|
||||
tunnel: 'Tunnel',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
@@ -75,7 +86,7 @@ export function NodeTypeFilter() {
|
||||
: 'border-border text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
{filterLabels[item.key]}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,11 @@ export const FLARED_WS_CONNECTED_LAST_SEEN =
|
||||
|
||||
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
export type NodeMessageT = (
|
||||
key: string,
|
||||
values?: Record<string, string | number | Date>,
|
||||
) => string;
|
||||
|
||||
export function isWSConnectedLastSeen(value: string | null | undefined) {
|
||||
return (
|
||||
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
|
||||
@@ -27,7 +32,7 @@ export function isMeaningfulTime(
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRelativeTime(value: string) {
|
||||
export function formatRelativeTime(value: string, t: NodeMessageT) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
@@ -35,16 +40,16 @@ export function formatRelativeTime(value: string) {
|
||||
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
if (diffMinutes < 1) return '刚刚';
|
||||
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
|
||||
if (diffMinutes < 1) return t('relative.justNow');
|
||||
if (diffMinutes < 60) return t('relative.minutesAgo', { count: diffMinutes });
|
||||
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
||||
if (diffHours < 24) return t('relative.hoursAgo', { count: diffHours });
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays} 天前`;
|
||||
if (diffDays < 30) return t('relative.daysAgo', { count: diffDays });
|
||||
|
||||
return `${Math.floor(diffDays / 30)} 个月前`;
|
||||
return t('relative.monthsAgo', { count: Math.floor(diffDays / 30) });
|
||||
}
|
||||
|
||||
export function getNodeStatusTone(status: NodeStatus): StatusTone {
|
||||
@@ -53,10 +58,10 @@ export function getNodeStatusTone(status: NodeStatus): StatusTone {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
export function getNodeStatusLabel(status: NodeStatus) {
|
||||
if (status === 'online') return '在线';
|
||||
if (status === 'pending') return '待接入';
|
||||
return '离线';
|
||||
export function getNodeStatusLabel(status: NodeStatus, t: NodeMessageT) {
|
||||
if (status === 'online') return t('status.online');
|
||||
if (status === 'pending') return t('status.pending');
|
||||
return t('status.offline');
|
||||
}
|
||||
|
||||
export function getApplyTone(result: ApplyResult): StatusTone {
|
||||
@@ -66,11 +71,11 @@ export function getApplyTone(result: ApplyResult): StatusTone {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getApplyLabel(result: ApplyResult) {
|
||||
if (result === 'success') return '成功';
|
||||
if (result === 'warning') return '警告';
|
||||
if (result === 'failed') return '失败';
|
||||
return '暂无';
|
||||
export function getApplyLabel(result: ApplyResult, t: NodeMessageT) {
|
||||
if (result === 'success') return t('apply.success');
|
||||
if (result === 'warning') return t('apply.warning');
|
||||
if (result === 'failed') return t('apply.failed');
|
||||
return t('apply.none');
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
|
||||
@@ -79,10 +84,13 @@ export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getOpenrestyStatusLabel(status: OpenrestyStatus) {
|
||||
if (status === 'healthy') return '健康';
|
||||
if (status === 'unhealthy') return '异常';
|
||||
return '未知';
|
||||
export function getOpenrestyStatusLabel(
|
||||
status: OpenrestyStatus,
|
||||
t: NodeMessageT,
|
||||
) {
|
||||
if (status === 'healthy') return t('health.healthy');
|
||||
if (status === 'unhealthy') return t('health.unhealthy');
|
||||
return t('health.unknown');
|
||||
}
|
||||
|
||||
export function getRelayStatusTone(
|
||||
@@ -93,10 +101,13 @@ export function getRelayStatusTone(
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
export function getRelayStatusLabel(status: string | null | undefined) {
|
||||
if (status === 'healthy') return '健康';
|
||||
if (status === 'unhealthy') return '异常';
|
||||
return '未知';
|
||||
export function getRelayStatusLabel(
|
||||
status: string | null | undefined,
|
||||
t: NodeMessageT,
|
||||
) {
|
||||
if (status === 'healthy') return t('health.healthy');
|
||||
if (status === 'unhealthy') return t('health.unhealthy');
|
||||
return t('health.unknown');
|
||||
}
|
||||
|
||||
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
|
||||
@@ -105,8 +116,8 @@ export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
|
||||
return 'Edge';
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
export function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function getServerUrl(value: string) {
|
||||
@@ -264,7 +275,10 @@ export function formatUsageRatio(used?: number | null, total?: number | null) {
|
||||
return Math.max(0, Math.min(100, (used / total) * 100));
|
||||
}
|
||||
|
||||
export function formatUptime(seconds?: number | null) {
|
||||
export function formatUptime(
|
||||
seconds: number | null | undefined,
|
||||
t: NodeMessageT,
|
||||
) {
|
||||
if (!seconds || seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
@@ -274,12 +288,12 @@ export function formatUptime(seconds?: number | null) {
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days} 天 ${hours} 小时`;
|
||||
return t('uptime.daysHours', { days, hours });
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours} 小时 ${minutes} 分钟`;
|
||||
return t('uptime.hoursMinutes', { hours, minutes });
|
||||
}
|
||||
return `${minutes} 分钟`;
|
||||
return t('uptime.minutes', { minutes });
|
||||
}
|
||||
|
||||
export function getHealthEventTone(event: {
|
||||
@@ -302,17 +316,17 @@ export function getHealthEventLabel(event: { event_type: string }) {
|
||||
return event.event_type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
export function getFlaredStatusLabel(node: NodeItem) {
|
||||
export function getFlaredStatusLabel(node: NodeItem, t: NodeMessageT) {
|
||||
if (isWSConnectedLastSeen(node.last_seen_at)) {
|
||||
return 'WS 已连接';
|
||||
return t('status.wsConnected');
|
||||
}
|
||||
if (node.status === 'online') {
|
||||
return '运行中';
|
||||
return t('status.running');
|
||||
}
|
||||
if (node.status === 'pending') {
|
||||
return '待接入';
|
||||
return t('status.pending');
|
||||
}
|
||||
return '离线';
|
||||
return t('status.offline');
|
||||
}
|
||||
|
||||
export function getFlaredStatusTone(node: NodeItem): StatusTone {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -42,19 +43,24 @@ export function NodesTable({
|
||||
onEdit: (node: NodeItem) => void;
|
||||
onDelete: (node: NodeItem) => void;
|
||||
}) {
|
||||
const t = useTranslations('nodes');
|
||||
return (
|
||||
<div className='border border-dashed rounded-lg overflow-hidden'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='py-2 h-8'>节点</TableHead>
|
||||
<TableHead className='py-2 h-8'>状态</TableHead>
|
||||
<TableHead className='py-2 h-8'>Version</TableHead>
|
||||
<TableHead className='py-2 h-8'>运行健康</TableHead>
|
||||
<TableHead className='py-2 h-8'>当前版本</TableHead>
|
||||
<TableHead className='py-2 h-8'>最近应用</TableHead>
|
||||
<TableHead className='py-2 h-8'>最近心跳</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('table.node')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('table.status')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('table.version')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('table.runHealth')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>
|
||||
{t('table.currentVersion')}
|
||||
</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('table.latestApply')}</TableHead>
|
||||
<TableHead className='py-2 h-8'>{t('table.lastSeen')}</TableHead>
|
||||
<TableHead className='py-2 h-8 text-right'>
|
||||
{t('table.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -70,17 +76,19 @@ export function NodesTable({
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
IP:{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
{t('ipLine', { ip: node.ip || '—' })}
|
||||
{node.ip_manual_override ? t('lockedSuffix') : ''}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
位置:{node.geo_name || '未配置地图点位'}
|
||||
{t('locationLine', {
|
||||
location: node.geo_name || t('noGeo'),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='py-3'>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
label={getNodeStatusLabel(node.status, t)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -90,41 +98,45 @@ export function NodesTable({
|
||||
<TableCell className='py-3'>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<NodeStatusBadge
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
label={getRelayStatusLabel(node.relay_status, t)}
|
||||
tone={getRelayStatusTone(node.relay_status)}
|
||||
/>
|
||||
) : node.node_type === 'tunnel_client' ? (
|
||||
<NodeStatusBadge
|
||||
label={node.status === 'online' ? '运行中' : '未知'}
|
||||
label={
|
||||
node.status === 'online' ? t('running') : t('unknown')
|
||||
}
|
||||
tone={node.status === 'online' ? 'success' : 'warning'}
|
||||
/>
|
||||
) : (
|
||||
<NodeStatusBadge
|
||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||
label={getOpenrestyStatusLabel(node.openresty_status, t)}
|
||||
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='py-3 text-muted-foreground'>
|
||||
{node.current_version ||
|
||||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
|
||||
(node.node_type === 'tunnel_relay'
|
||||
? t('liveConfig')
|
||||
: t('notApplied'))}
|
||||
</TableCell>
|
||||
<TableCell className='py-3'>
|
||||
{node.node_type === 'tunnel_relay' ? (
|
||||
<span className='text-sm text-muted-foreground'>—</span>
|
||||
) : (
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
label={getApplyLabel(node.latest_apply_result, t)}
|
||||
tone={getApplyTone(node.latest_apply_result)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='py-3 text-muted-foreground'>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
? t('wsConnected')
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: t('na')}
|
||||
</TableCell>
|
||||
<TableCell className='py-3'>
|
||||
<div className='flex flex-wrap justify-end gap-1'>
|
||||
@@ -134,7 +146,9 @@ export function NodesTable({
|
||||
className='h-7 text-xs'
|
||||
asChild
|
||||
>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
|
||||
<Link href={`/nodes/detail?id=${node.id}`}>
|
||||
{t('detail')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -143,7 +157,7 @@ export function NodesTable({
|
||||
onClick={() => onEdit(node)}
|
||||
>
|
||||
<Pencil className='size-3 mr-1' />
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -153,7 +167,7 @@ export function NodesTable({
|
||||
onClick={() => onDelete(node)}
|
||||
>
|
||||
<Trash2 className='size-3 mr-1' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -78,6 +79,8 @@ function systemConfigMap(configs: SystemConfig[]) {
|
||||
}
|
||||
|
||||
export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -106,7 +109,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
mutationFn: async (port: string) => {
|
||||
const portNum = parseInt(port, 10);
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||
throw new Error('端口号必须在 1 ~ 65535 范围内');
|
||||
throw new Error(t('relay.invalidPort'));
|
||||
}
|
||||
const portCfg = configs['relay_frps_web_ui_port'];
|
||||
await services.adminSystemConfig.updateSystemConfig(
|
||||
@@ -121,21 +124,23 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'system-configs'],
|
||||
});
|
||||
toast.success('FRPS WebUI 端口配置已更新');
|
||||
toast.success(t('relay.toastPortSaved'));
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '更新端口失败'),
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('relay.portSaveFailed'),
|
||||
),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
|
||||
NodeService.updateNode(node.id, payload),
|
||||
onSuccess: async () => {
|
||||
toast.success('中继节点已更新');
|
||||
toast.success(t('relay.toastUpdated'));
|
||||
setEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const webServerMutation = useMutation({
|
||||
@@ -155,19 +160,19 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
relay_web_server_enabled: enabled,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('FRPS WebUI 设置已更新');
|
||||
toast.success(t('relay.toastWebui'));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const forceSyncMutation = useMutation({
|
||||
mutationFn: () => NodeService.requestForceSync(node.id),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(`已向中继节点 ${updated.name} 下发强制同步指令`);
|
||||
toast.success(t('relay.toastForceSync', { name: updated.name }));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
@@ -187,22 +192,28 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(
|
||||
`已向中继节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
|
||||
t('relay.toastUpgrade', {
|
||||
name: updated.name,
|
||||
channel:
|
||||
updated.update_channel === 'preview'
|
||||
? t('channel.preview')
|
||||
: t('channel.stable'),
|
||||
}),
|
||||
);
|
||||
setUpgradeOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => NodeService.deleteNode(node.id),
|
||||
onSuccess: async () => {
|
||||
toast.success('中继节点已删除');
|
||||
toast.success(t('relay.toastDeleted'));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
router.push('/nodes');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
@@ -236,7 +247,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
className='h-8'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -245,7 +256,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1.5' />
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -255,7 +266,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => forceSyncMutation.mutate()}
|
||||
>
|
||||
<RotateCcw className='size-3.5 mr-1.5' />
|
||||
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
|
||||
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -264,7 +275,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => setUpgradeOpen(true)}
|
||||
>
|
||||
<Upload className='size-3.5 mr-1.5' />
|
||||
{node.update_requested ? '查看升级' : '升级 Relay'}
|
||||
{node.update_requested ? t('viewUpgrade') : t('relay.upgrade')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -273,7 +284,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1.5' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -284,76 +295,76 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<NodeSectionCard
|
||||
title='中继运行状态'
|
||||
description='frps 在线情况与负载摘要'
|
||||
title={t('relay.runStatus')}
|
||||
description={t('relay.runStatusDesc')}
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='运行状态'>
|
||||
<NodeInfoRow label={t('edge.runStatus')}>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
label={getNodeStatusLabel(node.status, t)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='中继健康'>
|
||||
<NodeInfoRow label={t('relay.health')}>
|
||||
<NodeStatusBadge
|
||||
label={getRelayStatusLabel(node.relay_status)}
|
||||
label={getRelayStatusLabel(node.relay_status, t)}
|
||||
tone={getRelayStatusTone(node.relay_status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近心跳'>
|
||||
<NodeInfoRow label={t('kpiLastSeen')}>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
? t('wsConnected')
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: t('na')}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='活动连接 / 代理数'>
|
||||
<NodeInfoRow label={t('relay.connections')}>
|
||||
{node.relay_frps_connections ?? '—'} /{' '}
|
||||
{node.relay_frps_proxy_count ?? '—'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='IP 地址'>
|
||||
<NodeInfoRow label={t('edge.ip')}>
|
||||
{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
{node.ip_manual_override ? t('lockedSuffix') : ''}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<NodeSectionCard
|
||||
title='网络端口'
|
||||
description='frps 控制面与 HTTP 虚拟主机配置'
|
||||
title={t('relay.portsTitle')}
|
||||
description={t('relay.portsDesc')}
|
||||
action={
|
||||
<Button variant='outline' size='sm' className='h-8' asChild>
|
||||
<Link
|
||||
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
|
||||
>
|
||||
<FileText className='size-3.5 mr-1.5' />
|
||||
应用记录
|
||||
{t('applyLogs')}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='绑定控制端口'>
|
||||
<NodeInfoRow label={t('relay.bindPort')}>
|
||||
{node.relay_bind_port || '—'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='VHost HTTP 端口'>
|
||||
<NodeInfoRow label={t('relay.vhostPort')}>
|
||||
{node.relay_vhost_http_port || '—'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='Agent 接入地址'>
|
||||
<NodeInfoRow label={t('relay.agentAddr')}>
|
||||
<span className='break-all'>
|
||||
{node.relay_agent_access_addr || '—'}
|
||||
</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用'>
|
||||
<NodeInfoRow label={t('edge.latestApply')}>
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
label={getApplyLabel(node.latest_apply_result, t)}
|
||||
tone={getApplyTone(node.latest_apply_result)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='Relay 版本'>
|
||||
<NodeInfoRow label={t('relay.relayVersion')}>
|
||||
{node.version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='frps 核心版本'>
|
||||
<NodeInfoRow label={t('relay.frpsVersion')}>
|
||||
{node.ext_version || 'unknown'}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
@@ -361,15 +372,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
</div>
|
||||
|
||||
<NodeSectionCard
|
||||
title='FRPS WebUI'
|
||||
description='控制 frps 内置 Web 管理界面是否启用及其监听端口'
|
||||
title={t('relay.webuiTitle')}
|
||||
description={t('relay.webuiDesc')}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between rounded-xl border px-4 py-4'>
|
||||
<div className='space-y-1'>
|
||||
<Label>启用 Web 管理界面</Label>
|
||||
<Label>{t('relay.enableWebui')}</Label>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
开启后,节点将启动 frps 的内置管理服务。
|
||||
{t('relay.enableWebuiHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -381,7 +392,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
<div className='rounded-xl border p-4 space-y-3'>
|
||||
<Label htmlFor='web_server_port' className='text-xs font-semibold'>
|
||||
Web 管理界面端口
|
||||
{t('relay.webuiPort')}
|
||||
</Label>
|
||||
<div className='flex max-w-sm items-center gap-2'>
|
||||
<Input
|
||||
@@ -391,7 +402,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
max={65535}
|
||||
value={webServerPort}
|
||||
onChange={(e) => setWebServerPort(e.target.value)}
|
||||
placeholder='例如: 17500'
|
||||
placeholder={t('relay.webuiPortPlaceholder')}
|
||||
className='bg-card text-xs h-9'
|
||||
disabled={
|
||||
savePortMutation.isPending || systemConfigsQuery.isLoading
|
||||
@@ -408,13 +419,12 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
{savePortMutation.isPending ? (
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : (
|
||||
'保存'
|
||||
tc('save')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
当前实际监听端口:{resolvedPort}{' '}
|
||||
(修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
|
||||
{t('relay.actualPort', { port: resolvedPort })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -426,7 +436,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
rel='noopener noreferrer'
|
||||
className='inline-flex items-center font-medium text-primary hover:underline'
|
||||
>
|
||||
打开 FRPS WebUI
|
||||
{t('relay.openWebui')}
|
||||
<ExternalLink className='size-3.5 ml-1.5' />
|
||||
</a>
|
||||
<span className='text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border'>
|
||||
@@ -438,16 +448,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
className='size-7 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(webUiUrl);
|
||||
toast.success('链接已复制到剪贴板');
|
||||
toast.success(t('relay.copiedLink'));
|
||||
}}
|
||||
title='复制链接'
|
||||
title={t('relay.copyLink')}
|
||||
>
|
||||
<Copy className='size-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
WebUI 已禁用或未配置绑定端口。
|
||||
{t('relay.webuiDisabled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -455,15 +465,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
<InstallCommand node={node} variant='relay' />
|
||||
|
||||
<NodeSectionCard title='节点元数据'>
|
||||
<NodeSectionCard title={t('relay.metadata')}>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='节点 ID'>
|
||||
<NodeInfoRow label={t('kpiNodeId')}>
|
||||
<span className='font-mono text-xs break-all'>{node.node_id}</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='创建时间'>
|
||||
<NodeInfoRow label={t('relay.createdAt')}>
|
||||
{formatDateTime(node.created_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='更新时间'>
|
||||
<NodeInfoRow label={t('relay.updatedAt')}>
|
||||
{formatDateTime(node.updated_at)}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
@@ -479,34 +489,34 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
typeTone='warning'
|
||||
statusBadges={[
|
||||
{
|
||||
label: getNodeStatusLabel(node.status),
|
||||
label: getNodeStatusLabel(node.status, t),
|
||||
tone: getNodeStatusTone(node.status),
|
||||
},
|
||||
{
|
||||
label: getRelayStatusLabel(node.relay_status),
|
||||
label: getRelayStatusLabel(node.relay_status, t),
|
||||
tone: getRelayStatusTone(node.relay_status),
|
||||
},
|
||||
]}
|
||||
actions={headerActions}
|
||||
kpis={[
|
||||
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
|
||||
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
|
||||
{
|
||||
label: 'Relay 版本',
|
||||
label: t('relay.relayVersion'),
|
||||
value: node.version || 'unknown',
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
label: 'frps 核心',
|
||||
label: t('relay.kpiFrps'),
|
||||
value: node.ext_version || 'unknown',
|
||||
icon: Network,
|
||||
},
|
||||
{
|
||||
label: '最近心跳',
|
||||
label: t('kpiLastSeen'),
|
||||
value: isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
? t('wsConnected')
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? formatRelativeTime(node.last_seen_at)
|
||||
: '暂无',
|
||||
? formatRelativeTime(node.last_seen_at, t)
|
||||
: t('na'),
|
||||
icon: Activity,
|
||||
},
|
||||
]}
|
||||
@@ -514,7 +524,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
variant='compact'
|
||||
connectionHint='中继承载活动连接数'
|
||||
connectionHint={t('relay.connectionHint')}
|
||||
/>
|
||||
}
|
||||
manage={manageTab}
|
||||
@@ -544,22 +554,21 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('relay.deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除中继节点「{node.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
{t('relay.deleteDesc', { name: node.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -63,6 +64,8 @@ import {
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
@@ -73,20 +76,20 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
|
||||
NodeService.updateNode(node.id, payload),
|
||||
onSuccess: async () => {
|
||||
toast.success('隧道节点已更新');
|
||||
toast.success(t('tunnel.toastUpdated'));
|
||||
setEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const forceSyncMutation = useMutation({
|
||||
mutationFn: () => NodeService.requestForceSync(node.id),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(`已向隧道节点 ${updated.name} 下发强制同步指令`);
|
||||
toast.success(t('tunnel.toastForceSync', { name: updated.name }));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const upgradeMutation = useMutation({
|
||||
@@ -106,22 +109,28 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
toast.success(
|
||||
`已向隧道节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
|
||||
t('tunnel.toastUpgrade', {
|
||||
name: updated.name,
|
||||
channel:
|
||||
updated.update_channel === 'preview'
|
||||
? t('channel.preview')
|
||||
: t('channel.stable'),
|
||||
}),
|
||||
);
|
||||
setUpgradeOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => NodeService.deleteNode(node.id),
|
||||
onSuccess: async () => {
|
||||
toast.success('隧道节点已删除');
|
||||
toast.success(t('tunnel.toastDeleted'));
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
router.push('/nodes');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
|
||||
});
|
||||
|
||||
const handleRefresh = () => {
|
||||
@@ -141,7 +150,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
className='h-8'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -150,7 +159,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className='size-3.5 mr-1.5' />
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -160,7 +169,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => forceSyncMutation.mutate()}
|
||||
>
|
||||
<RotateCcw className='size-3.5 mr-1.5' />
|
||||
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
|
||||
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -169,7 +178,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => setUpgradeOpen(true)}
|
||||
>
|
||||
<Upload className='size-3.5 mr-1.5' />
|
||||
{node.update_requested ? '查看升级' : '升级 openflared'}
|
||||
{node.update_requested ? t('viewUpgrade') : t('tunnel.upgrade')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -178,7 +187,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1.5' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
@@ -189,81 +198,83 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<NodeSectionCard
|
||||
title='隧道运行状态'
|
||||
description='openflared 连接与在线情况'
|
||||
title={t('tunnel.runStatus')}
|
||||
description={t('tunnel.runStatusDesc')}
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='运行状态'>
|
||||
<NodeInfoRow label={t('edge.runStatus')}>
|
||||
<NodeStatusBadge
|
||||
label={getNodeStatusLabel(node.status)}
|
||||
label={getNodeStatusLabel(node.status, t)}
|
||||
tone={getNodeStatusTone(node.status)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='flared 状态'>
|
||||
<NodeInfoRow label={t('tunnel.flaredStatus')}>
|
||||
<NodeStatusBadge
|
||||
label={getFlaredStatusLabel(node)}
|
||||
label={getFlaredStatusLabel(node, t)}
|
||||
tone={getFlaredStatusTone(node)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近心跳'>
|
||||
<NodeInfoRow label={t('kpiLastSeen')}>
|
||||
{isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WebSocket 长连接已建立'
|
||||
? t('wsEstablished')
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: '暂无'}
|
||||
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
|
||||
: t('na')}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='IP 地址'>
|
||||
<NodeInfoRow label={t('edge.ip')}>
|
||||
{node.ip || '—'}
|
||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||
{node.ip_manual_override ? t('lockedSuffix') : ''}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='自动更新'>
|
||||
{node.auto_update_enabled ? '已启用' : '手动'}
|
||||
<NodeInfoRow label={t('tunnel.autoUpdate')}>
|
||||
{node.auto_update_enabled ? t('enabled') : t('manual')}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
</NodeSectionCard>
|
||||
|
||||
<NodeSectionCard
|
||||
title='配置同步'
|
||||
description='隧道客户端配置追平状态'
|
||||
title={t('tunnel.syncTitle')}
|
||||
description={t('tunnel.syncDesc')}
|
||||
action={
|
||||
<Button variant='outline' size='sm' className='h-8' asChild>
|
||||
<Link
|
||||
href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}
|
||||
>
|
||||
<FileText className='size-3.5 mr-1.5' />
|
||||
应用记录
|
||||
{t('applyLogs')}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='当前配置版本'>
|
||||
{node.current_version || '未应用'}
|
||||
<NodeInfoRow label={t('tunnel.currentVersion')}>
|
||||
{node.current_version || t('notApplied')}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用'>
|
||||
<NodeInfoRow label={t('tunnel.latestApply')}>
|
||||
<NodeStatusBadge
|
||||
label={getApplyLabel(node.latest_apply_result)}
|
||||
label={getApplyLabel(node.latest_apply_result, t)}
|
||||
tone={getApplyTone(node.latest_apply_result)}
|
||||
/>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='最近应用时间'>
|
||||
<NodeInfoRow label={t('tunnel.latestApplyAt')}>
|
||||
{isMeaningfulTime(node.latest_apply_at)
|
||||
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
|
||||
: '暂无'}
|
||||
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
|
||||
: t('na')}
|
||||
</NodeInfoRow>
|
||||
{node.latest_apply_checksum ? (
|
||||
<NodeInfoRow label='同步文件数'>
|
||||
{node.latest_support_file_count} 个
|
||||
<NodeInfoRow label={t('tunnel.fileCount')}>
|
||||
{t('tunnel.fileCountValue', {
|
||||
count: node.latest_support_file_count,
|
||||
})}
|
||||
</NodeInfoRow>
|
||||
) : null}
|
||||
<NodeInfoRow label='升级状态'>
|
||||
<NodeInfoRow label={t('tunnel.upgradeStatus')}>
|
||||
<NodeStatusBadge
|
||||
label={
|
||||
node.update_requested
|
||||
? node.update_channel === 'preview'
|
||||
? '等待预览更新'
|
||||
: '等待更新'
|
||||
: '未下发'
|
||||
? t('update.waitingPreview')
|
||||
: t('update.waitingUpdate')
|
||||
: t('update.notRequested')
|
||||
}
|
||||
tone={node.update_requested ? 'warning' : 'info'}
|
||||
/>
|
||||
@@ -274,20 +285,23 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
|
||||
<InstallCommand node={node} variant='tunnel' />
|
||||
|
||||
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'>
|
||||
<NodeSectionCard
|
||||
title={t('tunnel.credentials')}
|
||||
description={t('tunnel.credentialsDesc')}
|
||||
>
|
||||
<div className='divide-y'>
|
||||
<NodeInfoRow label='Tunnel Token'>
|
||||
<NodeInfoRow label={t('tunnel.tunnelToken')}>
|
||||
<span className='font-mono text-xs break-all'>
|
||||
{node.access_token || '暂无'}
|
||||
{node.access_token || t('na')}
|
||||
</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='节点 ID'>
|
||||
<NodeInfoRow label={t('kpiNodeId')}>
|
||||
<span className='font-mono text-xs break-all'>{node.node_id}</span>
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='创建时间'>
|
||||
<NodeInfoRow label={t('tunnel.createdAt')}>
|
||||
{formatDateTime(node.created_at)}
|
||||
</NodeInfoRow>
|
||||
<NodeInfoRow label='更新时间'>
|
||||
<NodeInfoRow label={t('tunnel.updatedAt')}>
|
||||
{formatDateTime(node.updated_at)}
|
||||
</NodeInfoRow>
|
||||
</div>
|
||||
@@ -303,34 +317,34 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
typeTone='info'
|
||||
statusBadges={[
|
||||
{
|
||||
label: getNodeStatusLabel(node.status),
|
||||
label: getNodeStatusLabel(node.status, t),
|
||||
tone: getNodeStatusTone(node.status),
|
||||
},
|
||||
{
|
||||
label: getFlaredStatusLabel(node),
|
||||
label: getFlaredStatusLabel(node, t),
|
||||
tone: getFlaredStatusTone(node),
|
||||
},
|
||||
]}
|
||||
actions={headerActions}
|
||||
kpis={[
|
||||
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
|
||||
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
|
||||
{
|
||||
label: 'openflared',
|
||||
label: t('tunnel.kpiOpenflared'),
|
||||
value: node.version || 'unknown',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
label: '当前配置',
|
||||
value: node.current_version || '未应用',
|
||||
label: t('kpiCurrentConfig'),
|
||||
value: node.current_version || t('notApplied'),
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
label: '最近心跳',
|
||||
label: t('kpiLastSeen'),
|
||||
value: isWSConnectedLastSeen(node.last_seen_at)
|
||||
? 'WS 已连接'
|
||||
? t('wsConnected')
|
||||
: isMeaningfulTime(node.last_seen_at)
|
||||
? formatRelativeTime(node.last_seen_at)
|
||||
: '暂无',
|
||||
? formatRelativeTime(node.last_seen_at, t)
|
||||
: t('na'),
|
||||
icon: Activity,
|
||||
},
|
||||
]}
|
||||
@@ -338,7 +352,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
<NodeObservability
|
||||
nodeId={node.id}
|
||||
variant='compact'
|
||||
connectionHint='隧道并发连接数'
|
||||
connectionHint={t('tunnel.connectionHint')}
|
||||
/>
|
||||
}
|
||||
manage={manageTab}
|
||||
@@ -368,22 +382,21 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('tunnel.deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除隧道节点「{node.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
{t('tunnel.deleteDesc', { name: node.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Server } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -20,6 +21,7 @@ import { getErrorMessage } from '../components/node-utils';
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function NodeDetailPageClient() {
|
||||
const t = useTranslations('nodes');
|
||||
const searchParams = useSearchParams();
|
||||
const nodeId = searchParams.get('id')?.trim() ?? '';
|
||||
|
||||
@@ -41,7 +43,7 @@ export function NodeDetailPageClient() {
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description='缺少节点 ID,请从节点列表进入详情页。'
|
||||
description={t('missingId')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -50,7 +52,10 @@ export function NodeDetailPageClient() {
|
||||
if (nodesQuery.isLoading) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={Server} description='加载节点详情中...' />
|
||||
<LoadingStateWithBorder
|
||||
icon={Server}
|
||||
description={t('loadingDetail')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +65,7 @@ export function NodeDetailPageClient() {
|
||||
<div className='py-6 px-1'>
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(nodesQuery.error)}
|
||||
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
|
||||
onRetry={() => void nodesQuery.refetch()}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -75,13 +80,10 @@ export function NodeDetailPageClient() {
|
||||
<Button variant='ghost' size='sm' className='h-8 px-2' asChild>
|
||||
<Link href='/nodes'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回节点列表
|
||||
{t('backToList')}
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description='节点不存在,可能已被删除或 ID 无效。'
|
||||
/>
|
||||
<EmptyStateWithBorder icon={Server} description={t('notFound')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -44,6 +45,8 @@ import { getErrorMessage } from './components/node-utils';
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function NodesPageClient() {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
||||
@@ -75,25 +78,25 @@ export function NodesPageClient() {
|
||||
return NodeService.createNode(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(editingNode ? '节点已更新' : '节点已创建');
|
||||
toast.success(editingNode ? t('updated') : t('created'));
|
||||
setEditingNode(null);
|
||||
setEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('requestFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => NodeService.deleteNode(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('节点已删除');
|
||||
toast.success(t('deleted'));
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('requestFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,11 +119,11 @@ export function NodesPageClient() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Server className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>节点管理</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
|
||||
<Link href='/apply-logs'>应用记录</Link>
|
||||
<Link href='/apply-logs'>{t('applyLogs')}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -129,7 +132,7 @@ export function NodesPageClient() {
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新增节点
|
||||
{t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,10 +142,10 @@ export function NodesPageClient() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
节点列表
|
||||
{t('listTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getFilterDescription(nodeFilter)}
|
||||
{getFilterDescription(nodeFilter, t)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -157,7 +160,7 @@ export function NodesPageClient() {
|
||||
) : (
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
)}
|
||||
立即刷新
|
||||
{t('refreshNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -167,12 +170,12 @@ export function NodesPageClient() {
|
||||
{nodesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Server}
|
||||
description='加载节点列表中...'
|
||||
description={t('loadingList')}
|
||||
/>
|
||||
) : nodesQuery.isError ? (
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(nodesQuery.error)}
|
||||
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
|
||||
onRetry={handleRefresh}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -181,9 +184,7 @@ export function NodesPageClient() {
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description={
|
||||
nodes.length === 0
|
||||
? '暂无节点,请先创建一个节点。'
|
||||
: '当前筛选无结果'
|
||||
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -218,15 +219,14 @@ export function NodesPageClient() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除节点「{deleteTarget?.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
@@ -235,7 +235,7 @@ export function NodesPageClient() {
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -25,21 +25,13 @@ import {
|
||||
type OriginMutationPayload,
|
||||
OriginService,
|
||||
} from '@/lib/services/openflare';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const originSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => !/[/?#]/.test(value) && !value.includes('://'),
|
||||
'源站地址格式不合法',
|
||||
),
|
||||
remark: z.string().max(255),
|
||||
});
|
||||
|
||||
type OriginFormValues = z.infer<typeof originSchema>;
|
||||
type OriginFormValues = {
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const originsQueryKey = ['openflare', 'origins'] as const;
|
||||
|
||||
@@ -73,7 +65,21 @@ export function OriginEditorDialog({
|
||||
origin,
|
||||
onSaved,
|
||||
}: OriginEditorDialogProps) {
|
||||
const t = useTranslations('origins');
|
||||
const tc = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const originSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, t('addressRequired'))
|
||||
.refine(
|
||||
(value) => !/[/?#]/.test(value) && !value.includes('://'),
|
||||
t('addressInvalid'),
|
||||
),
|
||||
remark: z.string().max(255),
|
||||
});
|
||||
const form = useForm<OriginFormValues>({
|
||||
resolver: zodResolver(originSchema),
|
||||
defaultValues: toFormValues(origin),
|
||||
@@ -91,7 +97,7 @@ export function OriginEditorDialog({
|
||||
: OriginService.create(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(origin ? '源站已更新' : '源站已创建');
|
||||
toast.success(origin ? t('updated') : t('created'));
|
||||
await queryClient.invalidateQueries({ queryKey: originsQueryKey });
|
||||
if (origin) {
|
||||
await queryClient.invalidateQueries({
|
||||
@@ -102,7 +108,7 @@ export function OriginEditorDialog({
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
toast.error(error instanceof Error ? error.message : t('saveFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,10 +116,8 @@ export function OriginEditorDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{origin ? '编辑源站' : '新增源站'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
源站作为规则里的可复用地址目录,协议和端口仍由规则决定。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{origin ? t('editOrigin') : t('create')}</DialogTitle>
|
||||
<DialogDescription>{t('editorDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
@@ -122,7 +126,7 @@ export function OriginEditorDialog({
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='address'>源站地址</Label>
|
||||
<Label htmlFor='address'>{t('address')}</Label>
|
||||
<Input
|
||||
id='address'
|
||||
placeholder='origin.internal'
|
||||
@@ -135,22 +139,22 @@ export function OriginEditorDialog({
|
||||
) : null}
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='name'>源站名</Label>
|
||||
<Label htmlFor='name'>{t('name')}</Label>
|
||||
<Input
|
||||
id='name'
|
||||
placeholder='主站源站'
|
||||
placeholder={t('namePlaceholder')}
|
||||
{...form.register('name')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='remark'>备注</Label>
|
||||
<Label htmlFor='remark'>{t('remark')}</Label>
|
||||
<Textarea id='remark' rows={2} {...form.register('remark')} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -160,12 +164,12 @@ export function OriginEditorDialog({
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : origin ? (
|
||||
'保存修改'
|
||||
t('saveChanges')
|
||||
) : (
|
||||
'新增源站'
|
||||
t('create')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -33,9 +33,13 @@ import {
|
||||
import { OriginService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { OriginEditorDialog } from '../components/origin-editor-dialog';
|
||||
|
||||
export function OriginDetailPageClient() {
|
||||
const t = useTranslations('origins');
|
||||
const tc = useTranslations('common');
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const originId = searchParams.get('id')?.trim() ?? '';
|
||||
@@ -54,14 +58,14 @@ export function OriginDetailPageClient() {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => OriginService.deleteById(parsedId),
|
||||
onSuccess: async () => {
|
||||
toast.success('源站已删除');
|
||||
toast.success(t('deleted'));
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'origins'],
|
||||
});
|
||||
window.location.href = '/origins';
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -70,7 +74,7 @@ export function OriginDetailPageClient() {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder description='缺少有效的源站 ID。' />
|
||||
<EmptyStateWithBorder description={t('missingId')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +82,7 @@ export function OriginDetailPageClient() {
|
||||
if (originQuery.isLoading) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={MapPin} description='加载源站详情...' />
|
||||
<LoadingStateWithBorder icon={MapPin} description={t('loadingDetail')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +94,7 @@ export function OriginDetailPageClient() {
|
||||
message={
|
||||
originQuery.error instanceof Error
|
||||
? originQuery.error.message
|
||||
: '加载失败'
|
||||
: t('loadFailed')
|
||||
}
|
||||
onRetry={() => void originQuery.refetch()}
|
||||
/>
|
||||
@@ -104,10 +108,10 @@ export function OriginDetailPageClient() {
|
||||
<Button variant='ghost' size='sm' asChild>
|
||||
<Link href='/origins'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回列表
|
||||
{t('backToList')}
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder description='源站不存在或已被删除。' />
|
||||
<EmptyStateWithBorder description={t('notFound')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +123,7 @@ export function OriginDetailPageClient() {
|
||||
<Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild>
|
||||
<Link href='/origins'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回列表
|
||||
{t('backToList')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -136,7 +140,7 @@ export function OriginDetailPageClient() {
|
||||
size='sm'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑源站
|
||||
{t('editOrigin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -144,7 +148,7 @@ export function OriginDetailPageClient() {
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
删除源站
|
||||
{t('deleteOrigin')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,57 +156,57 @@ export function OriginDetailPageClient() {
|
||||
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
绑定规则
|
||||
{t('boundRoutes')}
|
||||
</p>
|
||||
<Badge variant='outline' className='mt-2 text-[10px]'>
|
||||
{origin.route_count} 条规则
|
||||
{t('routeCount', { count: origin.route_count })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
创建时间
|
||||
{t('createdAt')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{formatDateTime(origin.created_at)}</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
更新时间
|
||||
{t('updatedAt')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{formatDateTime(origin.updated_at)}</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3 sm:col-span-2 xl:col-span-1'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
备注
|
||||
{t('remark')}
|
||||
</p>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{origin.remark || '暂无备注'}
|
||||
{origin.remark || t('noRemark')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border border-dashed rounded-lg overflow-hidden bg-background'>
|
||||
<div className='px-4 py-3 border-b border-dashed'>
|
||||
<h2 className='text-sm font-semibold'>关联规则</h2>
|
||||
<h2 className='text-sm font-semibold'>{t('relatedRoutes')}</h2>
|
||||
<p className='text-xs text-muted-foreground mt-1'>
|
||||
展示当前源站作为主源站绑定的规则。
|
||||
{t('relatedRoutesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
{origin.routes.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无关联规则'
|
||||
description='当前源站还没有被任何规则引用。'
|
||||
title={t('noRelatedRoutes')}
|
||||
description={t('noRelatedRoutesDesc')}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs font-semibold'>域名</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>{t('domain')}</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
源站地址
|
||||
{t('address')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>状态</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>{t('status')}</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
更新时间
|
||||
{t('updatedAt')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -217,7 +221,7 @@ export function OriginDetailPageClient() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{route.enabled ? '启用' : '停用'}
|
||||
{route.enabled ? t('enabled') : t('disabled')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
@@ -240,18 +244,18 @@ export function OriginDetailPageClient() {
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除源站</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除源站 {origin.name} 吗?
|
||||
{t('deleteDesc', { name: origin.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
确认删除
|
||||
{t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -24,11 +24,15 @@ import { Button } from '@/components/ui/button';
|
||||
import { type OriginItem, OriginService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { OriginEditorDialog } from './components/origin-editor-dialog';
|
||||
|
||||
const originsQueryKey = ['openflare', 'origins'] as const;
|
||||
|
||||
export default function OriginsPage() {
|
||||
const t = useTranslations('origins');
|
||||
const tc = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
|
||||
@@ -44,12 +48,12 @@ export default function OriginsPage() {
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => OriginService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('源站已删除');
|
||||
toast.success(t('deleted'));
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: originsQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,10 +63,8 @@ export default function OriginsPage() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<MapPin className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>源站</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
集中维护规则复用的源站地址,减少批量改地址时的重复操作。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
@@ -75,7 +77,7 @@ export default function OriginsPage() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${originsQuery.isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -85,7 +87,7 @@ export default function OriginsPage() {
|
||||
}}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新增源站
|
||||
{t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +97,7 @@ export default function OriginsPage() {
|
||||
message={
|
||||
originsQuery.error instanceof Error
|
||||
? originsQuery.error.message
|
||||
: '加载失败'
|
||||
: t('loadFailed')
|
||||
}
|
||||
onRetry={() => void originsQuery.refetch()}
|
||||
/>
|
||||
@@ -106,8 +108,8 @@ export default function OriginsPage() {
|
||||
<LoadingStateWithBorder />
|
||||
) : origins.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无源站'
|
||||
description='点击右上角新增源站,后续规则可直接复用这些地址。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<div className='grid gap-0 md:grid-cols-2'>
|
||||
@@ -128,20 +130,24 @@ export default function OriginsPage() {
|
||||
: 'text-amber-600 border-amber-500/20'
|
||||
}`}
|
||||
>
|
||||
{origin.route_count} 条规则
|
||||
{t('routeCount', { count: origin.route_count })}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm'>{origin.address}</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{origin.remark || '暂无备注'}
|
||||
{origin.remark || t('noRemark')}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
最后更新:{formatDateTime(origin.updated_at)}
|
||||
{t('lastUpdated', {
|
||||
date: formatDateTime(origin.updated_at),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href={`/origins/detail?id=${origin.id}`}>详情</Link>
|
||||
<Link href={`/origins/detail?id=${origin.id}`}>
|
||||
{t('detail')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -151,7 +157,7 @@ export default function OriginsPage() {
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -159,7 +165,7 @@ export default function OriginsPage() {
|
||||
onClick={() => setDeleteTarget(origin)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
删除
|
||||
{tc('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,20 +189,20 @@ export default function OriginsPage() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除源站</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除源站 {deleteTarget?.name} 吗?
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel>{tc('cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() => {
|
||||
if (deleteTarget) deleteMutation.mutate(deleteTarget.id);
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
{t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { LayoutDashboard, RefreshCw } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -24,6 +25,7 @@ import { getErrorMessage } from './nodes/components/node-utils';
|
||||
const dashboardQueryKey = ['openflare', 'dashboard', 'overview'];
|
||||
|
||||
export default function OpenFlareDashboardPage() {
|
||||
const t = useTranslations('dashboard');
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: dashboardQueryKey,
|
||||
queryFn: () => DashboardService.getOverview(),
|
||||
@@ -37,11 +39,13 @@ export default function OpenFlareDashboardPage() {
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<LayoutDashboard className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>总览</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</h1>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||
{overview?.generated_at ? (
|
||||
<span>数据生成于 {formatDateTime(overview.generated_at)}</span>
|
||||
<span>
|
||||
{t('generatedAt', { time: formatDateTime(overview.generated_at) })}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -53,25 +57,27 @@ export default function OpenFlareDashboardPage() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1.5 ${overviewQuery.isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overviewQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
title='加载总览数据'
|
||||
description='正在聚合节点健康、流量与容量指标...'
|
||||
title={t('loadingTitle')}
|
||||
description={t('loadingDesc')}
|
||||
/>
|
||||
) : overviewQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={`总览看板加载失败:${getErrorMessage(overviewQuery.error)}`}
|
||||
message={t('loadFailed', {
|
||||
error: getErrorMessage(overviewQuery.error, t('requestFailed')),
|
||||
})}
|
||||
onRetry={() => overviewQuery.refetch()}
|
||||
/>
|
||||
) : !overview ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无总览数据'
|
||||
description='系统已经启动,但还没有可展示的总览聚合结果。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -76,6 +77,8 @@ export function DeploymentUploadDialog({
|
||||
rootDir,
|
||||
entryFile,
|
||||
}: DeploymentUploadDialogProps) {
|
||||
const t = useTranslations('pages.upload');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -96,14 +99,14 @@ export function DeploymentUploadDialog({
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!file) throw new Error('请选择部署包');
|
||||
if (!file) throw new Error(t('selectFile'));
|
||||
return PagesService.uploadDeployment(projectId, {
|
||||
file,
|
||||
onProgress: setUploadProgress,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('部署包上传成功');
|
||||
toast.success(t('success'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
@@ -114,7 +117,7 @@ export function DeploymentUploadDialog({
|
||||
handleOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '上传失败');
|
||||
toast.error(error instanceof Error ? error.message : t('failed'));
|
||||
setUploadProgress(null);
|
||||
},
|
||||
});
|
||||
@@ -122,7 +125,7 @@ export function DeploymentUploadDialog({
|
||||
const handleFileSelect = (selected: File | null) => {
|
||||
if (!selected) return;
|
||||
if (!isSupportedPagesPackage(selected.name)) {
|
||||
toast.error('仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式的部署包');
|
||||
toast.error(t('unsupported'));
|
||||
return;
|
||||
}
|
||||
setFile(selected);
|
||||
@@ -132,15 +135,13 @@ export function DeploymentUploadDialog({
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传部署包</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传不可变的静态资源压缩包,完成后可在部署历史中激活。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='pages-package'>本地部署包</FieldLabel>
|
||||
<FieldLabel htmlFor='pages-package'>{t('localPackage')}</FieldLabel>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
@@ -165,7 +166,7 @@ export function DeploymentUploadDialog({
|
||||
>
|
||||
<UploadCloud className='size-8 text-muted-foreground' />
|
||||
<span className='text-sm font-medium'>
|
||||
拖拽部署包到此处,或点击选择文件
|
||||
{t('dropHint')}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
zip、tar.gz、tar.xz、tar.bz2、tar、7z
|
||||
@@ -183,20 +184,23 @@ export function DeploymentUploadDialog({
|
||||
/>
|
||||
{file ? (
|
||||
<FieldDescription>
|
||||
已选择 {file.name}({formatBytes(file.size)})
|
||||
{t('selected', {
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
})}
|
||||
</FieldDescription>
|
||||
) : (
|
||||
<FieldDescription>请选择一个受支持的压缩包。</FieldDescription>
|
||||
<FieldDescription>{t('chooseArchive')}</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>部署入口</FieldLabel>
|
||||
<FieldLabel>{t('entry')}</FieldLabel>
|
||||
<div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'>
|
||||
{pagesEntryPath(rootDir, entryFile)}
|
||||
</div>
|
||||
<FieldDescription>
|
||||
入口来自项目设置;部署包上传不会覆盖该配置。
|
||||
{t('entryHint')}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
@@ -204,10 +208,10 @@ export function DeploymentUploadDialog({
|
||||
<Field>
|
||||
<div className='flex items-center justify-between text-xs text-muted-foreground'>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'}
|
||||
{uploadProgress >= 100 ? t('processing') : t('progress')}
|
||||
</span>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`}
|
||||
{uploadProgress >= 100 ? t('pleaseWait') : `${uploadProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={Math.min(uploadProgress, 100)} />
|
||||
@@ -221,7 +225,7 @@ export function DeploymentUploadDialog({
|
||||
variant='outline'
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -233,7 +237,7 @@ export function DeploymentUploadDialog({
|
||||
) : (
|
||||
<UploadCloud data-icon='inline-start' />
|
||||
)}
|
||||
{uploadMutation.isPending ? '上传中...' : '上传并创建部署'}
|
||||
{uploadMutation.isPending ? t('uploading') : t('submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -35,6 +36,8 @@ export function ProjectEditorDialog({
|
||||
onOpenChange,
|
||||
project,
|
||||
}: ProjectEditorDialogProps) {
|
||||
const t = useTranslations('pages');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const form = usePagesProjectForm(project);
|
||||
|
||||
@@ -50,7 +53,7 @@ export function ProjectEditorDialog({
|
||||
: PagesService.createProject(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(project ? '项目已更新' : '项目已创建');
|
||||
toast.success(project ? t('updated') : t('created'));
|
||||
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
if (project) {
|
||||
await queryClient.invalidateQueries({
|
||||
@@ -60,7 +63,7 @@ export function ProjectEditorDialog({
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
toast.error(error instanceof Error ? error.message : t('saveFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,11 +72,9 @@ export function ProjectEditorDialog({
|
||||
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{project ? '编辑 Pages 项目' : '新建 Pages 项目'}
|
||||
{project ? t('editTitle') : t('createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置静态站点托管参数,上传部署包后在代理规则中选择 Pages 上游。
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t('editorDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
@@ -86,7 +87,7 @@ export function ProjectEditorDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -96,12 +97,12 @@ export function ProjectEditorDialog({
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : project ? (
|
||||
'保存修改'
|
||||
t('saveChanges')
|
||||
) : (
|
||||
'创建项目'
|
||||
t('createProject')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -9,46 +11,50 @@ import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { type PagesProject } from '@/lib/services/openflare';
|
||||
|
||||
export const pagesProjectSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, '请输入项目名称').max(255),
|
||||
slug: z.string().trim().max(255).optional().or(z.literal('')),
|
||||
spa_fallback_enabled: z.boolean(),
|
||||
spa_fallback_path: z.string().trim(),
|
||||
api_proxy_enabled: z.boolean(),
|
||||
api_proxy_path: z.string().trim(),
|
||||
api_proxy_pass: z.string().trim(),
|
||||
api_proxy_rewrite: z.string().trim(),
|
||||
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
|
||||
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['spa_fallback_path'],
|
||||
message: '回退路径必须以 / 开头',
|
||||
});
|
||||
}
|
||||
if (data.api_proxy_enabled) {
|
||||
if (!data.api_proxy_path.startsWith('/')) {
|
||||
function createPagesProjectSchema(t: (key: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string().trim().min(1, t('form.nameRequired')).max(255),
|
||||
slug: z.string().trim().max(255).optional().or(z.literal('')),
|
||||
spa_fallback_enabled: z.boolean(),
|
||||
spa_fallback_path: z.string().trim(),
|
||||
api_proxy_enabled: z.boolean(),
|
||||
api_proxy_path: z.string().trim(),
|
||||
api_proxy_pass: z.string().trim(),
|
||||
api_proxy_rewrite: z.string().trim(),
|
||||
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
|
||||
entry_file: z.string().trim().min(1, t('form.entryRequired')).max(512),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '匹配路径必须以 / 开头',
|
||||
path: ['spa_fallback_path'],
|
||||
message: t('form.fallbackPathPrefix'),
|
||||
});
|
||||
}
|
||||
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '后端地址必须以 http:// 或 https:// 开头',
|
||||
});
|
||||
if (data.api_proxy_enabled) {
|
||||
if (!data.api_proxy_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: t('form.matchPathPrefix'),
|
||||
});
|
||||
}
|
||||
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: t('form.backendPrefix'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
|
||||
export type PagesProjectFormValues = z.infer<
|
||||
ReturnType<typeof createPagesProjectSchema>
|
||||
>;
|
||||
|
||||
export function toFormValues(
|
||||
project?: PagesProject | null,
|
||||
@@ -110,8 +116,10 @@ export function buildProjectPayload(
|
||||
}
|
||||
|
||||
export function usePagesProjectForm(project?: PagesProject | null) {
|
||||
const t = useTranslations('pages');
|
||||
const schema = useMemo(() => createPagesProjectSchema((key) => t(key)), [t]);
|
||||
return useForm<PagesProjectFormValues>({
|
||||
resolver: zodResolver(pagesProjectSchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: toFormValues(project),
|
||||
});
|
||||
}
|
||||
@@ -125,6 +133,7 @@ export function ProjectFormFields({
|
||||
form,
|
||||
idPrefix = '',
|
||||
}: ProjectFormFieldsProps) {
|
||||
const t = useTranslations('pages');
|
||||
const spaEnabled = form.watch('spa_fallback_enabled');
|
||||
const apiEnabled = form.watch('api_proxy_enabled');
|
||||
const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name);
|
||||
@@ -133,7 +142,7 @@ export function ProjectFormFields({
|
||||
<div className='space-y-4'>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('name')}>项目名称</Label>
|
||||
<Label htmlFor={fieldId('name')}>{t('form.name')}</Label>
|
||||
<Input id={fieldId('name')} {...form.register('name')} />
|
||||
{form.formState.errors.name ? (
|
||||
<p className='text-xs text-destructive'>
|
||||
@@ -142,10 +151,10 @@ export function ProjectFormFields({
|
||||
) : null}
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('slug')}>项目标识</Label>
|
||||
<Label htmlFor={fieldId('slug')}>{t('form.slug')}</Label>
|
||||
<Input
|
||||
id={fieldId('slug')}
|
||||
placeholder='留空自动生成'
|
||||
placeholder={t('form.slugPlaceholder')}
|
||||
{...form.register('slug')}
|
||||
/>
|
||||
</div>
|
||||
@@ -153,14 +162,14 @@ export function ProjectFormFields({
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('entry_file')}>入口文件</Label>
|
||||
<Label htmlFor={fieldId('entry_file')}>{t('form.entryFile')}</Label>
|
||||
<Input id={fieldId('entry_file')} {...form.register('entry_file')} />
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('root_dir')}>根目录</Label>
|
||||
<Label htmlFor={fieldId('root_dir')}>{t('form.rootDir')}</Label>
|
||||
<Input
|
||||
id={fieldId('root_dir')}
|
||||
placeholder='可选'
|
||||
placeholder={t('form.optional')}
|
||||
{...form.register('root_dir')}
|
||||
/>
|
||||
</div>
|
||||
@@ -168,9 +177,9 @@ export function ProjectFormFields({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>SPA fallback</p>
|
||||
<p className='text-sm font-medium'>{t('form.spaFallback')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
未命中静态文件时回退到指定路径
|
||||
{t('form.spaFallbackDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -184,7 +193,9 @@ export function ProjectFormFields({
|
||||
</div>
|
||||
{spaEnabled ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('spa_fallback_path')}>回退路径</Label>
|
||||
<Label htmlFor={fieldId('spa_fallback_path')}>
|
||||
{t('form.fallbackPath')}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldId('spa_fallback_path')}
|
||||
{...form.register('spa_fallback_path')}
|
||||
@@ -194,9 +205,9 @@ export function ProjectFormFields({
|
||||
|
||||
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>API 反向代理</p>
|
||||
<p className='text-sm font-medium'>{t('form.apiProxy')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
为静态站点附加 API 反代规则
|
||||
{t('form.apiProxyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -209,21 +220,27 @@ export function ProjectFormFields({
|
||||
{apiEnabled ? (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('api_proxy_path')}>匹配路径</Label>
|
||||
<Label htmlFor={fieldId('api_proxy_path')}>
|
||||
{t('form.matchPath')}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldId('api_proxy_path')}
|
||||
{...form.register('api_proxy_path')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('api_proxy_pass')}>后端地址</Label>
|
||||
<Label htmlFor={fieldId('api_proxy_pass')}>
|
||||
{t('form.backend')}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldId('api_proxy_pass')}
|
||||
{...form.register('api_proxy_pass')}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1.5 md:col-span-2'>
|
||||
<Label htmlFor={fieldId('api_proxy_rewrite')}>重写规则</Label>
|
||||
<Label htmlFor={fieldId('api_proxy_rewrite')}>
|
||||
{t('form.rewrite')}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldId('api_proxy_rewrite')}
|
||||
{...form.register('api_proxy_rewrite')}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user