xiaohongshufabu/web/src/pages/Products.jsx
2026-07-22 10:07:46 +08:00

418 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold mb-4">{title}</h3>
{children}
<div className="flex justify-end gap-2 mt-5">
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg border border-gray-300 hover:bg-gray-100">取消</button>
</div>
</div>
</div>
);
}
export default function Products() {
const [list, setList] = useState([]);
const [shopList, setShopList] = useState([]);
const [jobs, setJobs] = useState([]);
const [filterShop, setFilterShop] = useState("");
const [loading, setLoading] = useState(true);
const [jobLoading, setJobLoading] = useState(false);
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 [generateCount, setGenerateCount] = useState(1);
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));
};
const loadJobs = () => {
setJobLoading(true);
generate.jobs(filterShop || undefined)
.then(setJobs)
.catch(() => setJobs([]))
.finally(() => setJobLoading(false));
};
useEffect(load, [filterShop]);
useEffect(loadJobs, [filterShop]);
useEffect(() => {
const timer = setInterval(loadJobs, 3000);
return () => clearInterval(timer);
}, [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, count_per_product: generateCount });
toast(`任务已提交:共 ${res.total || 0} 个商品,${res.total_notes || 0} 条笔记`, "success");
loadJobs();
} 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, count_per_product: generateCount });
toast(`任务已提交:${generateCount} 条笔记`, "success");
loadJobs();
} catch (e) { toast(e.message, "error"); }
finally { setGeneratingId(null); }
};
const progressOf = (job) => {
const total = job.total_notes || 0;
if (!total) return 0;
return Math.min(100, Math.round(((job.completed_notes || 0) / total) * 100));
};
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 (
<div>
<h1 className="text-xl font-bold text-gray-800 mb-4">商品管理</h1>
<div className="bg-white rounded-xl shadow p-4 mb-4">
<div className="flex flex-wrap items-center gap-3">
<select className="border rounded-lg px-3 py-2 text-sm" value={filterShop} onChange={(e) => setFilterShop(e.target.value)}>
<option value="">全部店铺</option>
{shopList.map((s) => <option key={s.id} value={s.id}>{s.shop_name}</option>)}
</select>
<input className="border rounded-lg px-3 py-2 text-sm w-48" placeholder="输入商品标题关键词" value={search} onChange={(e) => setSearch(e.target.value)} />
<button onClick={() => {}} className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600">搜索</button>
<button onClick={openCreate} className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700">新增</button>
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>每商品生成</span>
<input type="number" min="1" className="w-20 border rounded-lg px-2 py-2 text-sm" value={generateCount} onChange={(e) => setGenerateCount(Math.max(1, parseInt(e.target.value || '1', 10) || 1))} />
<span></span>
</div>
<button onClick={handleGenerate} disabled={selected.size === 0 || generating} className="px-4 py-2 bg-green-500 text-white text-sm rounded-lg hover:bg-green-600 disabled:opacity-40">{generating ? "生成中..." : "批量生成笔记"}</button>
<button onClick={() => { if (!filterShop) { toast("请先选择店铺", "error"); return; } setBatchShop(filterShop); setBatchModal(true); }} className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700">+ 导入 Excel</button>
<button onClick={batchDelete} disabled={selected.size === 0} className="px-3 py-2 bg-red-500 text-white text-sm rounded-lg hover:bg-red-600 disabled:opacity-40">批量删除</button>
</div>
</div>
<div className="bg-white rounded-xl shadow p-4 mb-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700">生成任务</h2>
<span className="text-xs text-gray-400">{jobLoading ? "刷新中..." : "自动刷新"}</span>
</div>
{jobs.length === 0 ? (
<div className="text-sm text-gray-400">暂无生成任务</div>
) : (
<div className="space-y-3">
{jobs.map((job) => (
<div key={job.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium text-gray-700 truncate">{job.current_product_title || "任务"}</span>
<span className="text-gray-500">{job.status}</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500" style={{ width: `${progressOf(job)}%` }} />
</div>
<div className="mt-2 text-xs text-gray-500 flex justify-between">
<span>{job.completed_notes || 0}/{job.total_notes || 0}</span>
<span>{job.message || ""}</span>
</div>
</div>
))}
</div>
)}
</div>
{selected.size > 0 && (
<div className="mb-3 flex items-center gap-3 bg-blue-50 rounded-lg px-4 py-2 text-sm">
<span className="text-blue-700">已选择 {selected.size} </span>
<button onClick={() => setSelected(new Set())} className="px-3 py-1 text-gray-500 text-xs hover:text-gray-700">取消选择</button>
</div>
)}
{loading ? <p className="text-gray-400">加载中...</p> : (
<>
<div className="bg-white rounded-xl shadow overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-4 py-3 w-10">
<input type="checkbox" checked={pagedList.length > 0 && selected.size === pagedList.length} onChange={toggleSelectAll} className="rounded" />
</th>
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">店铺</th>
<th className="px-4 py-3">商品标题</th>
<th className="px-4 py-3">参考图</th>
<th className="px-4 py-3">最近更新时间</th>
<th className="px-4 py-3 text-right">操作</th>
</tr>
</thead>
<tbody>
{pagedList.length === 0 && (
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-400">暂无数据</td></tr>
)}
{pagedList.map((p) => (
<tr key={p.id} className={`border-t border-gray-100 hover:bg-gray-50 ${selected.has(p.id) ? "bg-blue-50" : ""}`}>
<td className="px-4 py-3"><input type="checkbox" checked={selected.has(p.id)} onChange={() => toggleSelect(p.id)} className="rounded" /></td>
<td className="px-4 py-3 font-mono text-xs">{p.id?.slice(0, 8)}</td>
<td className="px-4 py-3 text-gray-500">{shopName(p.shop_id)}</td>
<td className="px-4 py-3 font-medium max-w-[300px] truncate">{p.title}</td>
<td className="px-4 py-3">
{p.reference_image ? (
<div className="flex items-center gap-2">
<img src={p.reference_image} alt="" className="w-10 h-10 object-cover rounded cursor-pointer" onClick={() => window.open(p.reference_image, '_blank')} />
<div className="flex flex-col text-xs">
<label className="text-blue-500 hover:text-blue-700 cursor-pointer">
{uploadingId === p.id ? "上传中..." : "替换"}
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleUploadImage(p.id, e.target.files?.[0])} />
</label>
<button onClick={() => handleDeleteImage(p.id)} className="text-red-500 hover:text-red-700 text-left">删除</button>
</div>
</div>
) : (
<label className="text-xs text-blue-500 hover:text-blue-700 cursor-pointer">
{uploadingId === p.id ? "上传中..." : "上传参考图"}
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleUploadImage(p.id, e.target.files?.[0])} />
</label>
)}
</td>
<td className="px-4 py-3 text-gray-400 text-xs whitespace-nowrap">{p.updated_at?.slice(0, 19).replace("T", " ") || p.created_at?.slice(0, 19).replace("T", " ")}</td>
<td className="px-4 py-3 text-right space-x-2">
<button onClick={() => openEdit(p)} className="text-blue-600 hover:underline">修改</button>
<button onClick={() => handleGenerateOne(p)} disabled={generatingId === p.id} className="text-green-600 hover:underline disabled:opacity-40">{generatingId === p.id ? "生成中..." : "生成笔记"}</button>
<button onClick={() => remove(p.id)} className="text-red-500 hover:underline">删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm text-gray-500">
<span> {filteredList.length} </span>
<div className="flex items-center gap-2">
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)} className="px-3 py-1 border rounded disabled:opacity-40">上一页</button>
<span>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)} className="px-3 py-1 border rounded disabled:opacity-40">下一页</button>
<span className="ml-2 text-gray-400">跳至</span>
<input type="number" min="1" max={totalPages} value={jumpPage} onChange={(e) => setJumpPage(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleJumpPage()} className="w-14 border rounded px-2 py-1 text-center text-sm" placeholder="#" />
<span className="text-gray-400"></span>
<button onClick={handleJumpPage} className="px-3 py-1 bg-gray-600 text-white text-xs rounded-lg hover:bg-gray-700">跳转</button>
</div>
</div>
)}
</>
)}
<Modal open={modal} onClose={() => setModal(false)} title={editing ? "编辑商品" : "新增商品"}>
<div className="space-y-3">
<div>
<label className="block text-sm text-gray-600 mb-1">商品标题</label>
<input className="w-full border rounded-lg px-3 py-2 text-sm" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="block text-sm text-gray-600 mb-1">所属店铺</label>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={form.shop_id} onChange={(e) => setForm({ ...form, shop_id: e.target.value })}>
<option value="">请选择</option>
{shopList.map((s) => <option key={s.id} value={s.id}>{s.shop_name}</option>)}
</select>
</div>
<button onClick={save} disabled={saving} className="w-full py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50">
{saving ? "保存中..." : "保存"}
</button>
</div>
</Modal>
<Modal open={batchModal} onClose={() => setBatchModal(false)} title="批量添加商品">
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-600 mb-1">选择店铺</label>
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={batchShop} onChange={(e) => setBatchShop(e.target.value)}>
<option value="">请选择</option>
{shopList.map((s) => <option key={s.id} value={s.id}>{s.shop_name}</option>)}
</select>
</div>
<div>
<p className="text-sm font-medium text-gray-700 mb-2">方式一上传 Excel 文件</p>
<p className="text-xs text-gray-500 mb-2">支持 .xlsx / .xls / .csv需包含商品标题</p>
<input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" onChange={handleExcelImport} className="hidden" />
<button onClick={() => { if (!batchShop) { toast("请先选择店铺", "error"); return; } fileRef.current?.click(); }} className="w-full py-2 border-2 border-dashed border-purple-300 text-purple-600 rounded-lg text-sm hover:bg-purple-50">
选择 Excel 文件
</button>
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-2">方式二文本粘贴</p>
<p className="text-xs text-gray-500 mb-2">每行一个商品标题</p>
<textarea className="w-full border rounded-lg px-3 py-2 text-sm font-mono" rows={6} value={batchText} onChange={(e) => setBatchText(e.target.value)} placeholder={"商品标题A\n商品标题B\n商品标题C"} />
<button onClick={saveBatch} disabled={saving} className="w-full mt-2 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
{saving ? "提交中..." : "文本导入"}
</button>
</div>
</div>
</Modal>
</div>
);
}