import { useEffect, useState, useRef } from "react"; import { products, shops, generate } from "../api"; import { toast } from "../toast"; import * as XLSX from "xlsx"; const STATUS_MAP = { pending: "bg-gray-100 text-gray-600", generating: "bg-blue-100 text-blue-700", generated: "bg-purple-100 text-purple-700", }; const STATUS_LABEL = { pending: "待生成", generating: "生成中", generated: "已生成" }; const PAGE_SIZE = 10; function Modal({ open, onClose, title, children }) { if (!open) return null; return (
e.stopPropagation()}>

{title}

{children}
); } export default function Products() { const [list, setList] = useState([]); const [shopList, setShopList] = useState([]); const [filterShop, setFilterShop] = useState(""); const [loading, setLoading] = useState(true); const [modal, setModal] = useState(false); const [batchModal, setBatchModal] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState({ title: "", shop_id: "" }); const [batchText, setBatchText] = useState(""); const [batchShop, setBatchShop] = useState(""); const [saving, setSaving] = useState(false); const [selected, setSelected] = useState(new Set()); const [generating, setGenerating] = useState(false); const [generatingId, setGeneratingId] = useState(null); const imgFileRef = useRef(null); const [uploadingId, setUploadingId] = useState(null); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [jumpPage, setJumpPage] = useState(""); const fileRef = useRef(null); const load = () => { setLoading(true); Promise.all([products.list(filterShop || undefined), shops.list()]) .then(([p, s]) => { setList(p); setShopList(s); }) .catch(() => toast("加载失败", "error")) .finally(() => setLoading(false)); }; useEffect(load, [filterShop]); useEffect(() => { setPage(1); setSelected(new Set()); }, [filterShop, search]); const openCreate = () => { setEditing(null); setForm({ title: "", shop_id: filterShop || "" }); setModal(true); }; const openEdit = (p) => { setEditing(p); setForm({ title: p.title, shop_id: p.shop_id }); setModal(true); }; const save = async () => { if (!form.title.trim()) { toast("请填写商品标题", "error"); return; } if (!form.shop_id) { toast("请选择店铺", "error"); return; } setSaving(true); try { if (editing) { await products.update(editing.id, form); toast("更新成功", "success"); } else { await products.create(form); toast("创建成功", "success"); } setModal(false); load(); } catch (e) { toast(e.message, "error"); } finally { setSaving(false); } }; const remove = async (id) => { if (!window.confirm("确定删除该商品?")) return; try { await products.remove(id); toast("已删除", "success"); load(); } catch (e) { toast(e.message, "error"); } }; const batchDelete = async () => { if (selected.size === 0) { toast("请先选择要删除的商品", "error"); return; } if (!window.confirm(`确定删除选中的 ${selected.size} 个商品?关联的笔记将一并删除。`)) return; try { await products.batchDelete([...selected]); toast(`已删除 ${selected.size} 个商品`, "success"); setSelected(new Set()); load(); } catch (e) { toast(e.message, "error"); } }; const toggleSelect = (id) => { setSelected(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const toggleSelectAll = () => { if (selected.size === pagedList.length) setSelected(new Set()); else setSelected(new Set(pagedList.map(p => p.id))); }; const saveBatch = async () => { const titles = batchText.split("\n").map((l) => l.trim()).filter(Boolean); if (!titles.length) { toast("请输入至少一个商品标题", "error"); return; } if (!batchShop) { toast("请选择店铺", "error"); return; } setSaving(true); try { await products.batch({ shop_id: batchShop, titles }); toast(`已创建 ${titles.length} 个商品`, "success"); setBatchModal(false); setBatchText(""); load(); } catch (e) { toast(e.message, "error"); } finally { setSaving(false); } }; const handleExcelImport = (e) => { const file = e.target.files?.[0]; if (!file) return; if (!batchShop) { toast("请先在批量添加弹窗中选择店铺", "error"); e.target.value = ""; return; } const reader = new FileReader(); reader.onload = async (evt) => { try { const wb = XLSX.read(evt.target.result, { type: "array" }); const ws = wb.Sheets[wb.SheetNames[0]]; const rows = XLSX.utils.sheet_to_json(ws); if (rows.length === 0) { toast("Excel 文件为空", "error"); return; } const titles = rows.map(r => r["商品标题"] || r["title"] || r["标题"] || r["名称"] || "").filter(Boolean).map(String); if (titles.length === 0) { toast("未找到有效的商品标题列", "error"); return; } await products.batch({ shop_id: batchShop, titles }); toast(`Excel 导入完成:创建了 ${titles.length} 个商品`, "success"); setBatchModal(false); load(); } catch (err) { toast("Excel 解析失败: " + err.message, "error"); } }; reader.readAsArrayBuffer(file); e.target.value = ""; }; const handleExport = async () => { try { const data = await products.export(filterShop || undefined); const wb = XLSX.utils.book_new(); const ws = XLSX.utils.json_to_sheet(data.map(p => ({ "商品标题": p.title, "店铺": p.shop_name || "", "状态": STATUS_LABEL[p.status] || p.status, "创建时间": p.created_at || "", }))); XLSX.utils.book_append_sheet(wb, ws, "商品"); XLSX.writeFile(wb, "products.xlsx"); toast("导出成功", "success"); } catch (e) { toast(e.message, "error"); } }; const handleGenerate = async () => { if (selected.size === 0) { toast("请先选择要生成笔记的商品", "error"); return; } const productIds = [...selected]; const shopIds = [...new Set(list.filter(p => productIds.includes(p.id)).map(p => p.shop_id))]; if (shopIds.length > 1) { toast("请选择同一店铺的商品进行批量生成", "error"); return; } const shopId = shopIds[0]; if (!shopId) { toast("无法确定店铺", "error"); return; } setGenerating(true); try { const res = await generate.batch({ shop_id: shopId, product_ids: productIds }); toast(`批量生成完成:共 ${res.total || 0} 个商品`, "success"); load(); } catch (e) { toast(e.message, "error"); } finally { setGenerating(false); } }; const shopName = (id) => shopList.find((s) => s.id === id)?.shop_name || id; const handleGenerateOne = async (product) => { setGeneratingId(product.id); try { await generate.note({ product_id: product.id, shop_id: product.shop_id }); toast("笔记生成完成", "success"); load(); } catch (e) { toast(e.message, "error"); } finally { setGeneratingId(null); } }; const handleUploadImage = async (productId, file) => { if (!file) return; setUploadingId(productId); try { const base64 = await new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.readAsDataURL(file); }); await products.uploadImage(productId, { image: base64 }); toast("参考图上传成功", "success"); load(); } catch (e) { toast(e.message, "error"); } finally { setUploadingId(null); } }; const handleDeleteImage = async (productId) => { if (!window.confirm("确定删除该参考图?")) return; try { await products.deleteImage(productId); toast("参考图已删除", "success"); load(); } catch (e) { toast(e.message, "error"); } }; const filteredList = (() => { if (!search.trim()) return list; const q = search.trim().toLowerCase(); return list.filter((p) => (p.title || "").toLowerCase().includes(q)); })(); const totalPages = Math.max(1, Math.ceil(filteredList.length / PAGE_SIZE)); const pagedList = filteredList.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const handleJumpPage = () => { const p = parseInt(jumpPage, 10); if (isNaN(p) || p < 1 || p > totalPages) { toast(`请输入 1-${totalPages} 的页码`, "error"); return; } setPage(p); setJumpPage(""); }; return (

商品管理

setSearch(e.target.value)} />
{selected.size > 0 && (
已选择 {selected.size} 项
)} {loading ?

加载中...

: ( <>
{pagedList.length === 0 && ( )} {pagedList.map((p) => ( ))}
0 && selected.size === pagedList.length} onChange={toggleSelectAll} className="rounded" /> ID 店铺 商品标题 参考图 最近更新时间 操作
暂无数据
toggleSelect(p.id)} className="rounded" /> {p.id?.slice(0, 8)} {shopName(p.shop_id)} {p.title} {p.reference_image ? (
window.open(p.reference_image, '_blank')} />
) : ( )}
{p.updated_at?.slice(0, 19).replace("T", " ") || p.created_at?.slice(0, 19).replace("T", " ")}
{totalPages > 1 && (
共 {filteredList.length} 条
{page} / {totalPages} 跳至 setJumpPage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleJumpPage()} className="w-14 border rounded px-2 py-1 text-center text-sm" placeholder="#" />
)} )} setModal(false)} title={editing ? "编辑商品" : "新增商品"}>
setForm({ ...form, title: e.target.value })} />
setBatchModal(false)} title="批量添加商品">

方式一:上传 Excel 文件

支持 .xlsx / .xls / .csv,需包含「商品标题」列

方式二:文本粘贴

每行一个商品标题