feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂 - i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏 - 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条 - 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器 - UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正 - 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简 - 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
This commit is contained in:
1 parent
02d1bd87a7
commit
f3733f0bfc
201 files changed
+5438
-2388
No files matched your search
@@ -1,7 +1,9 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HardDrive, Star, Trash2, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ResourceCard } from '@/components/ResourceCard'
|
||||
import { CardActionButton } from '@/components/CardActionButton'
|
||||
import type { StorageConfig } from '@/types'
|
||||
import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS } from '../constants'
|
||||
|
||||
@@ -12,10 +14,11 @@ interface StorageCardProps {
|
||||
onEdit: (s: StorageConfig) => void
|
||||
}
|
||||
|
||||
/** 存储配置卡片 — 统一 ResourceCard 样式 */
|
||||
/** 存储配置卡片 — 统一 ResourceCard 样式(徽章与 StatusBadge 同为胶囊形) */
|
||||
export const StorageCard = memo(function StorageCard({
|
||||
storage, onSetDefault, onDelete, onEdit,
|
||||
}: StorageCardProps) {
|
||||
const { t } = useTranslation('storage')
|
||||
const Icon = STORAGE_TYPE_ICONS[storage.storageType] || HardDrive
|
||||
const cls = STORAGE_TYPE_ICON_CLASS[storage.storageType] || 'text-muted-foreground'
|
||||
return (
|
||||
@@ -25,33 +28,30 @@ export const StorageCard = memo(function StorageCard({
|
||||
title={storage.name}
|
||||
badges={
|
||||
<>
|
||||
<span className="px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium uppercase">
|
||||
{storage.storageType}
|
||||
</span>
|
||||
<Badge variant="secondary" className="rounded-full font-medium uppercase">{storage.storageType}</Badge>
|
||||
{storage.isDefault && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium inline-flex items-center gap-0.5">
|
||||
<Star className="size-3" />默认
|
||||
</span>
|
||||
<Badge className="rounded-full font-medium bg-success/10 text-success border-transparent hover:bg-success/10">
|
||||
<Star className="size-3" />{t('card.default')}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
fields={[
|
||||
{ label: '访问地址', value: storage.endpoint || '默认', title: storage.endpoint },
|
||||
{ label: t('card.endpoint'), value: storage.endpoint || t('card.default'), title: storage.endpoint },
|
||||
{ label: 'Bucket', value: storage.bucket },
|
||||
{ label: '路径前缀', value: storage.pathPrefix || '无' },
|
||||
{ label: '连接', value: storage.insecure ? 'HTTP(不加密)' : 'HTTPS' },
|
||||
{ label: t('card.pathPrefix'), value: storage.pathPrefix || t('card.none') },
|
||||
{ label: t('card.connection'), value: storage.insecure ? t('card.insecureHttp') : 'HTTPS' },
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onSetDefault(storage.id)} disabled={storage.isDefault}>
|
||||
<Star className="size-3 mr-1" />{storage.isDefault ? '默认' : '设为默认'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" aria-label={`编辑存储配置 ${storage.name}`} onClick={() => onEdit(storage)}>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" aria-label={`删除存储配置 ${storage.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(storage)}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<CardActionButton
|
||||
icon={Star}
|
||||
label={storage.isDefault ? t('card.default') : t('card.setDefault')}
|
||||
disabled={storage.isDefault}
|
||||
onClick={() => onSetDefault(storage.id)}
|
||||
/>
|
||||
<CardActionButton icon={Pencil} ariaLabel={t('card.editAria', { name: storage.name })} onClick={() => onEdit(storage)} />
|
||||
<CardActionButton icon={Trash2} ariaLabel={t('card.deleteAria', { name: storage.name })} destructive onClick={() => onDelete(storage)} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { HardDrive, PlugZap } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -26,21 +28,23 @@ import { PasswordInput } from '@/components/PasswordInput'
|
||||
import { toast } from 'sonner'
|
||||
import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS, STORAGE_TYPES } from '../constants'
|
||||
|
||||
// 表单 schema:zod 为唯一校验源,消息中文且具体
|
||||
const storageFormSchema = z.object({
|
||||
name: z.string().min(1, '请输入存储配置名称'),
|
||||
storageType: z.enum(['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs']),
|
||||
endpoint: z.string().optional(),
|
||||
bucket: z.string().min(1, '请填写 Bucket 名称'),
|
||||
region: z.string().optional(),
|
||||
accessKey: z.string().optional(),
|
||||
secretKey: z.string().optional(),
|
||||
pathPrefix: z.string().optional(),
|
||||
insecure: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
})
|
||||
// 表单 schema 工厂:zod 为唯一校验源,校验消息经 t 取词典
|
||||
// (t 随语言切换变化,组件内 useMemo 按 t 重建 schema,RHF 每次渲染刷新 resolver)
|
||||
const buildStorageFormSchema = (t: TFunction) =>
|
||||
z.object({
|
||||
name: z.string().min(1, t('form.nameRequired')),
|
||||
storageType: z.enum(['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs']),
|
||||
endpoint: z.string().optional(),
|
||||
bucket: z.string().min(1, t('form.bucketRequired')),
|
||||
region: z.string().optional(),
|
||||
accessKey: z.string().optional(),
|
||||
secretKey: z.string().optional(),
|
||||
pathPrefix: z.string().optional(),
|
||||
insecure: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
})
|
||||
|
||||
type StorageFormValues = z.infer<typeof storageFormSchema>
|
||||
type StorageFormValues = z.infer<ReturnType<typeof buildStorageFormSchema>>
|
||||
|
||||
const DEFAULT_VALUES: StorageFormValues = {
|
||||
name: '',
|
||||
@@ -71,11 +75,14 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
onCreated,
|
||||
editing,
|
||||
}: StorageFormDialogProps) {
|
||||
const { t } = useTranslation('storage')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<StorageTestResult | null>(null)
|
||||
const [createIfMissing, setCreateIfMissing] = useState(true)
|
||||
|
||||
// schema 随语言重建(校验消息跟随语言切换)
|
||||
const storageFormSchema = useMemo(() => buildStorageFormSchema(t), [t])
|
||||
const form = useForm<StorageFormValues>({
|
||||
resolver: zodResolver(storageFormSchema),
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
@@ -116,8 +123,8 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
bucketExists: false,
|
||||
bucketCreated: false,
|
||||
message: hasStoredSecret
|
||||
? '请先填写 Bucket 与 Access Key(Secret Key 已保存,留空即可)'
|
||||
: '请先填写 Bucket 与 Access Key / Secret Key',
|
||||
? t('form.testHintSaved')
|
||||
: t('form.testHintMissing'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -138,7 +145,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
})
|
||||
setTestResult(result)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '测试连接失败')
|
||||
toast.error(err instanceof Error ? err.message : t('form.errTest'))
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
@@ -163,7 +170,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
onOpenChange(false)
|
||||
onCreated()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '创建存储配置失败')
|
||||
toast.error(err instanceof Error ? err.message : t('form.errSubmit'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -173,7 +180,11 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? `编辑存储配置${editing.name ? ` · ${editing.name}` : ''}` : '添加存储配置'}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editing
|
||||
? `${t('form.editTitle')}${editing.name ? ` · ${editing.name}` : ''}`
|
||||
: t('form.addTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
@@ -182,7 +193,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>名称 *</FormLabel>
|
||||
<FormLabel>{t('form.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="production-minio" {...field} />
|
||||
</FormControl>
|
||||
@@ -196,7 +207,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
name="storageType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>存储类型</FormLabel>
|
||||
<FormLabel>{t('form.storageType')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{STORAGE_TYPES.map((type) => (
|
||||
@@ -229,7 +240,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>访问地址</FormLabel>
|
||||
<FormLabel>{t('form.endpoint')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
@@ -257,7 +268,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
name="bucket"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket *</FormLabel>
|
||||
<FormLabel>{t('form.bucket')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
@@ -276,7 +287,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
name="region"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>区域</FormLabel>
|
||||
<FormLabel>{t('form.region')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
@@ -322,7 +333,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={editing?.hasSecretKey ? '已保存 · 留空则不修改' : '请输入 Secret Key'}
|
||||
placeholder={editing?.hasSecretKey ? t('form.secretPlaceholderSaved') : t('form.secretPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -346,7 +357,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
|
||||
<Label htmlFor="s-insecure">{t('form.insecure')}</Label>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -362,7 +373,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<Label htmlFor="s-default">设为默认</Label>
|
||||
<Label htmlFor="s-default">{t('form.setDefault')}</Label>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -372,7 +383,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
checked={createIfMissing}
|
||||
onCheckedChange={setCreateIfMissing}
|
||||
/>
|
||||
<Label htmlFor="s-autocreate">测试时桶不存在则自动创建</Label>
|
||||
<Label htmlFor="s-autocreate">{t('form.autoCreate')}</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -398,14 +409,20 @@ export const StorageFormDialog = memo(function StorageFormDialog({
|
||||
onClick={handleTest}
|
||||
>
|
||||
<PlugZap className="w-4 h-4 mr-1" />
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
{testing ? t('form.testing') : t('form.test')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 bg-primary hover:bg-primary/90"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加存储'}
|
||||
{submitting
|
||||
? editing
|
||||
? t('form.saving')
|
||||
: t('form.adding')
|
||||
: editing
|
||||
? t('form.save')
|
||||
: t('form.add')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,20 +12,11 @@ export const STORAGE_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
|
||||
export const STORAGE_TYPE_ICON_CLASS: Record<string, string> = {
|
||||
s3: 'text-warning',
|
||||
oss: 'text-destructive',
|
||||
oss: 'text-primary',
|
||||
cos: 'text-info',
|
||||
obs: 'text-info',
|
||||
minio: 'text-info',
|
||||
rustfs: 'text-info',
|
||||
}
|
||||
|
||||
export const STORAGE_TYPE_COLORS: Record<string, string> = {
|
||||
s3: 'bg-warning/10 text-warning',
|
||||
oss: 'bg-destructive/10 text-destructive',
|
||||
cos: 'bg-info/10 text-info',
|
||||
obs: 'bg-info/10 text-info',
|
||||
minio: 'bg-info/10 text-info',
|
||||
rustfs: 'bg-info/10 text-info',
|
||||
}
|
||||
|
||||
export const STORAGE_TYPES = ['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs'] as const
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HardDrive, Plus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { storageApi, deploymentApi } from '@/api'
|
||||
@@ -19,6 +20,7 @@ import { StorageFormDialog } from './components/StorageFormDialog'
|
||||
* 卡片/表单/删除确认为域内子组件。成功操作静默(列表刷新即反馈)。
|
||||
*/
|
||||
const StoragePage = memo(function StoragePage() {
|
||||
const { t } = useTranslation('storage')
|
||||
const query = useListQuery<StorageConfig>((params, signal) =>
|
||||
storageApi.list(params, { signal })
|
||||
)
|
||||
@@ -46,11 +48,11 @@ const StoragePage = memo(function StoragePage() {
|
||||
setDeleteTarget(null)
|
||||
query.refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '删除存储配置失败')
|
||||
toast.error(err instanceof Error ? err.message : t('page.errDelete'))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTarget, query])
|
||||
}, [deleteTarget, query, t])
|
||||
|
||||
// 设为默认存储(后端保证唯一默认:置新默认会取消旧默认)
|
||||
const handleSetDefault = useCallback(
|
||||
@@ -59,23 +61,20 @@ const StoragePage = memo(function StoragePage() {
|
||||
await storageApi.update(id, { isDefault: true })
|
||||
query.refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '设置默认存储失败')
|
||||
toast.error(err instanceof Error ? err.message : t('page.errSetDefault'))
|
||||
}
|
||||
},
|
||||
[query]
|
||||
[query, t]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="存储配置"
|
||||
description="为 Loki 配置对象存储后端"
|
||||
title={t('page.title')}
|
||||
description={t('page.description')}
|
||||
action={
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90"
|
||||
onClick={() => setFormOpen(true)}
|
||||
>
|
||||
<Button onClick={() => setFormOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加存储
|
||||
{t('page.add')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -86,8 +85,8 @@ const StoragePage = memo(function StoragePage() {
|
||||
) : query.list.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={HardDrive}
|
||||
title="暂无存储配置"
|
||||
description="添加第一个 Loki 存储后端"
|
||||
title={t('page.emptyTitle')}
|
||||
description={t('page.emptyDescription')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -119,7 +118,7 @@ const StoragePage = memo(function StoragePage() {
|
||||
setEditingTarget(null)
|
||||
}
|
||||
}}
|
||||
onCreated={query.refresh}
|
||||
onCreated={() => (editingTarget ? query.refresh() : query.reload())}
|
||||
editing={editingTarget}
|
||||
/>
|
||||
|
||||
@@ -132,7 +131,7 @@ const StoragePage = memo(function StoragePage() {
|
||||
loading={deleting}
|
||||
description={
|
||||
depRefCount > 0
|
||||
? `确定要删除「${deleteTarget?.name}」吗?警告:仍有 ${depRefCount} 个部署使用该存储,删除后这些部署的对象存储配置将失效。`
|
||||
? t('page.deleteWarning', { name: deleteTarget?.name, count: depRefCount })
|
||||
: undefined
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
|
||||
Reference in new issue
Block a user