209 lines
5.8 KiB
TypeScript
209 lines
5.8 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import useSWR from 'swr';
|
||
|
|
import { CheckCircle2, CheckCheck, Loader2, Trash2 } 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 { Badge } from '@/components/ui/badge';
|
||
|
|
import { adminApi } from '@/lib/admin-services';
|
||
|
|
import { useAdminStore } from '@/store/adminStore';
|
||
|
|
import { formatDate } from '@/lib/utils';
|
||
|
|
import type { Message } from '@/lib/types';
|
||
|
|
|
||
|
|
export default function MessageAdminPage() {
|
||
|
|
const [hydrated, setHydrated] = useState(false);
|
||
|
|
const [page, setPage] = useState(1);
|
||
|
|
const [isRead, setIsRead] = useState<number | undefined>();
|
||
|
|
const [keyword, setKeyword] = useState('');
|
||
|
|
const [searchKw, setSearchKw] = useState('');
|
||
|
|
const [markingAll, setMarkingAll] = useState(false);
|
||
|
|
|
||
|
|
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
|
||
|
|
|
||
|
|
useEffect(() => setHydrated(true), []);
|
||
|
|
|
||
|
|
const { data, isLoading, mutate } = useSWR(
|
||
|
|
hydrated ? ['/admin/message', page, isRead, searchKw] : null,
|
||
|
|
() =>
|
||
|
|
adminApi.messagePaginate({
|
||
|
|
page,
|
||
|
|
pageSize: 10,
|
||
|
|
isRead,
|
||
|
|
keyword: searchKw || undefined,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
const onMarkRead = async (id: number) => {
|
||
|
|
try {
|
||
|
|
await adminApi.messageMarkRead(id);
|
||
|
|
await mutate();
|
||
|
|
} catch (e) {
|
||
|
|
alert((e as Error).message);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const onDelete = async (id: number) => {
|
||
|
|
if (!confirm('确认删除该留言?')) return;
|
||
|
|
try {
|
||
|
|
await adminApi.messageDelete(id);
|
||
|
|
await mutate();
|
||
|
|
} catch (e) {
|
||
|
|
alert((e as Error).message);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const onMarkAllRead = async () => {
|
||
|
|
setMarkingAll(true);
|
||
|
|
try {
|
||
|
|
// 拉取全部未读(跨页)
|
||
|
|
const unread = await adminApi.messagePaginate({
|
||
|
|
isRead: 0,
|
||
|
|
page: 1,
|
||
|
|
pageSize: 999,
|
||
|
|
});
|
||
|
|
// 串行标记,单个失败即中断
|
||
|
|
for (const m of unread.list) {
|
||
|
|
await adminApi.messageMarkRead(m.id);
|
||
|
|
}
|
||
|
|
await mutate();
|
||
|
|
} catch (e) {
|
||
|
|
alert((e as Error).message);
|
||
|
|
} finally {
|
||
|
|
setMarkingAll(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const hasUnread = (data?.list ?? []).some((m) => m.isRead === 0);
|
||
|
|
|
||
|
|
const columns: Column<Message>[] = [
|
||
|
|
{ key: 'id', title: 'ID', width: 60 },
|
||
|
|
{ key: 'name', title: '姓名', width: 120 },
|
||
|
|
{ key: 'phone', title: '电话', width: 140 },
|
||
|
|
{ key: 'email', title: '邮箱', width: 180 },
|
||
|
|
{
|
||
|
|
key: 'content',
|
||
|
|
title: '留言内容',
|
||
|
|
render: (r) => (
|
||
|
|
<span className="line-clamp-2 max-w-md text-xs text-gray-700">
|
||
|
|
{r.content}
|
||
|
|
</span>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'createdAt',
|
||
|
|
title: '提交时间',
|
||
|
|
width: 160,
|
||
|
|
render: (r) => formatDate(r.createdAt, 'YYYY-MM-DD HH:mm'),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'isRead',
|
||
|
|
title: '状态',
|
||
|
|
width: 90,
|
||
|
|
render: (r) =>
|
||
|
|
r.isRead === 1 ? (
|
||
|
|
<Badge variant="outline">已读</Badge>
|
||
|
|
) : (
|
||
|
|
<Badge variant="warning">未读</Badge>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: '_op',
|
||
|
|
title: '操作',
|
||
|
|
width: 150,
|
||
|
|
render: (r) => (
|
||
|
|
<div className="flex items-center gap-1.5 whitespace-nowrap">
|
||
|
|
{r.isRead === 0 && (
|
||
|
|
<Button size="sm" variant="outline" onClick={() => onMarkRead(r.id)}>
|
||
|
|
<CheckCircle2 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={
|
||
|
|
<>
|
||
|
|
<select
|
||
|
|
className="h-10 rounded-md border border-gray-300 px-3 text-sm"
|
||
|
|
value={isRead ?? ''}
|
||
|
|
onChange={(e) => {
|
||
|
|
setIsRead(e.target.value === '' ? undefined : Number(e.target.value));
|
||
|
|
setPage(1);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<option value="">全部状态</option>
|
||
|
|
<option value="0">未读</option>
|
||
|
|
<option value="1">已读</option>
|
||
|
|
</select>
|
||
|
|
<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
|
||
|
|
variant="outline"
|
||
|
|
onClick={onMarkAllRead}
|
||
|
|
disabled={!hasUnread || markingAll}
|
||
|
|
>
|
||
|
|
{markingAll ? (
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<CheckCheck className="h-4 w-4" />
|
||
|
|
)}
|
||
|
|
全部标记已读
|
||
|
|
</Button>
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<PaginationTable<Message>
|
||
|
|
columns={columns}
|
||
|
|
rows={data?.list ?? []}
|
||
|
|
total={data?.total ?? 0}
|
||
|
|
page={page}
|
||
|
|
pageSize={10}
|
||
|
|
loading={isLoading}
|
||
|
|
onPageChange={setPage}
|
||
|
|
rowKey={(r) => String(r.id)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|