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

@ -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<string[]>([]);
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
/>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">{reason.length}/500</span>
<div className="flex gap-2">
<button
type="button"
onClick={handleClose}
disabled={submitting}
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
type="submit"
disabled={submitting || !reason.trim()}
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"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</button>
</div>
<div className="mb-3 mt-1 text-right text-xs text-gray-400">{reason.length}/500</div>
<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
type="button"
onClick={handleClose}
disabled={submitting}
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
type="submit"
disabled={submitting || !reason.trim()}
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"
>
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</button>
</div>
</form>
</Modal>
@ -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);
}