feat: 宏观状态只同步主线+删除5s倒计时+序列号确认+分组切换(订单/设备)+序列号列

This commit is contained in:
2026-08-10 10:37:56 +08:00
parent ad9fb8d37e
commit 4854eb626e
3 changed files with 63 additions and 17 deletions

View File

@ -298,7 +298,7 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
product = product_result.scalar_one_or_none()
if product:
if data.task_name:
if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")):
product.overall_status = "在库" if "virtual_warehouse" in data.task_name else data.task_name
# 派发给人 → 产品离开仓库
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:

View File

@ -35,9 +35,12 @@ export default function AdminProductsPage() {
const [editExternalSerial, setEditExternalSerial] = useState("");
const [editSaving, setEditSaving] = useState(false);
// 删除确认
// 删除确认 + 防呆
const [deleteTarget, setDeleteTarget] = useState<ProductResponse | null>(null);
const [deleting, setDeleting] = useState(false);
const [countdown, setCountdown] = useState(5);
const [serialInput, setSerialInput] = useState("");
const canConfirmDelete = countdown <= 0 && serialInput === deleteTarget?.serial_number;
async function loadProducts() {
setLoading(true);
@ -118,6 +121,14 @@ export default function AdminProductsPage() {
}
// ---- 删除 ----
// 🔧 倒计时:打开删除弹窗时启动
useEffect(() => {
if (!deleteTarget) { setCountdown(5); setSerialInput(""); return; }
setCountdown(5); setSerialInput("");
const timer = setInterval(() => setCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000);
return () => clearInterval(timer);
}, [deleteTarget?.id]); // eslint-disable-line react-hooks/exhaustive-deps
function confirmDelete(product: ProductResponse) {
setDeleteTarget(product);
}
@ -277,6 +288,7 @@ export default function AdminProductsPage() {
)}
{/* 删除确认弹窗 */}
{/* 🔧 删除确认 — 5秒倒计时 + 序列号二次确认 */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !deleting && setDeleteTarget(null)} />
@ -286,21 +298,39 @@ export default function AdminProductsPage() {
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
<div>
<h3 className="text-base font-bold text-gray-800"></h3>
<p className="text-xs text-gray-500"></p>
<h3 className="text-base font-bold text-gray-800"></h3>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<p className="mb-2 text-sm text-gray-600">
<span className="font-mono font-bold">{deleteTarget.serial_number}</span>
<p className="mb-4 text-sm text-gray-600">
<span className="font-mono font-bold">{deleteTarget.serial_number}</span>
</p>
<p className="mb-6 text-xs text-red-500"></p>
<div className="flex justify-end gap-2">
<button onClick={() => setDeleteTarget(null)} disabled={deleting} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"></button>
<button onClick={handleDelete} disabled={deleting} className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
{deleting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
<Trash2 className="h-3.5 w-3.5" />
{/* 步骤1输入序列号确认 */}
<div className="mb-4">
<label className="mb-1 block text-xs font-medium text-gray-600">
</label>
<input
value={serialInput}
onChange={(e) => setSerialInput(e.target.value)}
placeholder={deleteTarget.serial_number}
maxLength={16}
className="w-full rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
disabled={deleting}
/>
</div>
{/* 步骤25秒倒计时 + 确认按钮 */}
<div className="flex items-center justify-end gap-2">
<button onClick={() => setDeleteTarget(null)} disabled={deleting}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"></button>
<button onClick={handleDelete} disabled={!canConfirmDelete || deleting}
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50">
{deleting ? <><Loader2 className="h-3.5 w-3.5 animate-spin" />...</>
: canConfirmDelete ? <><Trash2 className="h-3.5 w-3.5" /></>
: <> ({countdown}s)</>}
</button>
</div>
</div>

View File

@ -39,6 +39,7 @@ export default function AdminTasksPage() {
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [groupBy, setGroupBy] = useState<"order" | "device">("order"); // 🔧 分组维度
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -81,7 +82,7 @@ export default function AdminTasksPage() {
loadProducts(keyword);
}
// ---- 按订单分组 + 本地状态过滤(单一数据源: macro_status || status ----
// ---- 按订单/设备分组 + 本地状态过滤 ----
const orderGroups = useMemo<OrderGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
@ -89,7 +90,9 @@ export default function AdminTasksPage() {
const currentStatus = (p.macro_status || p.status).toUpperCase();
if (currentStatus !== statusFilter) continue;
}
const key = p.order_no || "未绑定订单";
const key = groupBy === "device"
? (p.material_name || p.material_id || "未命名设备")
: (p.order_no || "未绑定订单");
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
}
@ -102,7 +105,7 @@ export default function AdminTasksPage() {
(p) => p.current_location_id === "virtual_warehouse"
),
}));
}, [products, statusFilter]);
}, [products, statusFilter, groupBy]);
// ---- 手风琴切换 ----
function toggleOrder(orderNo: string) {
@ -228,6 +231,15 @@ export default function AdminTasksPage() {
{/* ---- 搜索 + 筛选 ---- */}
<div className="mb-6 rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs text-gray-400">:</span>
{(["order", "device"] as const).map(m => (
<button key={m} onClick={() => setGroupBy(m)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${groupBy === m ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-500 hover:bg-gray-200"}`}>
{m === "order" ? "按订单" : "按设备"}
</button>
))}
</div>
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
@ -339,8 +351,9 @@ export default function AdminTasksPage() {
{isOpen && (
<div className="border-t border-gray-100">
{/* 表头 */}
<div className="grid grid-cols-12 gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500">
<div className="grid gap-2" style="grid-template-columns: 2fr 1fr 2fr 1fr 1fr 2fr 2fr 2fr" bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500">
<div className="col-span-2">产品身份证</div>
<div className="col-span-1">序列号</div>
<div className="col-span-2">规格型号</div>
<div className="col-span-1">宏观状态</div>
<div className="col-span-1">任务状态</div>
@ -361,10 +374,13 @@ export default function AdminTasksPage() {
return (
<div key={p.id}>
<div className="grid grid-cols-12 gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50">
<div className="grid gap-2" style="grid-template-columns: 2fr 1fr 2fr 1fr 1fr 2fr 2fr 2fr" border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50">
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider">
{p.serial_number}
</div>
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">
{p.external_serial || "—"}
</div>
<div className="col-span-2 text-xs text-gray-500 truncate">
{p.spec_model || p.material_name || p.material_id || "—"}
</div>