361 lines
19 KiB
React
361 lines
19 KiB
React
|
|
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 [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 (
|
|||
|
|
<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>
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
{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>
|
|||
|
|
);
|
|||
|
|
}
|