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,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { Users, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -33,6 +34,7 @@ export const LdapImportDialog = memo(function LdapImportDialog({
localUsers,
onImported,
}: LdapImportDialogProps) {
const { t } = useTranslation('users')
const [loading, setLoading] = useState(false)
const [importing, setImporting] = useState(false)
const [users, setUsers] = useState<LdapUserPreview[]>([])
@@ -55,11 +57,11 @@ export const LdapImportDialog = memo(function LdapImportDialog({
setUsers(list)
setSelected(new Set())
} catch (err) {
setError(err instanceof Error ? err.message : '加载 LDAP 用户失败')
setError(err instanceof Error ? err.message : t('ldap.loadFailed'))
} finally {
setLoading(false)
}
}, [])
}, [t])
useEffect(() => {
if (open) void loadUsers()
@@ -88,7 +90,7 @@ export const LdapImportDialog = memo(function LdapImportDialog({
setResult(r)
onImported()
} catch (err) {
toast.error(err instanceof Error ? err.message : '导入失败')
toast.error(err instanceof Error ? err.message : t('ldap.importFailed'))
} finally {
setImporting(false)
}
@@ -98,21 +100,25 @@ export const LdapImportDialog = memo(function LdapImportDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>从 LDAP 导入用户</DialogTitle>
<DialogDescription>
来自 LDAP 目录的用户;导入后使用 LDAP 密码登录,角色为普通用户
</DialogDescription>
<DialogTitle>{t('ldap.title')}</DialogTitle>
<DialogDescription>{t('ldap.desc')}</DialogDescription>
</DialogHeader>
{result ? (
<div className="space-y-3">
<div className="flex items-center gap-4 text-sm">
{result.successCount > 0 && (
<span className="text-success">新增 {result.successCount}</span>
<span className="text-success">{t('ldap.added', { count: result.successCount })}</span>
)}
{result.updatedCount > 0 && (
<span className="text-info">{t('ldap.converted', { count: result.updatedCount })}</span>
)}
{result.skippedCount > 0 && (
<span className="text-muted-foreground">{t('ldap.skipped', { count: result.skippedCount })}</span>
)}
{result.failedCount > 0 && (
<span className="text-destructive">{t('ldap.failedCount', { count: result.failedCount })}</span>
)}
{result.updatedCount > 0 && <span className="text-info">转为 LDAP {result.updatedCount}</span>}
{result.skippedCount > 0 && <span className="text-muted-foreground">跳过 {result.skippedCount}</span>}
{result.failedCount > 0 && <span className="text-destructive">失败 {result.failedCount}</span>}
</div>
{result.failedUsers.length > 0 && (
<div className="text-xs text-destructive space-y-1 max-h-32 overflow-y-auto">
@@ -122,15 +128,17 @@ export const LdapImportDialog = memo(function LdapImportDialog({
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => void loadUsers()}>继续导入</Button>
<Button onClick={() => onOpenChange(false)}>完成</Button>
<Button variant="outline" onClick={() => void loadUsers()}>{t('ldap.continue')}</Button>
<Button onClick={() => onOpenChange(false)}>{t('ldap.done')}</Button>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{loading ? '加载 LDAP 用户…' : `共 ${users.length} 个用户,已选 ${selected.size} 个`}
{loading
? t('ldap.loading')
: t('ldap.summary', { total: users.length, selected: selected.size })}
</p>
<Button variant="ghost" size="sm" onClick={() => void loadUsers()} disabled={loading}>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
@@ -140,9 +148,11 @@ export const LdapImportDialog = memo(function LdapImportDialog({
<div className="space-y-3">
<p className="text-sm text-destructive">{error}</p>
<p className="text-xs text-muted-foreground">
请到
<Link to="/settings" className="text-primary mx-1">系统设置</Link>
检查 LDAP 配置与启用状态
{t('ldap.errorHintPre')}
<Link to="/settings" className="text-primary mx-1">
{t('menu.settings', { ns: 'common' })}
</Link>
{t('ldap.errorHintPost')}
</p>
</div>
) : (
@@ -168,14 +178,14 @@ export const LdapImportDialog = memo(function LdapImportDialog({
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{u.username}</span>
{isLdapAlready && (
<span className="text-xs text-muted-foreground">已导入</span>
<span className="text-xs text-muted-foreground">{t('ldap.imported')}</span>
)}
{willConvert && (
<span className="text-xs text-info">本地同名,将转为 LDAP</span>
<span className="text-xs text-info">{t('ldap.willConvert')}</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{u.displayName || '无姓名'} · {u.email || '无邮箱'}
{u.displayName || t('ldap.noName')} · {u.email || t('ldap.noEmail')}
</p>
</div>
</label>
@@ -183,16 +193,20 @@ export const LdapImportDialog = memo(function LdapImportDialog({
})}
{!loading && users.length === 0 && (
<div className="flex items-center justify-center gap-2 py-10 text-muted-foreground text-sm">
<Users className="w-4 h-4" /> 目录下没有用户
<Users className="w-4 h-4" /> {t('ldap.empty')}
</div>
)}
</div>
</ScrollArea>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('dialog.cancel', { ns: 'common' })}
</Button>
<Button onClick={handleImport} disabled={importing || selected.size === 0}>
{importing ? '导入中...' : `导入 ${selected.size} 个用户`}
{importing
? t('ldap.importing')
: t('ldap.importCount', { count: selected.size })}
</Button>
</div>
</>
@@ -1,5 +1,6 @@
import { memo, useEffect, useState } from 'react'
import { Dice5 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -39,6 +40,7 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
onClose,
onReset,
}: ResetPasswordDialogProps) {
const { t } = useTranslation('users')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
@@ -53,7 +55,7 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (password.length < 6) {
setError('密码至少需要 6 位')
setError(t('form.passwordMin'))
return
}
if (!user) return
@@ -63,7 +65,7 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
onClose()
onReset()
} catch (err) {
toast.error(err instanceof Error ? err.message : '重置密码失败')
toast.error(err instanceof Error ? err.message : t('resetFailed'))
} finally {
setSubmitting(false)
}
@@ -73,19 +75,19 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
<Dialog open={user !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>重置密码</DialogTitle>
<DialogTitle>{t('resetPassword')}</DialogTitle>
<DialogDescription>
重置「{user?.username}」的密码后,该用户的所有登录会话将立即失效。
{t('resetDesc', { name: user?.username ?? '' })}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reset-password">新密码 *</Label>
<Label htmlFor="reset-password">{t('newPassword')}</Label>
<div className="flex gap-2">
<PasswordInput
id="reset-password"
className="flex-1"
placeholder="至少 6 位"
placeholder={t('form.passwordPlaceholder')}
value={password}
onChange={(e) => {
setPassword(e.target.value)
@@ -97,8 +99,8 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
<Button
type="button"
variant="outline"
aria-label="随机生成密码"
title="随机生成密码"
aria-label={t('randomPassword')}
title={t('randomPassword')}
onClick={() => {
setPassword(randomPassword())
setError('')
@@ -111,10 +113,10 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
取消
{t('dialog.cancel', { ns: 'common' })}
</Button>
<Button type="submit" disabled={submitting || password.length < 6}>
{submitting ? '重置中...' : '重置密码'}
{submitting ? t('resetting') : t('resetPassword')}
</Button>
</div>
</form>
@@ -1,7 +1,8 @@
import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@@ -29,17 +30,19 @@ import { PasswordInput } from '@/components/PasswordInput'
import { userApi } from '@/api'
import { toast } from 'sonner'
const userFormSchema = z.object({
username: z
.string()
.min(2, '用户名至少 2 个字符')
.max(32, '用户名最多 32 个字符'),
nickname: z.string().optional(),
password: z.string().min(6, '密码至少需要 6 位'),
role: z.enum(['admin', 'user']),
})
// 表单校验 — 工厂接收 t,以便 zod 报错文案走 users 词典(见 form.*)
const buildUserFormSchema = (t: (key: string) => string) =>
z.object({
username: z
.string()
.min(2, t('form.usernameMin'))
.max(32, t('form.usernameMax')),
nickname: z.string().optional(),
password: z.string().min(6, t('form.passwordMin')),
role: z.enum(['admin', 'user']),
})
export type UserFormValues = z.infer<typeof userFormSchema>
export type UserFormValues = z.infer<ReturnType<typeof buildUserFormSchema>>
const DEFAULT_VALUES: UserFormValues = {
username: '',
@@ -60,8 +63,12 @@ export const UserFormDialog = memo(function UserFormDialog({
onOpenChange,
onCreated,
}: UserFormDialogProps) {
const { t } = useTranslation('users')
const [submitting, setSubmitting] = useState(false)
// 报错文案依赖当前语言,t 变化时重建 schema(resolver 随之更新)
const userFormSchema = useMemo(() => buildUserFormSchema(t), [t])
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: DEFAULT_VALUES,
@@ -85,7 +92,7 @@ export const UserFormDialog = memo(function UserFormDialog({
onOpenChange(false)
onCreated()
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建用户失败')
toast.error(err instanceof Error ? err.message : t('createFailed'))
} finally {
setSubmitting(false)
}
@@ -95,7 +102,7 @@ export const UserFormDialog = memo(function UserFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>创建用户</DialogTitle>
<DialogTitle>{t('create')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -104,9 +111,9 @@ export const UserFormDialog = memo(function UserFormDialog({
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>用户名 *</FormLabel>
<FormLabel>{t('form.username')}</FormLabel>
<FormControl>
<Input placeholder="登录用户名" autoComplete="off" {...field} />
<Input placeholder={t('form.usernamePlaceholder')} autoComplete="off" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -117,9 +124,9 @@ export const UserFormDialog = memo(function UserFormDialog({
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel>昵称</FormLabel>
<FormLabel>{t('form.nickname')}</FormLabel>
<FormControl>
<Input placeholder="显示昵称(可选)" {...field} />
<Input placeholder={t('form.nicknamePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -130,10 +137,10 @@ export const UserFormDialog = memo(function UserFormDialog({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>初始密码 *</FormLabel>
<FormLabel>{t('form.password')}</FormLabel>
<FormControl>
<PasswordInput
placeholder="至少 6 位"
placeholder={t('form.passwordPlaceholder')}
autoComplete="new-password"
{...field}
/>
@@ -147,16 +154,16 @@ export const UserFormDialog = memo(function UserFormDialog({
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>角色 *</FormLabel>
<FormLabel>{t('form.role')}</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择角色" />
<SelectValue placeholder={t('form.rolePlaceholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">普通用户</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
<SelectItem value="user">{t('role.user')}</SelectItem>
<SelectItem value="admin">{t('role.admin')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -164,7 +171,7 @@ export const UserFormDialog = memo(function UserFormDialog({
)}
/>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? '创建中...' : '创建用户'}
{submitting ? t('form.submitting') : t('create')}
</Button>
</form>
</Form>
+127 -26
View File
@@ -1,6 +1,9 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Users, UserRound, UsersRound, Plus, KeyRound, Trash2, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { userApi } from '@/api'
import { PageContainer } from '@/components/PageContainer'
@@ -19,22 +22,24 @@ import { ResetPasswordDialog } from './components/ResetPasswordDialog'
/** 角色徽章 */
function SourceBadge({ source }: { source?: string }) {
const { t } = useTranslation('users')
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full bg-info/10 text-info text-xs font-medium">
{source === 'ldap' ? 'LDAP 导入' : '本地账号'}
<span className="inline-flex items-center px-2 py-0.5 rounded-full bg-info/10 text-info text-xs font-medium">
{source === 'ldap' ? t('source.ldap') : t('source.local')}
</span>
)
}
function RoleBadge({ role }: { role: string }) {
const { t } = useTranslation('users')
return role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium">
<ShieldCheck className="w-3 h-3" />
管理员
{t('role.admin')}
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium">
普通用户
{t('role.user')}
</span>
)
}
@@ -45,6 +50,7 @@ function RoleBadge({ role }: { role: string }) {
* - 单卡片行列表 + 创建/重置密码/删除;成功操作静默(反馈分级)
*/
const UsersPage = memo(function UsersPage() {
const { t } = useTranslation('users')
const currentUser = getUser()
const isAdmin = currentUser?.role === 'admin'
@@ -66,6 +72,56 @@ const UsersPage = memo(function UsersPage() {
const [resetTarget, setResetTarget] = useState<User | null>(null)
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
const [deleting, setDeleting] = useState(false)
// 批量删除:自己一行不可选(后端守卫同样拒绝)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [batchConfirm, setBatchConfirm] = useState(false)
const [batchDeleting, setBatchDeleting] = useState(false)
// 列表刷新后剔除已不存在的选择项(如翻页/删除后)
useEffect(() => {
setSelected((prev) => {
const alive = new Set(query.list.map((u) => u.id))
const next = new Set([...prev].filter((id) => alive.has(id)))
return next.size === prev.size ? prev : next
})
}, [query.list])
const selectable = query.list.filter((u) => u.id !== currentUser?.id)
const allSelected = selectable.length > 0 && selectable.every((u) => selected.has(u.id))
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
setSelected((prev) => {
if (selectable.every((u) => prev.has(u.id))) return new Set()
return new Set(selectable.map((u) => u.id))
})
}
const handleBatchDelete = useCallback(async () => {
setBatchDeleting(true)
try {
const res = await userApi.batchRemove([...selected])
setBatchConfirm(false)
setSelected(new Set())
if (res.failures.length > 0) {
const reasons = res.failures.map((f) => `#${f.id}: ${f.reason}`).join('; ')
toast.warning(t('batch.skippedWarn', { count: res.failures.length, reasons }))
}
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('batch.failed'))
} finally {
setBatchDeleting(false)
}
}, [selected, query, t])
const handleDelete = useCallback(async () => {
const id = deleteTarget?.id
@@ -76,20 +132,20 @@ const UsersPage = memo(function UsersPage() {
setDeleteTarget(null)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除用户失败')
toast.error(err instanceof Error ? err.message : t('deleteFailed'))
} finally {
setDeleting(false)
}
}, [deleteTarget, query])
}, [deleteTarget, query, t])
// 非管理员:菜单不可见,直访时给明确提示
if (!isAdmin) {
return (
<PageContainer title="用户管理">
<PageContainer title={t('title')}>
<EmptyState
icon={UserRound}
title="需要管理员权限"
description="用户管理仅管理员可用,请联系管理员处理账号事宜"
title={t('adminOnly.title')}
description={t('adminOnly.desc')}
/>
</PageContainer>
)
@@ -97,19 +153,19 @@ const UsersPage = memo(function UsersPage() {
return (
<PageContainer
title="用户管理"
description="管理平台登录账号(仅管理员)"
title={t('title')}
description={t('subtitle')}
action={
<div className="flex items-center gap-2">
{ldapReady && (
<Button variant="outline" onClick={() => setLdapImportOpen(true)}>
<UsersRound className="w-4 h-4 mr-1" />
从 LDAP 导入
{t('ldapImport')}
</Button>
)}
<Button onClick={() => setFormOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
创建用户
{t('create')}
</Button>
</div>
}
@@ -119,16 +175,47 @@ const UsersPage = memo(function UsersPage() {
) : query.error ? (
<ErrorState message={query.error} onRetry={query.refresh} />
) : query.list.length === 0 ? (
<EmptyState icon={Users} title="暂无用户" description="创建第一个用户账号" />
<EmptyState icon={Users} title={t('emptyTitle')} description={t('emptyDesc')} />
) : (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CardTitle className="text-base flex items-center gap-2 w-full">
<Users className="w-4 h-4" />
用户列表(共 {query.total} 个)
{t('listTitle', { count: query.total })}
{selectable.length > 0 && (
<label className="ml-auto flex items-center gap-2 text-xs font-normal text-muted-foreground cursor-pointer">
<Checkbox
checked={allSelected}
onCheckedChange={toggleSelectAll}
aria-label={t('batch.selectAll')}
/>
{t('batch.selectAll')}
</label>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
{selected.size > 0 && (
<div className="flex items-center justify-between gap-2 mb-3 rounded-lg bg-muted/60 px-3 py-2">
<span className="text-sm text-muted-foreground">
{t('batch.selected', { count: selected.size })}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
{t('batch.clear')}
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => setBatchConfirm(true)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
{t('batch.delete')}
</Button>
</div>
</div>
)}
<div className="divide-y divide-border">
{query.list.map((user) => (
<div
@@ -136,6 +223,13 @@ const UsersPage = memo(function UsersPage() {
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3 min-w-0">
<Checkbox
checked={selected.has(user.id)}
onCheckedChange={() => toggleSelect(user.id)}
disabled={user.id === currentUser?.id}
aria-label={t('selectAria', { name: user.username })}
className="flex-shrink-0"
/>
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<UserRound className="w-4 h-4 text-muted-foreground" />
</div>
@@ -145,13 +239,13 @@ const UsersPage = memo(function UsersPage() {
<RoleBadge role={user.role} />
<SourceBadge source={user.authSource} />
{user.id === currentUser?.id && (
<span className="text-xs text-muted-foreground">(当前登录)</span>
<span className="text-xs text-muted-foreground">{t('currentUser')}</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{user.nickname || '未设置昵称'}
{user.nickname || t('noNickname')}
{user.createdAt &&
` · 创建于 ${new Date(user.createdAt).toLocaleDateString()}`}
` · ${t('createdAt', { date: new Date(user.createdAt).toLocaleDateString() })}`}
</p>
</div>
</div>
@@ -159,18 +253,16 @@ const UsersPage = memo(function UsersPage() {
{user.authSource !== 'ldap' && (
<Button variant="outline" size="sm" onClick={() => setResetTarget(user)}>
<KeyRound className="w-3.5 h-3.5 mr-1" />
重置密码
{t('resetPassword')}
</Button>
)}
<Button
variant="outline"
size="sm"
aria-label={`删除用户 ${user.username}`}
aria-label={t('deleteAria', { name: user.username })}
className="text-destructive hover:bg-destructive/10"
disabled={user.id === currentUser?.id}
title={
user.id === currentUser?.id ? '不能删除当前登录账号' : undefined
}
title={user.id === currentUser?.id ? t('cannotDeleteSelf') : undefined}
onClick={() => setDeleteTarget(user)}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -201,18 +293,27 @@ const UsersPage = memo(function UsersPage() {
user={resetTarget}
onClose={() => setResetTarget(null)}
onReset={() => {
toast.info(`已重置「${resetTarget?.username}」的密码,其所有会话已失效`)
toast.info(t('resetDone', { name: resetTarget?.username ?? '' }))
setResetTarget(null)
}}
/>
<ConfirmDialog
open={batchConfirm}
onOpenChange={setBatchConfirm}
title={t('batch.confirmTitle')}
description={t('batch.confirmDesc', { count: selected.size })}
loading={batchDeleting}
onConfirm={handleBatchDelete}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
name={deleteTarget?.username}
title="确认删除用户"
title={t('confirmDeleteTitle')}
onConfirm={handleDelete}
loading={deleting}
/>