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,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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user