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:
@ -73,6 +73,7 @@ export default function QrScanner({
|
||||
|
||||
// 3. 创建全新实例
|
||||
const scanner = new Html5Qrcode(SCANNER_ID, {
|
||||
verbose: false,
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
formatsToSupport: [
|
||||
Html5QrcodeSupportedFormats.CODE_128,
|
||||
|
||||
@ -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<string, string>;
|
||||
}) {
|
||||
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'
|
||||
? (<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>);
|
||||
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 ? (
|
||||
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
|
||||
{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">
|
||||
<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) && (
|
||||
<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"}`} />
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
133
frontend/src/components/ui/ImageUploader.tsx
Normal file
133
frontend/src/components/ui/ImageUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user