517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
|
|
'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, resolveUploadUrl } from '@/lib/utils';
|
||
|
|
import { isHTMLContent } from '@/lib/content';
|
||
|
|
import type { Product, ProductCategory } from '@/lib/types';
|
||
|
|
|
||
|
|
type ContentMode = 'rich' | 'markdown';
|
||
|
|
|
||
|
|
interface FormState {
|
||
|
|
categoryId: number;
|
||
|
|
name: string;
|
||
|
|
cover: string;
|
||
|
|
desc: string;
|
||
|
|
content: string;
|
||
|
|
sort: number;
|
||
|
|
isShow: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function ProductAdminPage() {
|
||
|
|
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 [form, setForm] = useState<FormState>({
|
||
|
|
categoryId: 0,
|
||
|
|
name: '',
|
||
|
|
cover: '',
|
||
|
|
desc: '',
|
||
|
|
content: '',
|
||
|
|
sort: 0,
|
||
|
|
isShow: 1,
|
||
|
|
});
|
||
|
|
|
||
|
|
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
|
||
|
|
|
||
|
|
// 分类管理弹窗
|
||
|
|
const [catOpen, setCatOpen] = useState(false);
|
||
|
|
const [catName, setCatName] = useState('');
|
||
|
|
const [catSort, setCatSort] = useState(0);
|
||
|
|
|
||
|
|
useEffect(() => setHydrated(true), []);
|
||
|
|
|
||
|
|
const { data: categories, mutate: mutateCats } = useSWR<ProductCategory[]>(
|
||
|
|
hydrated ? '/admin/product-category' : null,
|
||
|
|
() => adminApi.productCategoryAll(),
|
||
|
|
);
|
||
|
|
|
||
|
|
const { data, isLoading, mutate } = useSWR(
|
||
|
|
hydrated ? ['/admin/product', page, categoryId, searchKw] : null,
|
||
|
|
() =>
|
||
|
|
adminApi.productPaginate({
|
||
|
|
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,
|
||
|
|
name: '',
|
||
|
|
cover: '',
|
||
|
|
desc: '',
|
||
|
|
content: '',
|
||
|
|
sort: 0,
|
||
|
|
isShow: 1,
|
||
|
|
});
|
||
|
|
setOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const openEdit = async (id: number) => {
|
||
|
|
const detail = await adminApi.productDetail(id);
|
||
|
|
setEditId(id);
|
||
|
|
setContentMode(isHTMLContent(detail.content) ? 'rich' : 'markdown');
|
||
|
|
setForm({
|
||
|
|
categoryId: detail.categoryId,
|
||
|
|
name: detail.name,
|
||
|
|
cover: detail.cover,
|
||
|
|
desc: detail.desc ?? '',
|
||
|
|
content: detail.content ?? '',
|
||
|
|
sort: detail.sort,
|
||
|
|
isShow: detail.isShow,
|
||
|
|
});
|
||
|
|
setOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const onSave = async () => {
|
||
|
|
if (!form.categoryId) {
|
||
|
|
alert('请选择分类');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!form.name || !form.cover) {
|
||
|
|
alert('请填写名称并上传封面');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setSaving(true);
|
||
|
|
try {
|
||
|
|
const payload = { ...form, categoryId: Number(form.categoryId) };
|
||
|
|
if (editId) {
|
||
|
|
await adminApi.productUpdate(editId, payload);
|
||
|
|
} else {
|
||
|
|
await adminApi.productCreate(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.productDelete(id);
|
||
|
|
await mutate();
|
||
|
|
} catch (e) {
|
||
|
|
alert((e as Error).message);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 分类 CRUD
|
||
|
|
const onAddCategory = async () => {
|
||
|
|
if (!catName.trim()) {
|
||
|
|
alert('请输入分类名称');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await adminApi.productCategoryCreate({
|
||
|
|
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.productCategoryDelete(id);
|
||
|
|
await mutateCats();
|
||
|
|
} catch (e) {
|
||
|
|
alert((e as Error).message);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const columns: Column<Product>[] = [
|
||
|
|
{ key: 'id', title: 'ID', width: 60 },
|
||
|
|
{
|
||
|
|
key: 'cover',
|
||
|
|
title: '封面',
|
||
|
|
width: 100,
|
||
|
|
render: (r) => (
|
||
|
|
// eslint-disable-next-line @next/next/no-img-element
|
||
|
|
<img
|
||
|
|
src={resolveUploadUrl(r.cover)}
|
||
|
|
alt={r.name}
|
||
|
|
className="h-12 w-16 rounded object-cover"
|
||
|
|
/>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{ key: 'name', title: '产品名称' },
|
||
|
|
{
|
||
|
|
key: 'category',
|
||
|
|
title: '分类',
|
||
|
|
width: 120,
|
||
|
|
render: (r) => r.category?.name ?? '-',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'desc',
|
||
|
|
title: '描述',
|
||
|
|
render: (r) => (
|
||
|
|
<span className="line-clamp-1 max-w-md text-xs text-gray-600">
|
||
|
|
{r.desc ?? '-'}
|
||
|
|
</span>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'isShow',
|
||
|
|
title: '状态',
|
||
|
|
width: 80,
|
||
|
|
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={
|
||
|
|
<>
|
||
|
|
<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<Product>
|
||
|
|
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.name}
|
||
|
|
onChange={(e) => setForm({ ...form, name: 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={3}
|
||
|
|
value={form.desc}
|
||
|
|
onChange={(e) => setForm({ ...form, desc: 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>
|
||
|
|
<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>
|
||
|
|
|
||
|
|
{/* 分类管理 */}
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
}
|