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:
opsx committed 2026-10-27 17:11:38 +08:00
1 parent 02d1bd87a7
commit f3733f0bfc
201 files changed
+5438 -2388

No files matched your search

@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Globe, Save } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -8,28 +9,31 @@ import { cn } from '@/lib/utils'
import { settingsApi, type ImageRegistryConfig } from '@/api/settings'
import { toast } from 'sonner'
const MODES = [
{ value: 'overseas', label: '海外官方', desc: 'docker.io,海外网络环境' },
{ value: 'cn', label: '国内加速', desc: 'docker.m.daocloud.io 镜像,国内环境防被墙' },
{ value: 'custom', label: '私有仓库', desc: '自有 registry 前缀(如华为云 SWR)' },
] as const
const COMPONENTS = [
{ key: 'loki', label: 'Loki', repo: 'grafana/loki', tagHint: '跟随部署版本' },
{ key: 'grafana', label: 'Grafana', repo: 'grafana/grafana', tagHint: '跟随部署版本' },
{ key: 'promtail', label: 'Promtail', repo: 'grafana/promtail', tagHint: '3.4.0' },
{ key: 'fluentbit', label: 'Fluent Bit', repo: 'fluent/fluent-bit', tagHint: '5.1.5' },
] as const
/**
* 镜像源设置 — 影响平台拉取的全部镜像(Loki/Grafana/Promtail/Fluent Bit),
* 参照 kuboard 的国内/海外选择;实时预览四类镜像的生效地址。
*/
export const ImageRegistrySection = memo(function ImageRegistrySection() {
const { t } = useTranslation('settings')
const [cfg, setCfg] = useState<ImageRegistryConfig | null>(null)
const [customHost, setCustomHost] = useState('')
const [saving, setSaving] = useState(false)
// 源选项(文案走词典;value 保持字面量类型以匹配 cfg.mode)
const MODES = [
{ value: 'overseas', label: t('registry.modeOverseasLabel'), desc: t('registry.modeOverseasDesc') },
{ value: 'cn', label: t('registry.modeCnLabel'), desc: t('registry.modeCnDesc') },
{ value: 'custom', label: t('registry.modeCustomLabel'), desc: t('registry.modeCustomDesc') },
] as const
// 组件镜像行(repo 为 placeholder 示例;tagHint 区分「跟随部署版本」与内置版本号)
const COMPONENTS = [
{ key: 'loki', label: 'Loki', repo: 'grafana/loki', tagHint: t('registry.tagFollowVersion') },
{ key: 'grafana', label: 'Grafana', repo: 'grafana/grafana', tagHint: t('registry.tagFollowVersion') },
{ key: 'promtail', label: 'Promtail', repo: 'grafana/promtail', tagHint: '3.4.0' },
{ key: 'fluentbit', label: 'Fluent Bit', repo: 'fluent/fluent-bit', tagHint: '5.1.5' },
] as const
useEffect(() => {
settingsApi
.getImageRegistry()
@@ -37,7 +41,9 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
setCfg(c)
setCustomHost(c.customHost || '')
})
.catch(() => toast.error('加载镜像源配置失败'))
.catch(() => toast.error(t('registry.errLoad')))
// 仅首载拉取配置;t 只影响失败 toast,不随语言切换重跑(避免覆盖未保存输入)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSave = useCallback(async () => {
@@ -50,13 +56,13 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
images: cfg.images,
})
setCfg(saved)
toast.success('镜像源已保存,后续部署/采集将使用新镜像地址')
toast.success(t('registry.savedToast'))
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
toast.error(err instanceof Error ? err.message : t('notify.errSave'))
} finally {
setSaving(false)
}
}, [cfg, customHost])
}, [cfg, customHost, t])
if (!cfg) return null
@@ -65,7 +71,7 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4" />
镜像仓库源
{t('registry.cardTitle')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -89,19 +95,21 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
{cfg.mode === 'custom' && (
<div className="space-y-2">
<Label>私有仓库前缀 *</Label>
<Label>{t('registry.customPrefixLabel')}</Label>
<Input
placeholder="swr.cn-east-2.myhuaweicloud.com/your-namespace"
value={customHost}
onChange={(e) => setCustomHost(e.target.value)}
/>
<p className="text-xs text-muted-foreground">平台会在镜像名前拼接此前缀,如 {customHost || 'your-registry'}/grafana/loki:3.4.0</p>
<p className="text-xs text-muted-foreground">
{t('registry.customPrefixHint', { example: customHost || 'your-registry' })}
</p>
</div>
)}
{/* 各服务镜像自定义 */}
<div className="space-y-2">
<Label>各服务镜像(可单独覆盖仓库 / Tag)</Label>
<Label>{t('registry.imagesLabel')}</Label>
<div className="space-y-2">
{COMPONENTS.map(({ key, label, repo, tagHint }) => {
const img = cfg.images?.[key] || { repository: '', tag: '' }
@@ -114,14 +122,14 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
<div key={key} className="grid items-center gap-2 sm:grid-cols-[110px_1fr_150px]">
<span className="text-sm font-medium">{label}</span>
<Input
aria-label={`${label} 镜像仓库`}
aria-label={t('registry.repoAria', { label })}
value={img.repository}
placeholder={repo}
onChange={(e) => update({ repository: e.target.value })}
className="font-mono text-xs"
/>
<Input
aria-label={`${label} 镜像 Tag`}
aria-label={t('registry.tagAria', { label })}
value={img.tag}
placeholder={tagHint}
onChange={(e) => update({ tag: e.target.value })}
@@ -132,13 +140,15 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
})}
</div>
<p className="text-xs text-muted-foreground">
仓库填镜像路径(如 <code>grafana/loki</code>);Tag 留空时 Loki / Grafana 跟随部署版本,Promtail / Fluent Bit 用内置默认。
{t('registry.imagesHintBefore')}
<code>grafana/loki</code>
{t('registry.imagesHintAfter')}
</p>
</div>
{/* 生效镜像预览 */}
<div className="rounded-lg bg-muted/50 p-3 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">生效镜像预览</p>
<p className="text-xs font-medium text-muted-foreground">{t('registry.previewTitle')}</p>
{([
['Loki', cfg.sampleLoki],
['Grafana', cfg.sampleGrafana],
@@ -155,7 +165,7 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />
{saving ? '保存中...' : '保存镜像源'}
{saving ? t('actions.saving') : t('registry.saveButton')}
</Button>
</div>
</CardContent>
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Building2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -29,6 +30,7 @@ const EMPTY: LdapConfig = {
* 用户管理页才会出现「从 LDAP 导入」。
*/
export const LdapSection = memo(function LdapSection() {
const { t } = useTranslation('settings')
const [cfg, setCfg] = useState<LdapConfig>(EMPTY)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
@@ -42,7 +44,9 @@ export const LdapSection = memo(function LdapSection() {
setCfg({ ...EMPTY, ...c, bindPassword: '' })
setLoaded(true)
})
.catch(() => toast.error('加载 LDAP 配置失败'))
.catch(() => toast.error(t('ldap.errLoad')))
// 仅首载拉取配置;t 只影响失败 toast,不随语言切换重跑(避免覆盖未保存表单)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const set = useCallback(<K extends keyof LdapConfig>(k: K, v: LdapConfig[K]) => {
@@ -51,7 +55,7 @@ export const LdapSection = memo(function LdapSection() {
const handleTest = async () => {
if (!cfg.host || !cfg.bindDN || !cfg.baseDN) {
toast.error('请先填写服务器地址 / Bind DN / Base DN')
toast.error(t('ldap.errMissingFields'))
return
}
setTesting(true)
@@ -60,7 +64,7 @@ export const LdapSection = memo(function LdapSection() {
const result = await ldapApi.test(cfg)
setTestResult(result)
} catch (err) {
toast.error(err instanceof Error ? err.message : '测试失败')
toast.error(err instanceof Error ? err.message : t('ldap.errTest'))
} finally {
setTesting(false)
}
@@ -71,9 +75,9 @@ export const LdapSection = memo(function LdapSection() {
try {
const saved = await ldapApi.saveConfig(cfg)
setCfg({ ...EMPTY, ...saved, bindPassword: '' })
toast.success('LDAP 配置已保存')
toast.success(t('ldap.savedToast'))
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
toast.error(err instanceof Error ? err.message : t('notify.errSave'))
} finally {
setSaving(false)
}
@@ -85,24 +89,21 @@ export const LdapSection = memo(function LdapSection() {
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
LDAP 接入
{t('ldap.cardTitle')}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">启用后支持 LDAP 登录与批量导入</span>
<span className="text-xs text-muted-foreground">{t('ldap.headerHint')}</span>
<Switch checked={cfg.enabled} onCheckedChange={(v) => set('enabled', v)} />
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loaded && !cfg.enabled ? (
<p className="text-sm text-muted-foreground py-2 text-center">
LDAP 未启用 —— 打开开关填写目录服务信息(服务器 / Bind DN / Base DN),
保存后用户管理页将出现「从 LDAP 导入」
</p>
<p className="text-sm text-muted-foreground py-2 text-center">{t('ldap.disabledHint')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>服务器地址 *</Label>
<Label>{t('ldap.hostLabel')}</Label>
<Input
placeholder="8.217.205.92"
value={cfg.host}
@@ -111,7 +112,7 @@ export const LdapSection = memo(function LdapSection() {
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2 col-span-2">
<Label>端口</Label>
<Label>{t('ldap.portLabel')}</Label>
<Input
type="number"
value={cfg.port || 389}
@@ -119,14 +120,14 @@ export const LdapSection = memo(function LdapSection() {
/>
</div>
<div className="space-y-2 flex flex-col">
<Label>LDAPS</Label>
<Label>{t('ldap.ldapsLabel')}</Label>
<div className="pt-2">
<Switch checked={cfg.useSSL} onCheckedChange={(v) => set('useSSL', v)} />
</div>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Bind DN *</Label>
<Label>{t('ldap.bindDnLabel')}</Label>
<Input
placeholder="cn=admin,dc=example,dc=com"
value={cfg.bindDN}
@@ -134,16 +135,16 @@ export const LdapSection = memo(function LdapSection() {
/>
</div>
<div className="space-y-2">
<Label>Bind 密码 *</Label>
<Label>{t('ldap.bindPasswordLabel')}</Label>
<PasswordInput
placeholder={cfg.hasPassword ? '已保存,输入新密码以修改' : '管理员密码'}
placeholder={cfg.hasPassword ? t('ldap.bindPasswordSavedPlaceholder') : t('ldap.bindPasswordPlaceholder')}
value={cfg.bindPassword}
onChange={(e) => set('bindPassword', e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label>Base DN *</Label>
<Label>{t('ldap.baseDnLabel')}</Label>
<Input
placeholder="dc=example,dc=com"
value={cfg.baseDN}
@@ -151,20 +152,20 @@ export const LdapSection = memo(function LdapSection() {
/>
</div>
<div className="space-y-2">
<Label>用户过滤器</Label>
<Label>{t('ldap.userFilterLabel')}</Label>
<Input value={cfg.userFilter} onChange={(e) => set('userFilter', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label>用户名</Label>
<Label>{t('ldap.usernameLabel')}</Label>
<Input value={cfg.usernameAttr} onChange={(e) => set('usernameAttr', e.target.value)} />
</div>
<div className="space-y-2">
<Label>姓名</Label>
<Label>{t('ldap.nameLabel')}</Label>
<Input value={cfg.nameAttr} onChange={(e) => set('nameAttr', e.target.value)} />
</div>
<div className="space-y-2">
<Label>邮箱</Label>
<Label>{t('ldap.emailLabel')}</Label>
<Input value={cfg.emailAttr} onChange={(e) => set('emailAttr', e.target.value)} />
</div>
</div>
@@ -183,7 +184,7 @@ export const LdapSection = memo(function LdapSection() {
{testResult.message}
{testResult.userInfo && (
<span className="block text-xs mt-1 opacity-80">
{testResult.userInfo.displayName} · {testResult.userInfo.email || '无邮箱'}
{testResult.userInfo.displayName} · {testResult.userInfo.email || t('ldap.noEmail')}
</span>
)}
</div>
@@ -191,10 +192,10 @@ export const LdapSection = memo(function LdapSection() {
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={testing || !cfg.enabled}>
{testing ? '测试中...' : '测试连接'}
{testing ? t('actions.testing') : t('ldap.testButton')}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存配置'}
{saving ? t('actions.saving') : t('ldap.saveButton')}
</Button>
</div>
</CardContent>
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { useTranslation } from 'react-i18next'
import { BellRing, Plus, Pencil, Trash2, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -26,6 +27,7 @@ interface ChannelForm {
* webhook 加密存储(留空=保留旧值),每通道可独立测试/禁用/删除(有关联规则时禁删)。
*/
export const NotifySection = memo(function NotifySection() {
const { t } = useTranslation('settings')
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
@@ -40,9 +42,9 @@ export const NotifySection = memo(function NotifySection() {
const list = await alertsApi.listChannels()
setChannels(Array.isArray(list) ? list : [])
} catch (err) {
toast.error(err instanceof Error ? err.message : '加载通知通道失败')
toast.error(err instanceof Error ? err.message : t('notify.errLoad'))
}
}, [])
}, [t])
useEffect(() => {
void refresh()
@@ -62,11 +64,11 @@ export const NotifySection = memo(function NotifySection() {
const handleSave = async () => {
if (!form.name.trim()) {
toast.error('请填写通道名称')
toast.error(t('notify.errNameRequired'))
return
}
if (!editing && !form.webhook) {
toast.error('新建通道需要填写 Webhook 地址')
toast.error(t('notify.errWebhookRequired'))
return
}
setSaving(true)
@@ -89,7 +91,7 @@ export const NotifySection = memo(function NotifySection() {
setOpen(false)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
toast.error(err instanceof Error ? err.message : t('notify.errSave'))
} finally {
setSaving(false)
}
@@ -99,9 +101,9 @@ export const NotifySection = memo(function NotifySection() {
setTesting(true)
try {
await alertsApi.testChannel({ id: ch.id })
toast.success(`测试卡片已发送到「${ch.name}」`)
toast.success(t('notify.testSent', { name: ch.name }))
} catch (err) {
toast.error(err instanceof Error ? err.message : '发送失败')
toast.error(err instanceof Error ? err.message : t('notify.errSend'))
} finally {
setTesting(false)
}
@@ -112,7 +114,7 @@ export const NotifySection = memo(function NotifySection() {
await alertsApi.updateChannel(ch.id, { name: ch.name, enabled })
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '切换失败')
toast.error(err instanceof Error ? err.message : t('notify.errToggle'))
}
}
@@ -124,7 +126,7 @@ export const NotifySection = memo(function NotifySection() {
setDeleteTarget(null)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除失败')
toast.error(err instanceof Error ? err.message : t('notify.errDelete'))
} finally {
setDeleting(false)
}
@@ -136,21 +138,20 @@ export const NotifySection = memo(function NotifySection() {
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<BellRing className="w-4 h-4" />
告警通知通道(飞书/Lark)
{t('notify.cardTitle')}
</span>
<Button size="sm" variant="outline" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
新建通道
{t('notify.create')}
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-2 text-center">
尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),
在
<Link to="/alerts" className="text-primary mx-1">日志告警</Link>
页把规则关联到对应通道
{t('notify.emptyBefore')}
<Link to="/alerts" className="text-primary mx-1">{t('notify.emptyLink')}</Link>
{t('notify.emptyAfter')}
</p>
) : (
channels.map((ch) => (
@@ -159,19 +160,19 @@ export const NotifySection = memo(function NotifySection() {
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">{ch.name}</p>
<p className="text-xs text-muted-foreground">
{ch.hasWebhook ? 'Webhook 已配置' : '未配置'}
{ch.hasSecret ? ' · 签名已配置' : ''}
{ch.hasWebhook ? t('notify.webhookConfigured') : t('notify.webhookMissing')}
{ch.hasSecret ? t('notify.secretConfigured') : ''}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" aria-label={`测试 ${ch.name}`} disabled={testing} onClick={() => void handleTest(ch)}>
<Button variant="outline" size="sm" aria-label={t('notify.testAria', { name: ch.name })} disabled={testing} onClick={() => void handleTest(ch)}>
<Send className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" aria-label={`编辑 ${ch.name}`} onClick={() => openEdit(ch)}>
<Button variant="outline" size="sm" aria-label={t('notify.editAria', { name: ch.name })} onClick={() => openEdit(ch)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={`删除 ${ch.name}`}
variant="outline" size="sm" aria-label={t('notify.deleteAria', { name: ch.name })}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(ch)}
>
@@ -186,41 +187,46 @@ export const NotifySection = memo(function NotifySection() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? `编辑通道 · ${editing.name}` : '新建通知通道'}</DialogTitle>
<DialogTitle>
{editing ? t('notify.editTitle', { name: editing.name }) : t('notify.createTitle')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>通道名称 *</Label>
<Label>{t('notify.nameLabel')}</Label>
<Input
placeholder="如:应用告警群 / Nginx 告警群"
placeholder={t('notify.namePlaceholder')}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Webhook 地址{editing ? '(留空保持不变)' : ' *'}</Label>
<Label>
{t('notify.webhookLabel')}
{editing ? t('notify.webhookKeepHint') : ' *'}
</Label>
<PasswordInput
placeholder={editing?.hasWebhook ? '已保存,输入新地址以修改' : 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'}
placeholder={editing?.hasWebhook ? t('notify.webhookSavedPlaceholder') : 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'}
value={form.webhook}
onChange={(e) => setForm((f) => ({ ...f, webhook: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>签名校验密钥(可选)</Label>
<Label>{t('notify.signingLabel')}</Label>
<PasswordInput
placeholder={editing?.hasSecret ? '已保存,留空保持不变' : '飞书机器人「签名校验」密钥'}
placeholder={editing?.hasSecret ? t('notify.signingSavedPlaceholder') : t('notify.signingPlaceholder')}
value={form.signingSecret}
onChange={(e) => setForm((f) => ({ ...f, signingSecret: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
<Label>启用通道</Label>
<Label>{t('notify.enable')}</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button variant="outline" onClick={() => setOpen(false)}>{t('actions.cancel')}</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : editing ? '保存修改' : '创建通道'}
{saving ? t('actions.saving') : editing ? t('notify.saveChanges') : t('notify.createChannel')}
</Button>
</div>
</div>
@@ -231,7 +237,7 @@ export const NotifySection = memo(function NotifySection() {
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title="确认删除通知通道"
title={t('notify.deleteTitle')}
loading={deleting}
onConfirm={handleDelete}
/>
@@ -1,41 +1,103 @@
import { memo } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTheme } from 'next-themes'
import { Sun, Moon, Monitor } from 'lucide-react'
import { Sun, Moon, Monitor, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
DAY_START_HOUR,
DAY_END_HOUR,
setThemeAuto,
setThemeTimezone,
listTimezones,
} from '@/lib/theme-schedule'
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const OPTIONS = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
] as const
type ThemeMode = 'light' | 'dark' | 'system' | 'auto'
const OPTIONS: Array<{ value: ThemeMode; labelKey: string; icon: typeof Sun }> = [
{ value: 'light', labelKey: 'themeCard.light', icon: Sun },
{ value: 'dark', labelKey: 'themeCard.dark', icon: Moon },
{ value: 'system', labelKey: 'themeCard.system', icon: Monitor },
{ value: 'auto', labelKey: 'themeCard.auto', icon: Clock },
]
/**
* 主题设置 — 三态单选卡片(浅色/深色/跟随系统),next-themes 驱动,
* key `opsxloki.theme.mode`。
* 主题设置 — 紧凑分段选择器(浅色/深色/跟随系统/定时切换),next-themes 驱动,
* key `lokistack.theme.mode`;「定时切换」由调度器按时区白天浅色、夜间深色,
* 时区下拉仅在选中定时切换时内联出现。
*/
export const ThemeSection = memo(function ThemeSection() {
const { theme, setTheme } = useTheme()
const { t } = useTranslation('settings')
const { auto, timezone } = useScheduledTheme()
const timezones = useMemo(() => listTimezones(), [])
const activeValue: ThemeMode = auto ? 'auto' : theme === 'system' ? 'system' : theme === 'dark' ? 'dark' : 'light'
const selectTheme = (value: ThemeMode) => {
if (value === 'auto') {
setThemeAuto(true) // 调度器立即解析并应用
return
}
setThemeAuto(false)
setTheme(value)
}
return (
<div className="grid grid-cols-3 gap-3">
{OPTIONS.map((opt) => {
const Icon = opt.icon
const active = theme === opt.value
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => setTheme(opt.value)}
className={cn(
'flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all',
active ? 'border-primary bg-primary/5 text-primary' : 'border-border bg-card text-muted-foreground hover:border-input'
)}
>
<Icon className="w-5 h-5" />
<span className="text-sm font-medium">{opt.label}</span>
</button>
)
})}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-sm text-muted-foreground min-w-14 shrink-0">{t('themeCard.title')}</span>
<div role="group" aria-label={t('themeCard.title')} className="inline-flex items-center rounded-lg border border-border bg-card p-0.5">
{OPTIONS.map((opt) => {
const Icon = opt.icon
const active = activeValue === opt.value
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => selectTheme(opt.value)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors',
active
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Icon className="size-3.5" />
{t(opt.labelKey)}
</button>
)
})}
</div>
{auto && (
<Select value={timezone || 'browser'} onValueChange={(v) => setThemeTimezone(v === 'browser' ? '' : v)}>
<SelectTrigger className="h-8 w-44" aria-label={t('themeCard.timezoneAria')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="browser">{t('themeCard.browser')}</SelectItem>
{timezones.map((tz) => (
<SelectItem key={tz} value={tz}>{tz}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{auto && (
<p className="text-xs text-muted-foreground">
{t('themeCard.autoHint', {
start: String(DAY_START_HOUR).padStart(2, '0'),
end: String(DAY_END_HOUR).padStart(2, '0'),
})}
</p>
)}
</div>
)
})
+36 -32
View File
@@ -1,5 +1,6 @@
import { memo, useCallback, useState } from 'react'
import { useNavigate } from 'react-router'
import { useTranslation } from 'react-i18next'
import { KeyRound, Settings as SettingsIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
import { PageContainer } from '@/components/PageContainer'
import { LdapSection } from './components/LdapSection'
import { ThemeSection } from './components/ThemeSection'
import { LanguageSection } from '@/i18n/LanguageSection'
import { Palette } from 'lucide-react'
import { NotifySection } from './components/NotifySection'
import { ImageRegistrySection } from './components/ImageRegistrySection'
@@ -20,35 +22,36 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const COMMIT_HASH = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : '-'
// 系统信息常量
const SYSTEM_INFO = [
{ label: '版本', value: `${APP_VERSION} (${COMMIT_HASH})` },
{ label: '后端', value: 'Go + Gin + JSON 文件存储' },
{ label: '前端', value: 'React 19 + TypeScript + Tailwind CSS' },
{ label: '部署工具', value: 'kubectl + helm(镜像内置)' },
{ label: '简介', value: '轻量级 K8s Loki 部署平台' },
]
/**
* 系统设置 — 通用配置(主题/密码/关于)/ 告警通知 / LDAP 接入。
* 表单保持手写(字段少、校验已达标准);成功后清空表单即反馈(成功静默)。
*/
const SettingsPage = memo(function SettingsPage() {
const { t } = useTranslation('settings')
const navigate = useNavigate()
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [changing, setChanging] = useState(false)
// 系统信息列表(文案走 settings 词典;版本值为构建指纹动态拼接)
const systemInfo = [
{ label: t('about.versionLabel'), value: `${APP_VERSION} (${COMMIT_HASH})` },
{ label: t('about.backendLabel'), value: t('about.backendValue') },
{ label: t('about.frontendLabel'), value: t('about.frontendValue') },
{ label: t('about.k8sLabel'), value: t('about.k8sValue') },
{ label: t('about.summaryLabel'), value: t('about.summaryValue') },
]
const handleChangePassword = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
toast.error('两次输入的密码不一致')
toast.error(t('password.errMismatch'))
return
}
if (newPassword.length < 6) {
toast.error('新密码至少需要 6 位')
toast.error(t('password.errTooShort'))
return
}
setChanging(true)
@@ -58,36 +61,37 @@ const SettingsPage = memo(function SettingsPage() {
clearAuth()
navigate('/login', { replace: true })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '密码修改失败'
const message = err instanceof Error ? err.message : t('password.errFailed')
toast.error(message)
} finally {
setChanging(false)
}
},
[oldPassword, newPassword, confirmPassword, navigate]
[oldPassword, newPassword, confirmPassword, navigate, t]
)
return (
<PageContainer title="系统设置" description="通用配置、告警通知、LDAP 接入与默认镜像源">
<PageContainer title={t('title')} description={t('description')}>
<Tabs defaultValue="general" className="space-y-6">
<TabsList>
<TabsTrigger value="general">通用配置</TabsTrigger>
<TabsTrigger value="notify">告警通知</TabsTrigger>
<TabsTrigger value="ldap">LDAP 接入</TabsTrigger>
<TabsTrigger value="registry">默认镜像源</TabsTrigger>
<TabsTrigger value="general">{t('tabs.general')}</TabsTrigger>
<TabsTrigger value="notify">{t('tabs.notify')}</TabsTrigger>
<TabsTrigger value="ldap">{t('tabs.ldap')}</TabsTrigger>
<TabsTrigger value="registry">{t('tabs.registry')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
{/* 主题 */}
{/* 外观与语言(紧凑单卡:主题分段器 + 语言分段器) */}
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Palette className="w-4 h-4" />
主题
{t('prefsCard.title')}
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-3">
<ThemeSection />
<LanguageSection />
</CardContent>
</Card>
@@ -97,41 +101,41 @@ const SettingsPage = memo(function SettingsPage() {
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<KeyRound className="w-4 h-4" />
修改密码
{t('password.title')}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="old-pwd">当前密码</Label>
<Label htmlFor="old-pwd">{t('password.oldLabel')}</Label>
<PasswordInput
id="old-pwd"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="请输入当前密码"
placeholder={t('password.oldPlaceholder')}
required
autoComplete="current-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-pwd">新密码</Label>
<Label htmlFor="new-pwd">{t('password.newLabel')}</Label>
<PasswordInput
id="new-pwd"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码(至少 6 位)"
placeholder={t('password.newPlaceholder')}
required
minLength={6}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-pwd">确认新密码</Label>
<Label htmlFor="confirm-pwd">{t('password.confirmLabel')}</Label>
<PasswordInput
id="confirm-pwd"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
placeholder={t('password.confirmPlaceholder')}
required
autoComplete="new-password"
/>
@@ -141,7 +145,7 @@ const SettingsPage = memo(function SettingsPage() {
className="bg-primary hover:bg-primary/90"
disabled={changing}
>
{changing ? '修改中...' : '修改密码'}
{changing ? t('password.submitting') : t('password.submit')}
</Button>
</form>
</CardContent>
@@ -152,16 +156,16 @@ const SettingsPage = memo(function SettingsPage() {
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
关于 lokistack
{t('about.title')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-0">
{SYSTEM_INFO.map((item, index) => (
{systemInfo.map((item, index) => (
<div
key={item.label}
className={`flex justify-between py-2.5 ${
index < SYSTEM_INFO.length - 1 ? 'border-b border-border' : ''
index < systemInfo.length - 1 ? 'border-b border-border' : ''
}`}
>
<span className="text-sm text-muted-foreground">{item.label}</span>