337 lines
9.4 KiB
TypeScript
Raw Normal View History

2026-06-22 14:43:46 +08:00
'use client';
import { useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { AdminHeader } from '@/components/admin/AdminHeader';
import { PaginationTable, type Column } from '@/components/admin/PaginationTable';
import { TableToolbar } from '@/components/admin/TableToolbar';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ImageUpload } from '@/components/admin/ImageUpload';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { resolveUploadUrl } from '@/lib/utils';
import type { Team } from '@/lib/types';
interface FormState {
name: string;
position: string;
avatar: string;
desc: string;
sort: number;
isShow: number;
}
const DEFAULT: FormState = {
name: '',
position: '',
avatar: '',
desc: '',
sort: 0,
isShow: 1,
};
export default function TeamAdminPage() {
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 [form, setForm] = useState<FormState>(DEFAULT);
const [saving, setSaving] = useState(false);
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
const PAGE_SIZE = 10;
useEffect(() => setHydrated(true), []);
const { data, isLoading, mutate } = useSWR<Team[]>(
hydrated ? '/admin/team' : null,
() => adminApi.teamAll(),
);
// 防御性归一化:避免缓存/接口异常导致 data 不是数组
const list = Array.isArray(data) ? data : [];
// 客户端搜索过滤:按姓名或职位模糊匹配
const filtered = useMemo(() => {
const kw = searchKw.trim().toLowerCase();
if (!kw) return list;
return list.filter(
(m) =>
m.name.toLowerCase().includes(kw) ||
m.position.toLowerCase().includes(kw),
);
}, [list, searchKw]);
// 客户端分页
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pagedRows = useMemo(
() => filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE),
[filtered, safePage],
);
// 搜索/过滤变化时,若超出范围自动回到第 1 页
useEffect(() => {
if (page > totalPages) setPage(1);
}, [page, totalPages]);
const openCreate = () => {
setEditId(null);
setForm(DEFAULT);
setOpen(true);
};
const openEdit = async (id: number) => {
const d = await adminApi.teamDetail(id);
setEditId(id);
setForm({
name: d.name,
position: d.position,
avatar: d.avatar,
desc: d.desc ?? '',
sort: d.sort,
isShow: d.isShow,
});
setOpen(true);
};
const onSave = async () => {
if (!form.name || !form.position || !form.avatar) {
alert('请填写姓名、职位并上传头像');
return;
}
setSaving(true);
try {
if (editId) {
await adminApi.teamUpdate(editId, form);
} else {
await adminApi.teamCreate(form);
}
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.teamDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const columns: Column<Team>[] = [
{ key: 'id', title: 'ID', width: 60 },
{
key: 'avatar',
title: '头像',
width: 80,
render: (r) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveUploadUrl(r.avatar)}
alt={r.name}
className="h-10 w-10 rounded-full object-cover"
/>
),
},
{ key: 'name', title: '姓名', width: 120 },
{ key: 'position', title: '职位', width: 160 },
{
key: 'desc',
title: '简介',
render: (r) => (
<span className="line-clamp-2 max-w-md text-xs text-gray-600">
{r.desc ?? '-'}
</span>
),
},
{ key: 'sort', title: '排序', width: 80 },
{
key: 'isShow',
title: '状态',
width: 90,
render: (r) =>
r.isShow === 1 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline"></Badge>
),
},
{
key: '_op',
title: '操作',
width: 150,
render: (r) => (
<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>
{isSuperAdmin && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
),
},
];
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={
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
</Button>
}
/>
<PaginationTable<Team>
columns={columns}
rows={pagedRows}
total={total}
page={safePage}
pageSize={PAGE_SIZE}
loading={isLoading}
onPageChange={setPage}
rowKey={(r) => String(r.id)}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editId ? '编辑成员' : '新增成员'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.position}
onChange={(e) => setForm({ ...form, position: e.target.value })}
/>
</div>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<ImageUpload
value={form.avatar}
onChange={(url) => setForm({ ...form, avatar: url })}
hint="建议正方形头像,单图 ≤ 2M"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Textarea
rows={3}
value={form.desc}
onChange={(e) => setForm({ ...form, desc: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) =>
setForm({ ...form, sort: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.isShow}
onChange={(e) =>
setForm({ ...form, isShow: Number(e.target.value) })
}
>
<option value={1}></option>
<option value={0}></option>
</select>
</div>
</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>
</div>
);
}