2026-06-22 14:43:46 +08:00

569 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import {
Plus,
Pencil,
Trash2,
KeyRound,
Loader2,
ShieldCheck,
Crown,
} from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { PaginationTable, type Column } from '@/components/admin/PaginationTable';
import { TableToolbar } from '@/components/admin/TableToolbar';
import { Avatar } from '@/components/admin/Avatar';
import { ImageUpload } from '@/components/admin/ImageUpload';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { formatDate } from '@/lib/utils';
import type { AdminRole, AdminUser } from '@/lib/types';
interface CreateForm {
username: string;
password: string;
nickname: string;
avatar: string;
role: AdminRole;
}
interface EditForm {
nickname: string;
avatar: string;
role: AdminRole;
}
const CREATE_DEFAULT: CreateForm = {
username: '',
password: '',
nickname: '',
avatar: '',
role: 'normal',
};
const ROLE_LABEL: Record<AdminRole, string> = {
super_admin: '超级管理员',
normal: '普通管理员',
};
export default function AdminUserPage() {
const [hydrated, setHydrated] = useState(false);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
// 创建/编辑
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [createForm, setCreateForm] = useState<CreateForm>(CREATE_DEFAULT);
const [editForm, setEditForm] = useState<EditForm>({
nickname: '',
avatar: '',
role: 'normal',
});
const [saving, setSaving] = useState(false);
// 重置密码
const [pwdOpen, setPwdOpen] = useState(false);
const [pwdId, setPwdId] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState('');
const [pwdConfirm, setPwdConfirm] = useState('');
const [pwdSaving, setPwdSaving] = useState(false);
const currentAdminId = useAdminStore((s) => s.admin?.id);
const currentAdminRole = useAdminStore((s) => s.admin?.role);
const isSuperAdmin = currentAdminRole === 'super_admin';
useEffect(() => setHydrated(true), []);
const { data, isLoading, mutate } = useSWR(
hydrated ? ['/admin/admin-user', page, searchKw] : null,
() =>
adminApi.adminUserPaginate({
page,
pageSize: 10,
keyword: searchKw || undefined,
}),
);
const openCreate = () => {
setEditId(null);
setCreateForm(CREATE_DEFAULT);
setOpen(true);
};
const openEdit = async (id: number) => {
const detail = await adminApi.adminUserDetail(id);
setEditId(id);
setEditForm({
nickname: detail.nickname,
avatar: detail.avatar ?? '',
role: detail.role ?? 'normal',
});
setOpen(true);
};
const onSave = async () => {
if (editId) {
if (!editForm.nickname.trim()) {
alert('请填写名称');
return;
}
setSaving(true);
try {
await adminApi.adminUserUpdate(editId, {
nickname: editForm.nickname.trim(),
avatar: editForm.avatar,
role: editForm.role,
});
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
return;
}
// create
if (!createForm.username.trim() || !createForm.password || !createForm.nickname.trim()) {
alert('请填写登录账号、初始密码和名称');
return;
}
setSaving(true);
try {
await adminApi.adminUserCreate({
username: createForm.username.trim(),
password: createForm.password,
nickname: createForm.nickname.trim(),
avatar: createForm.avatar,
role: createForm.role,
});
setOpen(false);
await mutate();
} catch (e) {
alert((e as Error).message);
} finally {
setSaving(false);
}
};
const onDelete = async (id: number) => {
if (!confirm('确认删除该管理员账号?此操作不可恢复。')) return;
try {
await adminApi.adminUserDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const openResetPassword = (id: number) => {
setPwdId(id);
setNewPassword('');
setPwdConfirm('');
setPwdOpen(true);
};
const onResetPassword = async () => {
if (!pwdId) return;
if (newPassword.length < 6) {
alert('新密码至少 6 位');
return;
}
if (newPassword !== pwdConfirm) {
alert('两次输入的密码不一致');
return;
}
setPwdSaving(true);
try {
await adminApi.adminUserResetPassword(pwdId, newPassword);
setPwdOpen(false);
} catch (e) {
alert((e as Error).message);
} finally {
setPwdSaving(false);
}
};
const columns: Column<AdminUser>[] = [
{ key: 'id', title: 'ID', width: 60 },
{
key: 'avatar',
title: '头像',
width: 80,
render: (r) => <Avatar src={r.avatar} name={r.nickname} size={36} />,
},
{
key: 'username',
title: '登录账号',
width: 200,
render: (r) => (
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{r.username}</span>
{r.id === currentAdminId && (
<Badge variant="success">
<ShieldCheck className="h-3 w-3" />
</Badge>
)}
</div>
),
},
{ key: 'nickname', title: '名称', width: 140 },
{
key: 'role',
title: '角色',
width: 130,
render: (r) =>
r.role === 'super_admin' ? (
<Badge variant="warning">
<Crown className="h-3 w-3" /> {ROLE_LABEL[r.role]}
</Badge>
) : (
<Badge variant="secondary">{ROLE_LABEL[r.role]}</Badge>
),
},
{
key: 'createdAt',
title: '创建时间',
width: 160,
render: (r) => formatDate(r.createdAt, 'YYYY-MM-DD HH:mm'),
},
{
key: '_op',
title: '操作',
width: 240,
render: (r) =>
isSuperAdmin ? (
<div className="flex items-center gap-1.5 whitespace-nowrap">
<Button size="sm" variant="outline" onClick={() => openEdit(r.id)}>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openResetPassword(r.id)}
>
<KeyRound className="h-3 w-3" />
</Button>
{r.id !== currentAdminId && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
) : (
<span className="text-xs text-gray-400"></span>
),
},
];
return (
<div>
<AdminHeader
title="管理员账号"
description="维护可登录后台的员工账号(支持手机号/邮箱/自定义账号)"
/>
<TableToolbar
left={
<>
<Input
placeholder="搜索 登录账号/名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchKw(keyword);
setPage(1);
}
}}
className="max-w-xs"
/>
<Button
variant="outline"
onClick={() => {
setSearchKw(keyword);
setPage(1);
}}
>
</Button>
</>
}
right={
isSuperAdmin ? (
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
</Button>
) : null
}
/>
<PaginationTable<AdminUser>
columns={columns}
rows={data?.list ?? []}
total={data?.total ?? 0}
page={page}
pageSize={10}
loading={isLoading}
onPageChange={setPage}
rowKey={(r) => String(r.id)}
/>
{/* 创建 / 编辑弹窗 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{editId ? '编辑管理员账号' : '新增管理员账号'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{editId ? (
<>
<div className="space-y-1.5">
<Label></Label>
<Input
value={
data?.list.find((x) => x.id === editId)?.username ?? ''
}
disabled
/>
<p className="text-xs text-gray-500">
</p>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={editForm.nickname}
onChange={(e) =>
setEditForm({ ...editForm, nickname: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<ImageUpload
value={editForm.avatar}
onChange={(url) =>
setEditForm({ ...editForm, avatar: url })
}
hint="建议正方形头像,单图 ≤ 2M"
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="edit-role"
value="normal"
checked={editForm.role === 'normal'}
onChange={() =>
setEditForm({ ...editForm, role: 'normal' })
}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="edit-role"
value="super_admin"
checked={editForm.role === 'super_admin'}
onChange={() =>
setEditForm({ ...editForm, role: 'super_admin' })
}
/>
</label>
</div>
{editId === currentAdminId && (
<p className="text-xs text-amber-600">
</p>
)}
</div>
</>
) : (
<>
<div className="space-y-1.5">
<Label> *</Label>
<Input
placeholder="可使用手机号、邮箱或自定义账号"
value={createForm.username}
maxLength={50}
onChange={(e) =>
setCreateForm({
...createForm,
username: e.target.value,
})
}
/>
<p className="text-xs text-gray-500">
2-50
</p>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
type="password"
placeholder="至少 6 位"
value={createForm.password}
onChange={(e) =>
setCreateForm({
...createForm,
password: e.target.value,
})
}
/>
<p className="text-xs text-gray-500">
</p>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
placeholder="员工姓名或别名"
value={createForm.nickname}
maxLength={50}
onChange={(e) =>
setCreateForm({
...createForm,
nickname: e.target.value,
})
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<ImageUpload
value={createForm.avatar}
onChange={(url) =>
setCreateForm({ ...createForm, avatar: url })
}
hint="可选,建议正方形头像,单图 ≤ 2M"
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="create-role"
value="normal"
checked={createForm.role === 'normal'}
onChange={() =>
setCreateForm({ ...createForm, role: 'normal' })
}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="create-role"
value="super_admin"
checked={createForm.role === 'super_admin'}
onChange={() =>
setCreateForm({ ...createForm, role: 'super_admin' })
}
/>
</label>
</div>
<p className="text-xs text-gray-500">
</p>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 重置密码弹窗 */}
<Dialog open={pwdOpen} onOpenChange={setPwdOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label> *</Label>
<Input
type="password"
placeholder="至少 6 位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
type="password"
placeholder="再次输入新密码"
value={pwdConfirm}
onChange={(e) => setPwdConfirm(e.target.value)}
/>
</div>
<p className="text-xs text-gray-500">
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPwdOpen(false)}>
</Button>
<Button onClick={onResetPassword} disabled={pwdSaving}>
{pwdSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'确认重置'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}