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:
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user