520 lines
16 KiB
TypeScript
Raw Permalink Normal View History

2026-06-22 14:43:46 +08:00
'use client';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { Plus, Pencil, Trash2, Tags, 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 { RichEditor } from '@/components/admin/RichEditor';
import { MarkdownImport } from '@/components/admin/MarkdownImport';
import { adminApi } from '@/lib/admin-services';
import { useAdminStore } from '@/store/adminStore';
import { cn, formatDate, resolveUploadUrl } from '@/lib/utils';
import { isHTMLContent } from '@/lib/content';
import type { News, NewsCategory } from '@/lib/types';
type ContentMode = 'rich' | 'markdown';
interface FormState {
categoryId: number;
title: string;
cover: string;
intro: string;
content: string;
isTop: number;
status: number;
}
export default function NewsAdminPage() {
const [hydrated, setHydrated] = useState(false);
const [page, setPage] = useState(1);
const [categoryId, setCategoryId] = useState<number | undefined>();
const [keyword, setKeyword] = useState('');
const [searchKw, setSearchKw] = useState('');
const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [contentMode, setContentMode] = useState<ContentMode>('rich');
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
const [catOpen, setCatOpen] = useState(false);
const [catName, setCatName] = useState('');
const [catSort, setCatSort] = useState(0);
const [form, setForm] = useState<FormState>({
categoryId: 0,
title: '',
cover: '',
intro: '',
content: '',
isTop: 0,
status: 1,
});
useEffect(() => setHydrated(true), []);
const { data: categories, mutate: mutateCats } = useSWR<NewsCategory[]>(
hydrated ? '/admin/news-category' : null,
() => adminApi.newsCategoryAll(),
);
const { data, isLoading, mutate } = useSWR(
hydrated ? ['/admin/news', page, categoryId, searchKw] : null,
() =>
adminApi.newsPaginate({
page,
pageSize: 10,
categoryId,
keyword: searchKw || undefined,
}),
);
const openCreate = () => {
if (!categories || categories.length === 0) {
alert('请先创建新闻分类');
setCatOpen(true);
return;
}
setEditId(null);
setContentMode('rich');
setForm({
categoryId: categories[0].id,
title: '',
cover: '',
intro: '',
content: '',
isTop: 0,
status: 1,
});
setOpen(true);
};
const openEdit = async (id: number) => {
const detail = await adminApi.newsDetail(id);
setEditId(id);
setContentMode(isHTMLContent(detail.content) ? 'rich' : 'markdown');
setForm({
categoryId: detail.categoryId,
title: detail.title,
cover: detail.cover,
intro: detail.intro,
content: detail.content,
isTop: detail.isTop,
status: detail.status,
});
setOpen(true);
};
const onSave = async () => {
if (!form.title || !form.content) {
alert('请填写标题和正文');
return;
}
setSaving(true);
try {
const payload = { ...form, categoryId: Number(form.categoryId) };
if (editId) {
await adminApi.newsUpdate(editId, payload);
} else {
await adminApi.newsCreate(payload);
}
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.newsDelete(id);
await mutate();
} catch (e) {
alert((e as Error).message);
}
};
const onAddCategory = async () => {
if (!catName.trim()) {
alert('请输入分类名称');
return;
}
try {
await adminApi.newsCategoryCreate({
name: catName,
sort: catSort,
isShow: 1,
});
setCatName('');
setCatSort(0);
await mutateCats();
} catch (e) {
alert((e as Error).message);
}
};
const onDeleteCategory = async (id: number) => {
if (!confirm('确认删除该分类?')) return;
try {
await adminApi.newsCategoryDelete(id);
await mutateCats();
} catch (e) {
alert((e as Error).message);
}
};
const columns: Column<News>[] = [
{ key: 'id', title: 'ID', width: 60 },
{
key: 'cover',
title: '封面',
width: 100,
render: (r) =>
r.cover ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveUploadUrl(r.cover)}
alt={r.title}
className="h-12 w-16 rounded object-cover"
/>
) : (
<span className="text-xs text-gray-400"></span>
),
},
{ key: 'title', title: '标题' },
{
key: 'category',
title: '分类',
width: 100,
render: (r) => r.category?.name ?? '-',
},
{
key: 'createdAt',
title: '发布时间',
width: 160,
render: (r) => formatDate(r.createdAt, 'YYYY-MM-DD HH:mm'),
},
{
key: 'isTop',
title: '置顶',
width: 80,
render: (r) =>
r.isTop === 1 ? <Badge variant="warning"></Badge> : '-',
},
{
key: 'status',
title: '状态',
width: 80,
render: (r) =>
r.status === 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={
<>
<select
className="h-10 rounded-md border border-gray-300 px-3 text-sm"
value={categoryId ?? ''}
onChange={(e) => {
setCategoryId(e.target.value ? Number(e.target.value) : undefined);
setPage(1);
}}
>
<option value=""></option>
{(categories ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</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={() => setCatOpen(true)}>
<Tags className="h-4 w-4" />
</Button>
<Button onClick={openCreate}>
<Plus className="h-4 w-4" />
</Button>
</>
}
/>
<PaginationTable<News>
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-3xl">
<DialogHeader>
<DialogTitle>{editId ? '编辑新闻' : '新增新闻'}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-2">
<div className="grid grid-cols-2 gap-3">
<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.categoryId}
onChange={(e) =>
setForm({ ...form, categoryId: Number(e.target.value) })
}
>
{(categories ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
/>
</div>
</div>
<div className="space-y-1.5">
<Label></Label>
<ImageUpload
value={form.cover}
onChange={(url) => setForm({ ...form, cover: url })}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Textarea
rows={2}
value={form.intro}
onChange={(e) => setForm({ ...form, intro: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label> *</Label>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-gray-200 p-0.5">
<button
type="button"
onClick={() => setContentMode('rich')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'rich'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
</button>
<button
type="button"
onClick={() => setContentMode('markdown')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'markdown'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
Markdown
</button>
</div>
{contentMode === 'markdown' && (
<MarkdownImport
onImport={(md) => setForm({ ...form, content: md })}
onError={(msg) => alert(msg)}
/>
)}
</div>
</div>
{contentMode === 'rich' ? (
<RichEditor
value={form.content}
onChange={(html) => setForm({ ...form, content: html })}
/>
) : (
<Textarea
rows={12}
placeholder="支持 Markdown 语法。可点击右上角上传 .md 文档"
className="font-mono text-sm"
value={form.content}
onChange={(e) =>
setForm({ ...form, content: e.target.value })
}
/>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<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.isTop}
onChange={(e) =>
setForm({ ...form, isTop: Number(e.target.value) })
}
>
<option value={0}></option>
<option value={1}></option>
</select>
</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.status}
onChange={(e) =>
setForm({ ...form, status: 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>
<Dialog open={catOpen} onOpenChange={setCatOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder="新分类名称"
value={catName}
onChange={(e) => setCatName(e.target.value)}
/>
<Input
type="number"
placeholder="排序"
value={catSort}
onChange={(e) => setCatSort(Number(e.target.value))}
className="w-24"
/>
<Button onClick={onAddCategory}></Button>
</div>
<div className="max-h-80 overflow-y-auto">
{(categories ?? []).map((c) => (
<div
key={c.id}
className="flex items-center justify-between border-b border-gray-100 py-2"
>
<span className="text-sm text-gray-700">{c.name}</span>
<Button
size="sm"
variant="ghost"
onClick={() => onDeleteCategory(c.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{(categories ?? []).length === 0 && (
<p className="py-4 text-center text-sm text-gray-400">
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}