337 lines
9.4 KiB
TypeScript
337 lines
9.4 KiB
TypeScript
'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>
|
|
);
|
|
}
|