diff --git a/frontend/src/components/QrScanner/QrScanner.tsx b/frontend/src/components/QrScanner/QrScanner.tsx index 3b96710..2ee2583 100644 --- a/frontend/src/components/QrScanner/QrScanner.tsx +++ b/frontend/src/components/QrScanner/QrScanner.tsx @@ -73,6 +73,7 @@ export default function QrScanner({ // 3. 创建全新实例 const scanner = new Html5Qrcode(SCANNER_ID, { + verbose: false, useBarCodeDetectorIfSupported: true, formatsToSupport: [ Html5QrcodeSupportedFormats.CODE_128, diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx index 5bf2639..fb84e60 100644 --- a/frontend/src/components/TaskTree/TaskFlowView.tsx +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -3,7 +3,7 @@ */ import { memo, useMemo, useState, useEffect } from "react"; import { Image } from "antd"; -import { FileText, Package, User, X } from "lucide-react"; +import { Package, User, X } from "lucide-react"; import type { TaskResponse } from "../../types/api"; import { TASK_STATUS } from "../../types/api"; import { getStatusConfig } from "../../constants/task"; @@ -87,14 +87,13 @@ const SIZE_MAP = { // 极简卡片 // ============================================================ const SlimCard = memo(function SlimCard({ - task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm", assigneeNames, + task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm", }: { task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean; onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; onViewRecords?: (t: TaskResponse) => void; rootMainId?: string; size?: "sm" | "lg"; - assigneeNames?: Record; }) { const cfg = getStatusConfig(task.status); const sz = SIZE_MAP[size]; @@ -232,7 +231,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren const arrow = side === 'left' ? (
) : (
); - const card = ; + const card = ; const kidsContainer = kids.length > 0 ? (
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))} @@ -299,7 +298,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren {/* 中央 */}
+ assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} /> {active(mainTask.status) && (
)} diff --git a/frontend/src/components/TaskTree/TaskTreeViewer.tsx b/frontend/src/components/TaskTree/TaskTreeViewer.tsx index cb73063..e06273d 100644 --- a/frontend/src/components/TaskTree/TaskTreeViewer.tsx +++ b/frontend/src/components/TaskTree/TaskTreeViewer.tsx @@ -20,6 +20,8 @@ import { import { useToast } from "../ui/Toast"; import type { ProductScanResponse, TaskResponse } from "../../types/api"; import { getStatusConfig } from "../../constants/task"; +import ImageUploader from "../ui/ImageUploader"; +import { extractErrorMessage } from "../../utils/errorMessage"; // ============================================================ // 通用 Modal 容器 @@ -142,9 +144,10 @@ export const RejectModal = memo(function RejectModal({ task: TaskResponse | null; submitting: boolean; onClose: () => void; - onSubmit: (reason: string) => void; + onSubmit: (reason: string, images: string[]) => void; }) { const [reason, setReason] = useState(""); + const [images, setImages] = useState([]); if (!task) return null; @@ -152,12 +155,15 @@ export const RejectModal = memo(function RejectModal({ e.preventDefault(); const trimmed = reason.trim(); if (!trimmed) return; - onSubmit(trimmed); + // 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交 + onSubmit(trimmed, images); setReason(""); + setImages([]); } function handleClose() { setReason(""); + setImages([]); onClose(); } @@ -179,29 +185,37 @@ export const RejectModal = memo(function RejectModal({ placeholder="请详细说明品质驳回的原因(必填)" rows={3} maxLength={500} - className="mb-4 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100" + className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100" autoFocus /> -
- {reason.length}/500 -
- - -
+
{reason.length}/500
+ + + + +
+ +
@@ -517,9 +531,7 @@ export default function TaskTreeViewer() { const msg = err?.response?.status === 404 ? `未找到产品身份证 ${trimmed} 对应的产品` - : err?.response?.data?.detail ?? - err?.message ?? - "查询失败,请检查后端服务"; + : extractErrorMessage(err, "查询失败,请检查后端服务"); setError(msg); } finally { setLoading(false); @@ -537,28 +549,22 @@ export default function TaskTreeViewer() { setModalTarget(null); await refresh(); } catch (err: any) { - toast( - err?.response?.data?.detail ?? err?.message ?? "接收失败", - "error" - ); + toast(extractErrorMessage(err, "接收失败"), "error"); } finally { setSubmitting(false); } } - async function handleReject(reason: string) { + async function handleReject(reason: string, images: string[]) { if (!modalTarget) return; setSubmitting(true); try { - await rejectTask(modalTarget.task.id, reason); + await rejectTask(modalTarget.task.id, reason, images); toast("任务已驳回,已自动创建返工任务", "success"); setModalTarget(null); await refresh(); } catch (err: any) { - toast( - err?.response?.data?.detail ?? err?.message ?? "驳回失败", - "error" - ); + toast(extractErrorMessage(err, "驳回失败"), "error"); } finally { setSubmitting(false); } @@ -582,10 +588,7 @@ export default function TaskTreeViewer() { setModalTarget(null); await refresh(); } catch (err: any) { - toast( - err?.response?.data?.detail ?? err?.message ?? "转交失败", - "error" - ); + toast(extractErrorMessage(err, "转交失败"), "error"); } finally { setSubmitting(false); } diff --git a/frontend/src/components/scan/TaskListCard.tsx b/frontend/src/components/scan/TaskListCard.tsx index 88dbe05..55c48d8 100644 --- a/frontend/src/components/scan/TaskListCard.tsx +++ b/frontend/src/components/scan/TaskListCard.tsx @@ -1,7 +1,7 @@ /** 任务进度列表卡片 — 递归渲染 task_tree */ import { useMemo, memo } from "react"; import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react"; -import type { TaskResponse, TaskSummary } from "../../types/api"; +import type { TaskResponse } from "../../types/api"; import { statusColor, statusLabel } from "../../constants/task"; interface TaskListCardProps { diff --git a/frontend/src/components/ui/ImageUploader.tsx b/frontend/src/components/ui/ImageUploader.tsx new file mode 100644 index 0000000..006eafd --- /dev/null +++ b/frontend/src/components/ui/ImageUploader.tsx @@ -0,0 +1,133 @@ +/** 图片上传器 — 选图后直传 /api/v1/upload/,回填可访问的 URL 列表 + * + * 用途:品质驳回弹窗(后端 TaskRejectRequest.images 为选填,可不上传)。 + * 样式与 RejectModal 的原生控件保持一致(非 antd),支持缩略图预览与移除。 + */ +import { useRef, useState } from "react"; +import { ImagePlus, Loader2, X } from "lucide-react"; +import api from "../../services/api"; +import { extractErrorMessage } from "../../utils/errorMessage"; + +// 与后端 TaskRejectRequest.images 的 max_length 对齐 +const MAX_IMAGES = 9; + +interface ImageUploaderProps { + value: string[]; + onChange: (urls: string[]) => void; + max?: number; + disabled?: boolean; +} + +export default function ImageUploader({ + value, + onChange, + max = MAX_IMAGES, + disabled, +}: ImageUploaderProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + + async function handleFiles(files: FileList | null) { + if (!files || files.length === 0) return; + setError(""); + + const slots = max - value.length; + if (slots <= 0) { + setError(`最多上传 ${max} 张图片`); + return; + } + + const picked = Array.from(files).slice(0, slots); + const notImage = picked.find((f) => !f.type.startsWith("image/")); + if (notImage) { + setError(`「${notImage.name}」不是图片文件`); + return; + } + + setUploading(true); + try { + const uploaded: string[] = []; + for (const file of picked) { + const form = new FormData(); + form.append("file", file); + // api 实例默认 Content-Type 为 application/json,此处必须覆盖为 multipart + const { data } = await api.post<{ url: string }>("/upload/", form, { + headers: { "Content-Type": "multipart/form-data" }, + timeout: 60000, // 图片上传比普通请求慢,放宽超时 + }); + uploaded.push(data.url); + } + onChange([...value, ...uploaded]); + } catch (err: any) { + setError(extractErrorMessage(err, "上传失败,请重试")); + } finally { + setUploading(false); + // 清空 input,允许重复选择同一文件 + if (inputRef.current) inputRef.current.value = ""; + } + } + + function removeAt(idx: number) { + onChange(value.filter((_, i) => i !== idx)); + } + + const full = value.length >= max; + + return ( +
+
+ {value.map((url, i) => ( +
+ {`异常图片 + {!disabled && ( + + )} +
+ ))} + + {!full && ( + + )} +
+ + handleFiles(e.target.files)} + /> + +
+ + {value.length}/{max} + + {error && {error}} +
+
+ ); +} diff --git a/frontend/src/pages/MyTasksPage.tsx b/frontend/src/pages/MyTasksPage.tsx index e2be184..093bd25 100644 --- a/frontend/src/pages/MyTasksPage.tsx +++ b/frontend/src/pages/MyTasksPage.tsx @@ -1,6 +1,5 @@ /** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */ import { useEffect, useState, useMemo } from "react"; -import { ClipboardList } from "lucide-react"; import { useNavigate } from "react-router-dom"; import api from "../services/api"; import { useAuth } from "../contexts/AuthContext"; diff --git a/frontend/src/pages/ScanPage.tsx b/frontend/src/pages/ScanPage.tsx index 90058f5..0acc293 100644 --- a/frontend/src/pages/ScanPage.tsx +++ b/frontend/src/pages/ScanPage.tsx @@ -7,6 +7,7 @@ import type { ProductScanResponse } from "../types/api"; import ManualInput from "../components/scan/ManualInput"; import QueryResult from "../components/scan/QueryResult"; import { lifecycleBadge } from "../constants/task"; +import { extractErrorMessage } from "../utils/errorMessage"; // 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载 const CameraScanner = lazy(() => import("../components/scan/CameraScanner")); @@ -32,11 +33,7 @@ export default function ScanPage() { try { setProduct(await scanProduct(sn)); } catch (err: unknown) { - const detail = - err && typeof err === "object" && "response" in err - ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail - : null; - setError(detail ?? "网络错误,请检查后端服务"); + setError(extractErrorMessage(err, "网络错误,请检查后端服务")); } finally { setLoading(false); } diff --git a/frontend/src/pages/admin/AdminPrintConfigPage.tsx b/frontend/src/pages/admin/AdminPrintConfigPage.tsx index 7c362db..f1c910a 100644 --- a/frontend/src/pages/admin/AdminPrintConfigPage.tsx +++ b/frontend/src/pages/admin/AdminPrintConfigPage.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from "react"; -import { Settings, Save, Loader2, Wifi, WifiOff } from "lucide-react"; +import { Save, Loader2, Wifi, WifiOff } from "lucide-react"; import { getPrinterConfig, updatePrinterConfig, type PrinterConfig, } from "../../services/printApi"; import { useToast } from "../../components/ui/Toast"; +import { extractErrorMessage } from "../../utils/errorMessage"; export default function AdminPrintConfigPage() { const { toast } = useToast(); @@ -33,7 +34,7 @@ export default function AdminPrintConfigPage() { setPort(lp.port); setEnabled(lp.enabled ?? true); } catch (err: any) { - toast(err?.response?.data?.detail ?? "加载配置失败", "error"); + toast(extractErrorMessage(err, "加载配置失败"), "error"); } finally { setLoading(false); } @@ -50,7 +51,7 @@ export default function AdminPrintConfigPage() { const result = await updatePrinterConfig(ip.trim(), port, enabled); toast(result.message, "success"); } catch (err: any) { - toast(err?.response?.data?.detail ?? "保存失败", "error"); + toast(extractErrorMessage(err, "保存失败"), "error"); } finally { setSaving(false); } diff --git a/frontend/src/pages/admin/AdminProductsPage.tsx b/frontend/src/pages/admin/AdminProductsPage.tsx index 1880248..901f044 100644 --- a/frontend/src/pages/admin/AdminProductsPage.tsx +++ b/frontend/src/pages/admin/AdminProductsPage.tsx @@ -14,6 +14,7 @@ import { import { useToast } from "../../components/ui/Toast"; import { useAuth } from "../../contexts/AuthContext"; import { getStatusConfig, lifecycleBadge } from "../../constants/task"; +import { extractErrorMessage } from "../../utils/errorMessage"; const QR_BASE = "/api/v1/products/qrcode"; @@ -125,12 +126,12 @@ export default function AdminProductsPage() { } // ---- 打印 ---- - async function handleOpenPrint(product: ProductResponse) { /* unchanged */ setPrintTarget(product); setPreviewUrl(null); setPrintCopies(1); setPrintLoading(true); try { setPreviewUrl(await getLabelPreview({ serial_number: product.serial_number, material_name: product.material_name ?? product.material_id ?? "", spec_model: product.spec_model ?? "", order_no: product.order_no ?? "" })); } catch (err: any) { toast(err?.response?.data?.detail ?? "生成预览失败", "error"); setPrintTarget(null); } finally { setPrintLoading(false); } } - async function handleConfirmPrint() { if (!printTarget) return; setPrinting(true); try { const r = await executePrint({ serial_number: printTarget.serial_number, material_name: printTarget.material_name ?? "", spec_model: printTarget.spec_model ?? "", order_no: printTarget.order_no ?? "", copies: printCopies }); toast(r.message, "success"); setPrintTarget(null); } catch (err: any) { toast(err?.response?.data?.detail ?? "打印失败", "error"); } finally { setPrinting(false); } } + async function handleOpenPrint(product: ProductResponse) { /* unchanged */ setPrintTarget(product); setPreviewUrl(null); setPrintCopies(1); setPrintLoading(true); try { setPreviewUrl(await getLabelPreview({ serial_number: product.serial_number, material_name: product.material_name ?? product.material_id ?? "", spec_model: product.spec_model ?? "", order_no: product.order_no ?? "" })); } catch (err: unknown) { toast(extractErrorMessage(err, "生成预览失败"), "error"); setPrintTarget(null); } finally { setPrintLoading(false); } } + async function handleConfirmPrint() { if (!printTarget) return; setPrinting(true); try { const r = await executePrint({ serial_number: printTarget.serial_number, material_name: printTarget.material_name ?? "", spec_model: printTarget.spec_model ?? "", order_no: printTarget.order_no ?? "", copies: printCopies }); toast(r.message, "success"); setPrintTarget(null); } catch (err: unknown) { toast(extractErrorMessage(err, "打印失败"), "error"); } finally { setPrinting(false); } } // ---- 编辑 ---- function openEdit(p: ProductResponse) { setEditTarget(p); setEditOrderNo(p.order_no ?? ""); setEditExternalSerial(p.external_serial ?? ""); } - async function handleSaveEdit() { if (!editTarget) return; setEditSaving(true); try { await api.patch(`/products/${editTarget.id}`, { order_no: editOrderNo.trim() || null, external_serial: editExternalSerial.trim() || null }); toast("保存成功", "success"); setEditTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "保存失败", "error"); } finally { setEditSaving(false); } } + async function handleSaveEdit() { if (!editTarget) return; setEditSaving(true); try { await api.patch(`/products/${editTarget.id}`, { order_no: editOrderNo.trim() || null, external_serial: editExternalSerial.trim() || null }); toast("保存成功", "success"); setEditTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "保存失败"), "error"); } finally { setEditSaving(false); } } // ---- 删除 ---- useEffect(() => { @@ -141,7 +142,7 @@ export default function AdminProductsPage() { }, [deleteTarget?.id]); // eslint-disable-line function confirmDelete(p: ProductResponse) { setDeleteTarget(p); } - async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "删除失败", "error"); } finally { setDeleting(false); } } + async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "删除失败"), "error"); } finally { setDeleting(false); } } // ---- 状态收口(入库 / 出库,管理员)---- // 🛡️ 双重确认倒计时 + 必须输入产品身份证,防误触 @@ -164,8 +165,8 @@ export default function AdminProductsPage() { toast(`已收口为「${finalizeStatus}」`, "success"); setFinalizeTarget(null); loadProducts(); - } catch (err: any) { - toast(err?.response?.data?.detail ?? "操作失败", "error"); + } catch (err: unknown) { + toast(extractErrorMessage(err, "操作失败"), "error"); } finally { setFinalizing(false); } diff --git a/frontend/src/pages/admin/AdminTasksPage.tsx b/frontend/src/pages/admin/AdminTasksPage.tsx index 19340d8..23fe09f 100644 --- a/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/frontend/src/pages/admin/AdminTasksPage.tsx @@ -5,7 +5,7 @@ import { Search, Loader2, Package, ChevronDown, ChevronRight, Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns, } from "lucide-react"; -import { Tooltip, Popover, Checkbox, Input } from "antd"; +import { Tooltip, Popover, Checkbox, Input, Button } from "antd"; import api from "../../services/api"; import { scanProduct } from "../../services/productApi"; import { @@ -19,13 +19,18 @@ import type { ProductScanResponse } from "../../types/api"; import { useToast } from "../../components/ui/Toast"; import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task"; import { useAuth } from "../../contexts/AuthContext"; +import { extractErrorMessage } from "../../utils/errorMessage"; -const STATUS_TABS = [ +const STATUS_TABS: { key: string; label: string; accent?: boolean }[] = [ { key: "PENDING", label: "待接收" }, { key: "WIP", label: "进行中" }, { key: "COMPLETED", label: "已完成" }, { key: "ARCHIVED", label: "已入库" }, { key: "OUTBOUND", label: "已出库" }, + // 🔧 售后流转中 = 出库回流设备中正在流转的(lifecycle_phase=AFTER_SALES 且 当前 WIP)。 + // 它与上面几个"状态码"不是同一维度(靠 lifecycle_phase 判定),故单列一个独立 key; + // 多选时与其它状态是「或」关系 —— 只勾它即可一键剥离全部生产数据。 + { key: "AFTER_SALES_WIP", label: "售后流转中", accent: true }, ]; type SortOrder = "asc" | "desc"; @@ -60,11 +65,15 @@ function formatDuration(hours: number | null): string { export default function AdminTasksPage() { const { toast } = useToast(); const { user: currentUser } = useAuth(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); // 搜索 & 筛选 - const [keyword, setKeyword] = useState(""); - const [statusFilters, setStatusFilters] = useState>(new Set()); + // 从大屏下钻: ?search=xxx 灌进搜索框、?status=WIP,ARCHIVED 点亮状态 Tabs + // —— 直接复用原生筛选状态,表现与用户手点完全一致,不引入任何额外 UI + const [keyword, setKeyword] = useState(() => searchParams.get("search") || ""); + const [statusFilters, setStatusFilters] = useState>( + () => new Set((searchParams.get("status") || "").split(",").map((s) => s.trim()).filter(Boolean)), + ); const [groupBy, setGroupBy] = useState<"order" | "device">("device"); const [products, setProducts] = useState([]); const [loading, setLoading] = useState(false); @@ -207,7 +216,14 @@ export default function AdminTasksPage() { const [columnOrder, setColumnOrder] = useState(() => columns.map((c) => c.key)); const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null); const [textFilters, setTextFilters] = useState>({}); - const [enumFilters, setEnumFilters] = useState>>({}); + // 从大屏下钻: ?stage=测试 → 点亮「当前工序」列的筛选(该列显示值对空状态用「—」, + // 故「待启动」需折算成「—」才能命中) + const [enumFilters, setEnumFilters] = useState>>(() => { + const stage = searchParams.get("stage"); + const initial: Record> = {}; + if (stage) initial.overall_status = new Set([stage === "待启动" ? "—" : stage]); + return initial; + }); const [dragCol, setDragCol] = useState(null); const [dragOverCol, setDragOverCol] = useState(null); // 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示) @@ -269,7 +285,13 @@ export default function AdminTasksPage() { for (const p of products) { if (statusFilters.size > 0) { const currentStatus = (p.macro_status || p.status).toUpperCase(); - if (!statusFilters.has(currentStatus)) continue; + // 🔧 「售后流转中」按 lifecycle_phase 判定,与状态码不同维,单独算: + // 只勾它时,未勾任何状态码 → 仅留下售后回流且正在流转的设备。 + const isAfterSalesWip = + p.lifecycle_phase === "AFTER_SALES" && currentStatus === "WIP"; + const matchedByStatus = statusFilters.has(currentStatus); + const matchedByAfterSales = statusFilters.has("AFTER_SALES_WIP") && isAfterSalesWip; + if (!matchedByStatus && !matchedByAfterSales) continue; } // 列筛选(所有列) let skip = false; @@ -321,7 +343,51 @@ export default function AdminTasksPage() { }); }, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps + /** 删除 URL 上的指定跳转预设参数(只删传入的 key,不动其它无关参数) */ + function clearUrlPresets(keys: string[]) { + const hit = keys.filter((k) => searchParams.has(k)); + if (hit.length === 0) return; + const next = new URLSearchParams(searchParams); + hit.forEach((k) => next.delete(k)); + setSearchParams(next, { replace: true }); + } + + /** + * 解锁大屏带过来的 ?stage —— URL 参数与本地列筛选一起清掉。 + * + * 该筛选只应在「从大屏跳进来的那一刻」生效一次。它藏在列筛选浮层里, + * 界面上几乎不可见,若不清除会与状态 Tab 叠加成互斥条件、筛出空列表, + * 用户却找不到原因。用户手动改了任何分类条件 = 最新意图优先。 + */ + function clearStageLock() { + clearUrlPresets(["stage"]); + setEnumFilters((prev) => { + if (!prev.overall_status) return prev; + const next = { ...prev }; + delete next.overall_status; + return next; + }); + } + + /** 全局重置:清空全部筛选条件与跳转预设,并强制一次无条件全量查询 */ + function handleReset() { + // stage(工序锁)与 sn(设备定位)都是跳转带进来的预设,重置时一并丢弃, + // 否则用户刷新页面会被旧条件"复活"回去 + clearUrlPresets(["stage", "sn"]); + setKeyword(""); + setStatusFilters(new Set()); + setTextFilters({}); + setEnumFilters({}); + setSort(null); + setExpandedOrders(new Set()); + setTaskTrees({}); + setActiveTreeProductId(null); + loadProducts(""); + } + + // 用户手动切换状态分类 = 最新意图,丢弃大屏带来的工序限制 function toggleStatusFilter(key: string) { + clearStageLock(); setStatusFilters((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); @@ -408,7 +474,7 @@ export default function AdminTasksPage() { setTreeLoading((s) => ({ ...s, [serialNumber]: true })); scanProduct(serialNumber) .then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result }))) - .catch((err: any) => toast(err?.response?.data?.detail ?? err?.message ?? "加载任务树失败", "error")) + .catch((err: unknown) => toast(extractErrorMessage(err, "加载任务树失败"), "error")) .finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false }))); } } @@ -435,24 +501,24 @@ export default function AdminTasksPage() { if (sn) await refreshProductTree(sn); await loadProducts(keyword); } catch (err: any) { - toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error"); + toast(extractErrorMessage(err, "接收失败"), "error"); } finally { setSubmitting(false); } } - async function handleReject(reason: string) { + async function handleReject(reason: string, images: string[]) { if (!modalTarget) return; setSubmitting(true); try { - await rejectTask(modalTarget.task.id, reason); + await rejectTask(modalTarget.task.id, reason, images); toast("任务已驳回", "success"); const sn = modalTarget.task.product_sn || ""; setModalTarget(null); if (sn) await refreshProductTree(sn); await loadProducts(keyword); } catch (err: any) { - toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error"); + toast(extractErrorMessage(err, "驳回失败"), "error"); } finally { setSubmitting(false); } @@ -475,7 +541,7 @@ export default function AdminTasksPage() { if (sn) await refreshProductTree(sn); await loadProducts(keyword); } catch (err: any) { - toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error"); + toast(extractErrorMessage(err, "转交失败"), "error"); } finally { setSubmitting(false); } @@ -579,6 +645,7 @@ export default function AdminTasksPage() { className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100">全部折叠
+
@@ -607,12 +674,13 @@ export default function AdminTasksPage() { {loading ? : } 查询 + {/* 状态筛选 Tabs(多选) */}