418 lines
22 KiB
JavaScript
418 lines
22 KiB
JavaScript
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>
|
||
);
|
||
}
|