fix: 前端统一解析后端 detail 报错,杜绝 [object Object]

各处 catch 里直接 `toast(err?.response?.data?.detail ?? "xx失败")` 有坑:
请求体校验失败(422)时 detail 是数组,React 会把每个对象渲染成 [object Object]。

- utils/errorMessage.ts(新增): extractErrorMessage 统一解析三种形态 ——
  字符串 / FastAPI 校验错误数组 / 对象;数组取 msg 并剥掉 Pydantic 的
  "Value error, " 前缀、带上字段名、同字段去重后用「;」拼接;解析不出内容
  则回落到调用方给的兜底文案。行为与移动端 extractErrorDetail 对齐
- 19 处调用点全部替换:AdminTasksPage(4) / AdminProductsPage(5) /
  TaskTreeViewer(4) / AdminPrintConfigPage(2) / ScanPage / CreateProductDialog /
  ImageUploader / authApi
- TaskRejectPayload.images 与 taskApi.rejectTask 的注释同步为「选填,支持空数组」
- 新增 components/ui/ImageUploader.tsx(驳回弹窗选图上传组件),
  RejectModal 同步放开无图拦截:去掉 images 非空校验、提交按钮不再因
  无图禁用、标签由必填星号改为「(选填,最多 9 张)」,并移除因此失效的
  error 状态
- AdminTasksPage.tsx: 支持大屏下钻的 ?search / ?status / ?stage 预设参数,
  新增「售后流转中」状态 Tab(按 lifecycle_phase 判定)与全局重置;
  ?stage 只在跳入时生效一次,用户手动改条件即解除锁定
- 杂项清理: TaskFlowView 移除透传的 assigneeNames、TaskListCard/MyTasksPage
  移除未使用导入、QrScanner 关闭 verbose 日志
This commit is contained in:
2026-09-15 10:57:56 +08:00
parent 6b3f14e8fd
commit 7b8084a43d
15 changed files with 367 additions and 82 deletions

View File

@ -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<Set<string>>(new Set());
// 从大屏下钻: ?search=xxx 灌进搜索框、?status=WIP,ARCHIVED 点亮状态 Tabs
// —— 直接复用原生筛选状态,表现与用户手点完全一致,不引入任何额外 UI
const [keyword, setKeyword] = useState(() => searchParams.get("search") || "");
const [statusFilters, setStatusFilters] = useState<Set<string>>(
() => new Set((searchParams.get("status") || "").split(",").map((s) => s.trim()).filter(Boolean)),
);
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false);
@ -207,7 +216,14 @@ export default function AdminTasksPage() {
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
const [textFilters, setTextFilters] = useState<Record<string, string>>({});
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>({});
// 从大屏下钻: ?stage=测试 → 点亮「当前工序」列的筛选(该列显示值对空状态用「—」,
// 故「待启动」需折算成「—」才能命中)
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>(() => {
const stage = searchParams.get("stage");
const initial: Record<string, Set<string>> = {};
if (stage) initial.overall_status = new Set([stage === "待启动" ? "—" : stage]);
return initial;
});
const [dragCol, setDragCol] = useState<string | null>(null);
const [dragOverCol, setDragOverCol] = useState<string | null>(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">全部折叠</button>
</div>
</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" />
@ -607,12 +674,13 @@ export default function AdminTasksPage() {
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
查询
</button>
<Button onClick={handleReset} disabled={loading}>重置</Button>
</form>
{/* 状态筛选 Tabs(多选) */}
<div className="mt-3 flex gap-1.5 flex-wrap">
<button
onClick={() => setStatusFilters(new Set())}
onClick={() => { clearStageLock(); setStatusFilters(new Set()); }}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilters.size === 0
? "bg-blue-600 text-white"
@ -629,8 +697,8 @@ export default function AdminTasksPage() {
onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
active
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
? tab.accent ? "bg-purple-600 text-white" : "bg-blue-600 text-white"
: tab.accent ? "bg-purple-50 text-purple-700 hover:bg-purple-100" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{tab.label}