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

@ -73,6 +73,7 @@ export default function QrScanner({
// 3. 创建全新实例 // 3. 创建全新实例
const scanner = new Html5Qrcode(SCANNER_ID, { const scanner = new Html5Qrcode(SCANNER_ID, {
verbose: false,
useBarCodeDetectorIfSupported: true, useBarCodeDetectorIfSupported: true,
formatsToSupport: [ formatsToSupport: [
Html5QrcodeSupportedFormats.CODE_128, Html5QrcodeSupportedFormats.CODE_128,

View File

@ -3,7 +3,7 @@
*/ */
import { memo, useMemo, useState, useEffect } from "react"; import { memo, useMemo, useState, useEffect } from "react";
import { Image } from "antd"; 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 type { TaskResponse } from "../../types/api";
import { TASK_STATUS } from "../../types/api"; import { TASK_STATUS } from "../../types/api";
import { getStatusConfig } from "../../constants/task"; import { getStatusConfig } from "../../constants/task";
@ -87,14 +87,13 @@ const SIZE_MAP = {
// 极简卡片 // 极简卡片
// ============================================================ // ============================================================
const SlimCard = memo(function SlimCard({ 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; task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
onViewRecords?: (t: TaskResponse) => void; onViewRecords?: (t: TaskResponse) => void;
rootMainId?: string; rootMainId?: string;
size?: "sm" | "lg"; size?: "sm" | "lg";
assigneeNames?: Record<string, string>;
}) { }) {
const cfg = getStatusConfig(task.status); const cfg = getStatusConfig(task.status);
const sz = SIZE_MAP[size]; const sz = SIZE_MAP[size];
@ -232,7 +231,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
const arrow = side === 'left' const arrow = side === 'left'
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>) ? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>); : (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>);
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} assigneeNames={assigneeNames} />; const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} />;
const kidsContainer = kids.length > 0 ? ( const kidsContainer = kids.length > 0 ? (
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}> <div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))} {kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
@ -299,7 +298,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
{/* 中央 */} {/* 中央 */}
<div className="shrink-0 z-10 relative"> <div className="shrink-0 z-10 relative">
<SlimCard task={mainTask} active={active(mainTask.status)} <SlimCard task={mainTask} active={active(mainTask.status)}
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} assigneeNames={assigneeNames} /> assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} />
{active(mainTask.status) && ( {active(mainTask.status) && (
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} /> <div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
)} )}

View File

@ -20,6 +20,8 @@ import {
import { useToast } from "../ui/Toast"; import { useToast } from "../ui/Toast";
import type { ProductScanResponse, TaskResponse } from "../../types/api"; import type { ProductScanResponse, TaskResponse } from "../../types/api";
import { getStatusConfig } from "../../constants/task"; import { getStatusConfig } from "../../constants/task";
import ImageUploader from "../ui/ImageUploader";
import { extractErrorMessage } from "../../utils/errorMessage";
// ============================================================ // ============================================================
// 通用 Modal 容器 // 通用 Modal 容器
@ -142,9 +144,10 @@ export const RejectModal = memo(function RejectModal({
task: TaskResponse | null; task: TaskResponse | null;
submitting: boolean; submitting: boolean;
onClose: () => void; onClose: () => void;
onSubmit: (reason: string) => void; onSubmit: (reason: string, images: string[]) => void;
}) { }) {
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [images, setImages] = useState<string[]>([]);
if (!task) return null; if (!task) return null;
@ -152,12 +155,15 @@ export const RejectModal = memo(function RejectModal({
e.preventDefault(); e.preventDefault();
const trimmed = reason.trim(); const trimmed = reason.trim();
if (!trimmed) return; if (!trimmed) return;
onSubmit(trimmed); // 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交
onSubmit(trimmed, images);
setReason(""); setReason("");
setImages([]);
} }
function handleClose() { function handleClose() {
setReason(""); setReason("");
setImages([]);
onClose(); onClose();
} }
@ -179,12 +185,21 @@ export const RejectModal = memo(function RejectModal({
placeholder="请详细说明品质驳回的原因(必填)" placeholder="请详细说明品质驳回的原因(必填)"
rows={3} rows={3}
maxLength={500} 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 autoFocus
/> />
<div className="flex items-center justify-between"> <div className="mb-3 mt-1 text-right text-xs text-gray-400">{reason.length}/500</div>
<span className="text-xs text-gray-400">{reason.length}/500</span>
<div className="flex gap-2"> <label className="mb-1 block text-sm font-medium text-gray-700">
异常图片 <span className="text-xs font-normal text-gray-400">(选填,最多 9 张)</span>
</label>
<ImageUploader
value={images}
onChange={setImages}
disabled={submitting}
/>
<div className="mt-4 flex items-center justify-end gap-2">
<button <button
type="button" type="button"
onClick={handleClose} onClick={handleClose}
@ -202,7 +217,6 @@ export const RejectModal = memo(function RejectModal({
确认驳回 确认驳回
</button> </button>
</div> </div>
</div>
</form> </form>
</Modal> </Modal>
); );
@ -517,9 +531,7 @@ export default function TaskTreeViewer() {
const msg = const msg =
err?.response?.status === 404 err?.response?.status === 404
? `未找到产品身份证 ${trimmed} 对应的产品` ? `未找到产品身份证 ${trimmed} 对应的产品`
: err?.response?.data?.detail ?? : extractErrorMessage(err, "查询失败,请检查后端服务");
err?.message ??
"查询失败,请检查后端服务";
setError(msg); setError(msg);
} finally { } finally {
setLoading(false); setLoading(false);
@ -537,28 +549,22 @@ export default function TaskTreeViewer() {
setModalTarget(null); setModalTarget(null);
await refresh(); await refresh();
} catch (err: any) { } catch (err: any) {
toast( toast(extractErrorMessage(err, "接收失败"), "error");
err?.response?.data?.detail ?? err?.message ?? "接收失败",
"error"
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
} }
async function handleReject(reason: string) { async function handleReject(reason: string, images: string[]) {
if (!modalTarget) return; if (!modalTarget) return;
setSubmitting(true); setSubmitting(true);
try { try {
await rejectTask(modalTarget.task.id, reason); await rejectTask(modalTarget.task.id, reason, images);
toast("任务已驳回,已自动创建返工任务", "success"); toast("任务已驳回,已自动创建返工任务", "success");
setModalTarget(null); setModalTarget(null);
await refresh(); await refresh();
} catch (err: any) { } catch (err: any) {
toast( toast(extractErrorMessage(err, "驳回失败"), "error");
err?.response?.data?.detail ?? err?.message ?? "驳回失败",
"error"
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -582,10 +588,7 @@ export default function TaskTreeViewer() {
setModalTarget(null); setModalTarget(null);
await refresh(); await refresh();
} catch (err: any) { } catch (err: any) {
toast( toast(extractErrorMessage(err, "转交失败"), "error");
err?.response?.data?.detail ?? err?.message ?? "转交失败",
"error"
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View File

@ -1,7 +1,7 @@
/** 任务进度列表卡片 — 递归渲染 task_tree */ /** 任务进度列表卡片 — 递归渲染 task_tree */
import { useMemo, memo } from "react"; import { useMemo, memo } from "react";
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-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"; import { statusColor, statusLabel } from "../../constants/task";
interface TaskListCardProps { interface TaskListCardProps {

View File

@ -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<HTMLInputElement>(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 (
<div>
<div className="flex flex-wrap gap-2">
{value.map((url, i) => (
<div
key={`${url}-${i}`}
className="group relative h-20 w-20 overflow-hidden rounded-lg border border-gray-200"
>
<img src={url} alt={`异常图片 ${i + 1}`} className="h-full w-full object-cover" />
{!disabled && (
<button
type="button"
onClick={() => removeAt(i)}
title="移除"
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 transition-opacity group-hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
{!full && (
<button
type="button"
disabled={disabled || uploading}
onClick={() => inputRef.current?.click()}
className="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-gray-300 text-gray-400 transition-colors hover:border-red-400 hover:text-red-500 disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ImagePlus className="h-5 w-5" />
)}
<span className="text-[11px]">{uploading ? "上传中" : "添加图片"}</span>
</button>
)}
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => handleFiles(e.target.files)}
/>
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-gray-400">
{value.length}/{max}
</span>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
</div>
);
}

View File

@ -1,6 +1,5 @@
/** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */ /** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { ClipboardList } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import api from "../services/api"; import api from "../services/api";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";

View File

@ -7,6 +7,7 @@ import type { ProductScanResponse } from "../types/api";
import ManualInput from "../components/scan/ManualInput"; import ManualInput from "../components/scan/ManualInput";
import QueryResult from "../components/scan/QueryResult"; import QueryResult from "../components/scan/QueryResult";
import { lifecycleBadge } from "../constants/task"; import { lifecycleBadge } from "../constants/task";
import { extractErrorMessage } from "../utils/errorMessage";
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载 // 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
const CameraScanner = lazy(() => import("../components/scan/CameraScanner")); const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
@ -32,11 +33,7 @@ export default function ScanPage() {
try { try {
setProduct(await scanProduct(sn)); setProduct(await scanProduct(sn));
} catch (err: unknown) { } catch (err: unknown) {
const detail = setError(extractErrorMessage(err, "网络错误,请检查后端服务"));
err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: null;
setError(detail ?? "网络错误,请检查后端服务");
} finally { } finally {
setLoading(false); setLoading(false);
} }

View File

@ -1,11 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Settings, Save, Loader2, Wifi, WifiOff } from "lucide-react"; import { Save, Loader2, Wifi, WifiOff } from "lucide-react";
import { import {
getPrinterConfig, getPrinterConfig,
updatePrinterConfig, updatePrinterConfig,
type PrinterConfig, type PrinterConfig,
} from "../../services/printApi"; } from "../../services/printApi";
import { useToast } from "../../components/ui/Toast"; import { useToast } from "../../components/ui/Toast";
import { extractErrorMessage } from "../../utils/errorMessage";
export default function AdminPrintConfigPage() { export default function AdminPrintConfigPage() {
const { toast } = useToast(); const { toast } = useToast();
@ -33,7 +34,7 @@ export default function AdminPrintConfigPage() {
setPort(lp.port); setPort(lp.port);
setEnabled(lp.enabled ?? true); setEnabled(lp.enabled ?? true);
} catch (err: any) { } catch (err: any) {
toast(err?.response?.data?.detail ?? "加载配置失败", "error"); toast(extractErrorMessage(err, "加载配置失败"), "error");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -50,7 +51,7 @@ export default function AdminPrintConfigPage() {
const result = await updatePrinterConfig(ip.trim(), port, enabled); const result = await updatePrinterConfig(ip.trim(), port, enabled);
toast(result.message, "success"); toast(result.message, "success");
} catch (err: any) { } catch (err: any) {
toast(err?.response?.data?.detail ?? "保存失败", "error"); toast(extractErrorMessage(err, "保存失败"), "error");
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@ -14,6 +14,7 @@ import {
import { useToast } from "../../components/ui/Toast"; import { useToast } from "../../components/ui/Toast";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { getStatusConfig, lifecycleBadge } from "../../constants/task"; import { getStatusConfig, lifecycleBadge } from "../../constants/task";
import { extractErrorMessage } from "../../utils/errorMessage";
const QR_BASE = "/api/v1/products/qrcode"; 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 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: any) { toast(err?.response?.data?.detail ?? "打印失败", "error"); } finally { setPrinting(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 ?? ""); } 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(() => { useEffect(() => {
@ -141,7 +142,7 @@ export default function AdminProductsPage() {
}, [deleteTarget?.id]); // eslint-disable-line }, [deleteTarget?.id]); // eslint-disable-line
function confirmDelete(p: ProductResponse) { setDeleteTarget(p); } 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"); toast(`已收口为「${finalizeStatus}」`, "success");
setFinalizeTarget(null); setFinalizeTarget(null);
loadProducts(); loadProducts();
} catch (err: any) { } catch (err: unknown) {
toast(err?.response?.data?.detail ?? "操作失败", "error"); toast(extractErrorMessage(err, "操作失败"), "error");
} finally { } finally {
setFinalizing(false); setFinalizing(false);
} }

View File

@ -5,7 +5,7 @@ import {
Search, Loader2, Package, ChevronDown, ChevronRight, Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns, Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
} from "lucide-react"; } from "lucide-react";
import { Tooltip, Popover, Checkbox, Input } from "antd"; import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
import api from "../../services/api"; import api from "../../services/api";
import { scanProduct } from "../../services/productApi"; import { scanProduct } from "../../services/productApi";
import { import {
@ -19,13 +19,18 @@ import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast"; import { useToast } from "../../components/ui/Toast";
import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task"; import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext"; 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: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" }, { key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" }, { key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" }, { key: "ARCHIVED", label: "已入库" },
{ key: "OUTBOUND", label: "已出库" }, { key: "OUTBOUND", label: "已出库" },
// 🔧 售后流转中 = 出库回流设备中正在流转的(lifecycle_phase=AFTER_SALES 且 当前 WIP)。
// 它与上面几个"状态码"不是同一维度(靠 lifecycle_phase 判定),故单列一个独立 key;
// 多选时与其它状态是「或」关系 —— 只勾它即可一键剥离全部生产数据。
{ key: "AFTER_SALES_WIP", label: "售后流转中", accent: true },
]; ];
type SortOrder = "asc" | "desc"; type SortOrder = "asc" | "desc";
@ -60,11 +65,15 @@ function formatDuration(hours: number | null): string {
export default function AdminTasksPage() { export default function AdminTasksPage() {
const { toast } = useToast(); const { toast } = useToast();
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [searchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
// 搜索 & 筛选 // 搜索 & 筛选
const [keyword, setKeyword] = useState(""); // 从大屏下钻: ?search=xxx 灌进搜索框、?status=WIP,ARCHIVED 点亮状态 Tabs
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set()); // —— 直接复用原生筛选状态,表现与用户手点完全一致,不引入任何额外 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 [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [products, setProducts] = useState<ProductResponse[]>([]); const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -207,7 +216,14 @@ export default function AdminTasksPage() {
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key)); const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null); const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
const [textFilters, setTextFilters] = useState<Record<string, string>>({}); 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 [dragCol, setDragCol] = useState<string | null>(null);
const [dragOverCol, setDragOverCol] = useState<string | null>(null); const [dragOverCol, setDragOverCol] = useState<string | null>(null);
// 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示) // 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示)
@ -269,7 +285,13 @@ export default function AdminTasksPage() {
for (const p of products) { for (const p of products) {
if (statusFilters.size > 0) { if (statusFilters.size > 0) {
const currentStatus = (p.macro_status || p.status).toUpperCase(); 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; let skip = false;
@ -321,7 +343,51 @@ export default function AdminTasksPage() {
}); });
}, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps }, [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) { function toggleStatusFilter(key: string) {
clearStageLock();
setStatusFilters((prev) => { setStatusFilters((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key); if (next.has(key)) next.delete(key); else next.add(key);
@ -408,7 +474,7 @@ export default function AdminTasksPage() {
setTreeLoading((s) => ({ ...s, [serialNumber]: true })); setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
scanProduct(serialNumber) scanProduct(serialNumber)
.then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result }))) .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 }))); .finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
} }
} }
@ -435,24 +501,24 @@ export default function AdminTasksPage() {
if (sn) await refreshProductTree(sn); if (sn) await refreshProductTree(sn);
await loadProducts(keyword); await loadProducts(keyword);
} catch (err: any) { } catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error"); toast(extractErrorMessage(err, "接收失败"), "error");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
} }
async function handleReject(reason: string) { async function handleReject(reason: string, images: string[]) {
if (!modalTarget) return; if (!modalTarget) return;
setSubmitting(true); setSubmitting(true);
try { try {
await rejectTask(modalTarget.task.id, reason); await rejectTask(modalTarget.task.id, reason, images);
toast("任务已驳回", "success"); toast("任务已驳回", "success");
const sn = modalTarget.task.product_sn || ""; const sn = modalTarget.task.product_sn || "";
setModalTarget(null); setModalTarget(null);
if (sn) await refreshProductTree(sn); if (sn) await refreshProductTree(sn);
await loadProducts(keyword); await loadProducts(keyword);
} catch (err: any) { } catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error"); toast(extractErrorMessage(err, "驳回失败"), "error");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -475,7 +541,7 @@ export default function AdminTasksPage() {
if (sn) await refreshProductTree(sn); if (sn) await refreshProductTree(sn);
await loadProducts(keyword); await loadProducts(keyword);
} catch (err: any) { } catch (err: any) {
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error"); toast(extractErrorMessage(err, "转交失败"), "error");
} finally { } finally {
setSubmitting(false); 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> className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100">全部折叠</button>
</div> </div>
</div> </div>
<form onSubmit={handleSearch} className="flex items-center gap-2"> <form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl"> <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" /> <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" />} {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
查询 查询
</button> </button>
<Button onClick={handleReset} disabled={loading}>重置</Button>
</form> </form>
{/* 状态筛选 Tabs(多选) */} {/* 状态筛选 Tabs(多选) */}
<div className="mt-3 flex gap-1.5 flex-wrap"> <div className="mt-3 flex gap-1.5 flex-wrap">
<button <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 ${ className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilters.size === 0 statusFilters.size === 0
? "bg-blue-600 text-white" ? "bg-blue-600 text-white"
@ -629,8 +697,8 @@ export default function AdminTasksPage() {
onClick={() => toggleStatusFilter(tab.key)} onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${ className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
active active
? "bg-blue-600 text-white" ? tab.accent ? "bg-purple-600 text-white" : "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200" : tab.accent ? "bg-purple-50 text-purple-700 hover:bg-purple-100" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`} }`}
> >
{tab.label} {tab.label}

View File

@ -3,6 +3,7 @@ import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } f
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons"; import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
import api from "../../services/api"; import api from "../../services/api";
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi"; import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
import { extractErrorMessage } from "../../utils/errorMessage";
// ============================================================ // ============================================================
// 类型 // 类型
@ -184,8 +185,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
}); });
setCreatedSn(data.serial_number); setCreatedSn(data.serial_number);
onCreated(); onCreated();
} catch (err: any) { } catch (err: unknown) {
message.error(err?.response?.data?.detail ?? "创建失败"); message.error(extractErrorMessage(err, "创建失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View File

@ -1,5 +1,6 @@
/** 认证 API — 登录、刷新 Token、获取用户信息 */ /** 认证 API — 登录、刷新 Token、获取用户信息 */
import type { UserInfo } from "../contexts/AuthContext"; import type { UserInfo } from "../contexts/AuthContext";
import { extractErrorMessage } from "../utils/errorMessage";
const API_BASE = import.meta.env.VITE_API_BASE_URL; const API_BASE = import.meta.env.VITE_API_BASE_URL;
@ -23,7 +24,7 @@ export async function login(username: string, password: string): Promise<LoginRe
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
throw new Error((err as any).detail ?? "登录失败"); throw new Error(extractErrorMessage(err, "登录失败"));
} }
return res.json(); return res.json();
} }

View File

@ -96,14 +96,17 @@ export async function receiveTask(taskId: string): Promise<TaskResponse> {
/** /**
* 品质驳回 — → REJECTED,后端自动创建返工任务。 * 品质驳回 — → REJECTED,后端自动创建返工任务。
* 对应后端 POST /api/v1/tasks/{taskId}/reject * 对应后端 POST /api/v1/tasks/{taskId}/reject
*
* images 为选填,支持空数组(编号错误、选错工序等场景无需拍照举证)。
*/ */
export async function rejectTask( export async function rejectTask(
taskId: string, taskId: string,
reason: string reason: string,
images: string[]
): Promise<TaskResponse> { ): Promise<TaskResponse> {
const { data } = await api.post<TaskResponse>( const { data } = await api.post<TaskResponse>(
`/tasks/${taskId}/reject`, `/tasks/${taskId}/reject`,
{ reason } satisfies TaskRejectPayload { reason, images } satisfies TaskRejectPayload
); );
return data; return data;
} }

View File

@ -81,6 +81,9 @@ export interface TaskListResponse {
export interface TaskRejectPayload { export interface TaskRejectPayload {
reason: string; reason: string;
/** 异常图片 URL 列表(选填,支持空数组,最多 9 张)
* 对应后端 TaskRejectRequest.images —— 缺失或空数组均合法 */
images: string[];
} }
export interface TaskTransferPayload { export interface TaskTransferPayload {
@ -99,6 +102,10 @@ export interface ProductScanResponse {
order_id: string; order_id: string;
order_no: string; order_no: string;
material_id: string | null; material_id: string | null;
/** 后端 schemas/product.py:83 返回,卡片展示用 */
material_name: string | null;
/** 后端 schemas/product.py:84 返回,卡片展示用 */
spec_model: string | null;
parent_product_id: string | null; parent_product_id: string | null;
current_location_id: string | null; current_location_id: string | null;
status: string; status: string;

View File

@ -0,0 +1,71 @@
/**
* 错误文案提取 — 把 FastAPI 的各类 detail 统一转成可读中文。
*
* 背景:各处 catch 里直接 `toast(err?.response?.data?.detail ?? "失败")` 是有坑的 ——
* 请求体校验失败(422)时 detail 是一个数组,React 拿到数组会把里面每个对象渲染成
* "[object Object]",用户看不懂,排查也无从下手。
*
* 这里对齐移动端 track-uniapp/src/utils/request.js 的 extractErrorDetail 行为,
* 覆盖三种形态:
* 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"}
* 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, ..."}]}
* 3. 对象: {"detail": {"msg": "..."}}
* 解析不出内容时回落到调用方给的 fallback,绝不把原始对象丢给 UI。
*/
/** loc 里这些是 FastAPI 的位置前缀,不是业务字段名,展示时去掉 */
const LOC_PREFIXES = new Set(["body", "query", "path", "header", "cookie"]);
/** 校验错误数组 → "字段: 原因;字段2: 原因2" */
function flattenValidationErrors(items: unknown[]): string {
const messages = items
.map((item) => {
if (typeof item === "string") return item;
if (!item || typeof item !== "object") return "";
const { msg, message, loc } = item as {
msg?: unknown;
message?: unknown;
loc?: unknown;
};
// Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,纯噪音,去掉
const text = String(msg ?? message ?? "").replace(/^Value error,\s*/, "");
const field = Array.isArray(loc)
? loc.filter((seg) => !LOC_PREFIXES.has(String(seg))).join(".")
: "";
return field && text ? `${field}: ${text}` : text;
})
.filter(Boolean);
// 同一字段可能有多条规则失败,去重后再拼,避免「images: x;images: x」
return Array.from(new Set(messages)).join(";");
}
/**
* 从任意抛出物里取出可读的中文报错。
* @param err catch 到的任意值(axios 错误、被抛出的裸 JSON、Error、字符串皆可)
* @param fallback 解析不出任何内容时的兜底文案
*/
export function extractErrorMessage(err: unknown, fallback = "操作失败"): string {
const responseData = (err as { response?: { data?: unknown } })?.response?.data;
// detail 优先取 axios 的 response.data.detail,其次兼容被直接抛出的裸对象/裸 JSON
const detail =
(responseData as { detail?: unknown } | undefined)?.detail ??
(err as { detail?: unknown })?.detail;
if (typeof detail === "string" && detail.trim()) return detail.trim();
if (Array.isArray(detail)) {
const flattened = flattenValidationErrors(detail);
if (flattened) return flattened;
}
if (detail && typeof detail === "object") {
const obj = detail as { msg?: unknown; message?: unknown };
const text = String(obj.msg ?? obj.message ?? "").trim();
if (text) return text;
}
// 后端直接返回字符串 body 的情况
if (typeof responseData === "string" && responseData.trim()) {
return responseData.trim();
}
// 兜底沿用 axios / fetch 的原生 message(如 "Request failed with status code 500")
if (err instanceof Error && err.message) return err.message;
return fallback;
}