feat: 实现前端管理看板与移动端扫码视图组件
This commit is contained in:
224
frontend/src/components/QrScanner/QrScanner.tsx
Normal file
224
frontend/src/components/QrScanner/QrScanner.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 摄像头扫码组件 — 基于 html5-qrcode
|
||||
*
|
||||
* 策略:getCameras() 列出所有 → 关键词匹配后置 → 取末位
|
||||
* 如果 getCameras() 失败 → facingMode: "environment"
|
||||
* 用户可以点切换按钮循环切换摄像头
|
||||
*/
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
Html5Qrcode,
|
||||
Html5QrcodeSupportedFormats,
|
||||
type CameraDevice,
|
||||
} from "html5-qrcode";
|
||||
import { Camera, Loader2, Repeat } from "lucide-react";
|
||||
|
||||
interface QrScannerProps {
|
||||
onScan: (decodedText: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
active: boolean;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const SCANNER_ID = "qr-scanner-viewport";
|
||||
const REAR_KEYWORDS = ["back", "environment", "rear", "后置", "背面", "后面"];
|
||||
|
||||
export default function QrScanner({
|
||||
onScan,
|
||||
onError,
|
||||
active,
|
||||
width = "100%",
|
||||
height = "260px",
|
||||
}: QrScannerProps) {
|
||||
const scannerRef = useRef<Html5Qrcode | null>(null);
|
||||
const isRunningRef = useRef(false);
|
||||
const lastScanRef = useRef("");
|
||||
const cooldownRef = useRef(false);
|
||||
const [status, setStatus] = useState<"idle" | "starting" | "running" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [cameras, setCameras] = useState<CameraDevice[]>([]);
|
||||
const [currentCameraIdx, setCurrentCameraIdx] = useState(0);
|
||||
|
||||
const handleDecode = useCallback(
|
||||
(decodedText: string) => {
|
||||
if (cooldownRef.current && decodedText === lastScanRef.current) return;
|
||||
lastScanRef.current = decodedText;
|
||||
cooldownRef.current = true;
|
||||
setTimeout(() => { cooldownRef.current = false; }, 2000);
|
||||
if (navigator.vibrate) navigator.vibrate(100);
|
||||
onScan(decodedText);
|
||||
},
|
||||
[onScan]
|
||||
);
|
||||
|
||||
// 启动摄像头 — 每次用全新实例,确保摄像头硬件切换
|
||||
const startCamera = useCallback(
|
||||
async (cameraIdx: number, devices: CameraDevice[]) => {
|
||||
setCurrentCameraIdx(cameraIdx);
|
||||
setStatus("starting");
|
||||
|
||||
try {
|
||||
// 1. 彻底销毁旧实例
|
||||
if (scannerRef.current) {
|
||||
try { await scannerRef.current.stop(); } catch {}
|
||||
try { await scannerRef.current.clear(); } catch {}
|
||||
scannerRef.current = null;
|
||||
}
|
||||
isRunningRef.current = false;
|
||||
|
||||
// 2. 清空容器 DOM(避免残留 video 元素)
|
||||
const container = document.getElementById(SCANNER_ID);
|
||||
if (container) container.innerHTML = "";
|
||||
|
||||
// 3. 创建全新实例
|
||||
const scanner = new Html5Qrcode(SCANNER_ID, {
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
formatsToSupport: [
|
||||
Html5QrcodeSupportedFormats.CODE_128,
|
||||
Html5QrcodeSupportedFormats.QR_CODE,
|
||||
],
|
||||
});
|
||||
scannerRef.current = scanner;
|
||||
|
||||
// 4. 选择摄像头
|
||||
const cameraConfig =
|
||||
devices.length > 0 && cameraIdx < devices.length
|
||||
? { deviceId: { exact: devices[cameraIdx].id } }
|
||||
: { facingMode: "environment" as const };
|
||||
|
||||
// 5. 启动
|
||||
await scanner.start(
|
||||
cameraConfig,
|
||||
{
|
||||
fps: 20,
|
||||
videoConstraints: {
|
||||
width: { min: 1280, ideal: 2560 },
|
||||
height: { min: 720, ideal: 1440 },
|
||||
},
|
||||
},
|
||||
handleDecode,
|
||||
() => {}
|
||||
);
|
||||
isRunningRef.current = true;
|
||||
setStatus("running");
|
||||
} catch (err: unknown) {
|
||||
setStatus("error");
|
||||
setErrorMsg(err instanceof Error ? err.message : "启动失败");
|
||||
onError?.(err instanceof Error ? err.message : "无法启动摄像头");
|
||||
}
|
||||
},
|
||||
[handleDecode, onError]
|
||||
);
|
||||
|
||||
// 切换摄像头
|
||||
const switchCamera = useCallback(async () => {
|
||||
if (cameras.length < 2) return;
|
||||
const nextIdx = (currentCameraIdx + 1) % cameras.length;
|
||||
await startCamera(nextIdx, cameras);
|
||||
}, [cameras, currentCameraIdx, startCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!active) {
|
||||
if (scannerRef.current) {
|
||||
try { scannerRef.current.stop(); } catch {}
|
||||
try { scannerRef.current.clear(); } catch {}
|
||||
scannerRef.current = null;
|
||||
}
|
||||
isRunningRef.current = false;
|
||||
const container = document.getElementById(SCANNER_ID);
|
||||
if (container) container.innerHTML = "";
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
// 枚举摄像头
|
||||
let devices: CameraDevice[] = [];
|
||||
try {
|
||||
devices = await Html5Qrcode.getCameras();
|
||||
} catch {}
|
||||
if (cancelled) return;
|
||||
setCameras(devices);
|
||||
|
||||
// 选初始摄像头:关键词匹配后置 → 末位
|
||||
let startIdx = 0;
|
||||
if (devices.length > 0) {
|
||||
const rearIdx = devices.findIndex((d) =>
|
||||
REAR_KEYWORDS.some((kw) => d.label.toLowerCase().includes(kw))
|
||||
);
|
||||
startIdx = rearIdx >= 0 ? rearIdx : devices.length - 1;
|
||||
}
|
||||
|
||||
await startCamera(startIdx, devices);
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (scannerRef.current) {
|
||||
try { scannerRef.current.stop(); } catch {}
|
||||
try { scannerRef.current.clear(); } catch {}
|
||||
scannerRef.current = null;
|
||||
}
|
||||
isRunningRef.current = false;
|
||||
};
|
||||
}, [active, startCamera, onError]);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-xl bg-black">
|
||||
<div
|
||||
id={SCANNER_ID}
|
||||
style={{ width, height, minHeight: "260px" }}
|
||||
className="[&_video]:object-cover [&_video]:!w-full [&_video]:!h-full"
|
||||
/>
|
||||
|
||||
{!active && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white">
|
||||
<Camera className="mb-3 h-12 w-12 text-gray-500" />
|
||||
<p className="text-sm text-gray-400">点击按钮启动后置摄像头</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "starting" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/80 text-white">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "running" && (
|
||||
<>
|
||||
{/* 当前摄像头标签 */}
|
||||
<div className="absolute left-1/2 top-3 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1.5 backdrop-blur-sm">
|
||||
<span className="text-xs text-white">
|
||||
{cameras.length > 0 && currentCameraIdx < cameras.length
|
||||
? cameras[currentCameraIdx].label
|
||||
: "摄像头"} · 扫描中
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 切换摄像头按钮 — 只在有多个摄像头时显示 */}
|
||||
{cameras.length >= 2 && (
|
||||
<button
|
||||
onClick={switchCamera}
|
||||
className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-black/50 px-3 py-1.5 text-xs text-white backdrop-blur-sm transition-colors active:bg-black/70"
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
切换
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white px-6 text-center gap-2">
|
||||
<p className="text-sm font-medium text-red-400">摄像头不可用</p>
|
||||
<p className="text-xs text-gray-400">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/layout/AdminLayout.tsx
Normal file
87
frontend/src/components/layout/AdminLayout.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone } from "lucide-react";
|
||||
|
||||
const MENU = [
|
||||
{
|
||||
title: "全局概览",
|
||||
path: "/admin/dashboard",
|
||||
icon: LayoutDashboard,
|
||||
description: "生产数据总览 · 产品 + 任务统计",
|
||||
},
|
||||
{
|
||||
title: "产品管理",
|
||||
path: "/admin/products",
|
||||
icon: Package,
|
||||
description: "创建产品 · 生成二维码 · 打印标签",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
{/* 侧边栏 */}
|
||||
<aside className="fixed left-0 top-0 z-40 flex h-screen w-56 flex-col border-r border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 px-5 py-4">
|
||||
<QrCode className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-bold text-gray-800">生产流转 · 管理端</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 px-3 py-4">
|
||||
{MENU.map((item) => {
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors ${
|
||||
isActive ? "bg-blue-50 text-blue-700" : "text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`mt-0.5 h-4 w-4 shrink-0 ${isActive ? "text-blue-600" : "text-gray-400"}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-xs text-gray-400">{item.description}</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="space-y-1 border-t border-gray-100 px-5 py-3">
|
||||
<a
|
||||
href="/scan"
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-green-600 transition-colors hover:bg-green-50"
|
||||
>
|
||||
<Smartphone className="h-3.5 w-3.5" />
|
||||
模拟手机扫码
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-gray-400 transition-colors hover:text-gray-600"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
移动端首页
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="ml-56 flex-1">
|
||||
<div className="sticky top-0 z-30 border-b border-gray-200 bg-white px-6 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<LayoutDashboard className="h-3 w-3" />
|
||||
<span>管理端</span>
|
||||
<span>/</span>
|
||||
<span className="font-medium text-gray-600">
|
||||
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/layout/AppLayout.tsx
Normal file
49
frontend/src/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { ScanLine, ClipboardList, Bell, User } from "lucide-react";
|
||||
|
||||
/** 底部导航 Tab 配置 */
|
||||
const TABS = [
|
||||
{ path: "/scan", label: "扫码干活", icon: ScanLine },
|
||||
{ path: "/tasks", label: "我的任务", icon: ClipboardList },
|
||||
{ path: "/notifications", label: "消息", icon: Bell },
|
||||
{ path: "/profile", label: "我的", icon: User },
|
||||
] as const;
|
||||
|
||||
/** 导航栏高度(供页面计算偏移量) */
|
||||
export const TAB_BAR_HEIGHT = 64; // px(h-16)
|
||||
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col bg-gray-50">
|
||||
{/* ======== 主内容区 ======== */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* ======== 底部导航栏 ======== */}
|
||||
<nav
|
||||
className="fixed bottom-0 z-50 w-full border-t border-gray-200 bg-white pb-safe"
|
||||
style={{ height: TAB_BAR_HEIGHT }}
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-lg items-center justify-around">
|
||||
{TABS.map(({ path, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-0.5 px-3 py-1 transition-colors ${
|
||||
isActive
|
||||
? "text-blue-600"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
<span className="text-[10px] font-medium leading-none">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/scan/CameraScanner.tsx
Normal file
58
frontend/src/components/scan/CameraScanner.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
/** 摄像头扫码区域 — 包含取景框 + 启停按钮 + 错误提示 */
|
||||
import { Camera, CameraOff } from "lucide-react";
|
||||
import QrScanner from "../QrScanner/QrScanner";
|
||||
|
||||
interface CameraScannerProps {
|
||||
active: boolean;
|
||||
onToggle: (on: boolean) => void;
|
||||
onScan: (text: string) => void;
|
||||
error: string | null;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
export default function CameraScanner({
|
||||
active,
|
||||
onToggle,
|
||||
onScan,
|
||||
error,
|
||||
onError,
|
||||
}: CameraScannerProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-2 shadow-sm">
|
||||
<QrScanner
|
||||
onScan={onScan}
|
||||
onError={onError}
|
||||
active={active}
|
||||
height="260px"
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
{!active ? (
|
||||
<button
|
||||
onClick={() => onToggle(true)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white active:bg-blue-700"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
启动摄像头
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onToggle(false)}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-2.5 text-sm text-gray-500 active:bg-gray-50"
|
||||
>
|
||||
<CameraOff className="h-4 w-4" />
|
||||
关闭摄像头
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
<strong>摄像头不可用:</strong>{error}
|
||||
<br />
|
||||
请在<strong>手机浏览器</strong>中打开此页面使用扫码,或使用下方手动输入。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/scan/ManualInput.tsx
Normal file
56
frontend/src/components/scan/ManualInput.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
/** 手动输入序列号区域 */
|
||||
import { Search, Loader2 } from "lucide-react";
|
||||
|
||||
interface ManualInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSearch: () => void;
|
||||
loading: boolean;
|
||||
lastScanned: string;
|
||||
}
|
||||
|
||||
export default function ManualInput({
|
||||
value,
|
||||
onChange,
|
||||
onSearch,
|
||||
loading,
|
||||
lastScanned,
|
||||
}: ManualInputProps) {
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") onSearch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="手动输入16位序列号"
|
||||
maxLength={16}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-4 py-3 text-sm focus:border-blue-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={onSearch}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-3 text-sm font-medium text-white active:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="h-4 w-4" />
|
||||
)}
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
{lastScanned && (
|
||||
<p className="mt-1.5 text-xs text-gray-400">
|
||||
最近扫描: <span className="font-mono text-gray-600">{lastScanned}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/scan/ProductCard.tsx
Normal file
53
frontend/src/components/scan/ProductCard.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
/** 产品信息卡片 */
|
||||
import { Package } from "lucide-react";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "待处理",
|
||||
in_progress: "进行中",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "pending": return "bg-yellow-100 text-yellow-700";
|
||||
case "in_progress": return "bg-blue-100 text-blue-700";
|
||||
case "completed": return "bg-green-100 text-green-700";
|
||||
default: return "bg-gray-100 text-gray-600";
|
||||
}
|
||||
}
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductScanResponse;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-800">产品信息</h3>
|
||||
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(product.status)}`}>
|
||||
{STATUS_LABELS[product.status] ?? product.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">序列号</span>
|
||||
<p className="font-mono font-medium text-gray-800">{product.serial_number}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">订单编号</span>
|
||||
<p className="font-medium text-gray-800">{product.order_no}</p>
|
||||
</div>
|
||||
{product.material_id && (
|
||||
<div>
|
||||
<span className="text-gray-400">物料 ID</span>
|
||||
<p className="font-medium text-gray-800">{product.material_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/components/scan/QueryResult.tsx
Normal file
42
frontend/src/components/scan/QueryResult.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
/** 查询结果区域 — 加载中 / 错误 / 产品卡片 + 任务列表 */
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import ProductCard from "./ProductCard";
|
||||
import TaskListCard from "./TaskListCard";
|
||||
|
||||
interface QueryResultProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
product: ProductScanResponse | null;
|
||||
}
|
||||
|
||||
export default function QueryResult({ loading, error, product }: QueryResultProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
查询中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (product) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ProductCard product={product} />
|
||||
<TaskListCard tasks={product.top_level_tasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
56
frontend/src/components/scan/TaskListCard.tsx
Normal file
56
frontend/src/components/scan/TaskListCard.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
/** 任务进度列表卡片 */
|
||||
import { ClipboardList, ChevronRight } from "lucide-react";
|
||||
import type { TaskSummary } from "../../types/api";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "待处理",
|
||||
in_progress: "进行中",
|
||||
completed: "已完成",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "pending": return "bg-yellow-100 text-yellow-700";
|
||||
case "in_progress": return "bg-blue-100 text-blue-700";
|
||||
case "completed": return "bg-green-100 text-green-700";
|
||||
default: return "bg-gray-100 text-gray-600";
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskListCardProps {
|
||||
tasks: TaskSummary[];
|
||||
}
|
||||
|
||||
export default function TaskListCard({ tasks }: TaskListCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 px-4 py-3">
|
||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-800">当前进度</h3>
|
||||
<span className="ml-auto text-xs text-gray-400">{tasks.length} 个任务</span>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-400">暂无关联任务</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-800">{task.task_name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
负责人: {task.assignee_id ?? "未分配"}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${statusColor(task.status)}`}>
|
||||
{STATUS_LABELS[task.status] ?? task.status}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-gray-300" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/pages/MyTasksPage.tsx
Normal file
29
frontend/src/pages/MyTasksPage.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
/** 待办 — 我的任务列表 */
|
||||
export default function MyTasksPage() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
<h2 className="text-xl font-bold text-gray-800">我的任务</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">待处理和进行中的任务</p>
|
||||
|
||||
{/* 占位空状态 */}
|
||||
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg
|
||||
className="h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">暂无待办任务</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/pages/NotificationsPage.tsx
Normal file
29
frontend/src/pages/NotificationsPage.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
/** 消息 — 通知推送 */
|
||||
export default function NotificationsPage() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
<h2 className="text-xl font-bold text-gray-800">消息通知</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">任务流转和系统通知</p>
|
||||
|
||||
{/* 占位空状态 */}
|
||||
<div className="mt-12 flex flex-1 flex-col items-center justify-center">
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
||||
<svg
|
||||
className="h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">暂无新消息</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/pages/ProfilePage.tsx
Normal file
35
frontend/src/pages/ProfilePage.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
/** 我的 — 个人中心 */
|
||||
export default function ProfilePage() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col px-4 pt-safe">
|
||||
{/* 用户信息卡片 */}
|
||||
<div className="mt-4 flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 text-xl font-bold text-blue-600">
|
||||
张
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-800">张三</h3>
|
||||
<p className="text-sm text-gray-500">操作员</p>
|
||||
</div>
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 菜单列表占位 */}
|
||||
<div className="mt-6 space-y-1 rounded-xl bg-white shadow-sm">
|
||||
{["工作统计", "设置", "帮助与反馈", "关于"].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex items-center justify-between border-b border-gray-50 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<span className="text-sm text-gray-700">{item}</span>
|
||||
<svg className="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/pages/ScanPage.tsx
Normal file
84
frontend/src/pages/ScanPage.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
|
||||
import { useState, useCallback } from "react";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { scanProduct } from "../services/productApi";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
import CameraScanner from "../components/scan/CameraScanner";
|
||||
import ManualInput from "../components/scan/ManualInput";
|
||||
import QueryResult from "../components/scan/QueryResult";
|
||||
|
||||
export default function ScanPage() {
|
||||
const [cameraActive, setCameraActive] = useState(false);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const [serialNumber, setSerialNumber] = useState("");
|
||||
const [lastScanned, setLastScanned] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [product, setProduct] = useState<ProductScanResponse | null>(null);
|
||||
|
||||
/** 通用查询 */
|
||||
const doQuery = useCallback(async (sn: string) => {
|
||||
if (sn.length < 8) { setError("序列号至少需要 8 位"); return; }
|
||||
setSerialNumber(sn);
|
||||
setLastScanned(sn);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setProduct(null);
|
||||
try {
|
||||
setProduct(await scanProduct(sn));
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
err && typeof err === "object" && "response" in err
|
||||
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: null;
|
||||
setError(detail ?? "网络错误,请检查后端服务");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 扫码回调:提取纯序列号 */
|
||||
const handleScan = useCallback(
|
||||
(decodedText: string) => {
|
||||
setCameraError(null);
|
||||
doQuery(decodedText.trim().replace(/[^a-zA-Z0-9]/g, "").slice(0, 16));
|
||||
},
|
||||
[doQuery]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col pb-20 pt-safe">
|
||||
<div className="flex items-center gap-2 px-4 pt-2">
|
||||
<QrCode className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-lg font-bold text-gray-800">扫码干活</h2>
|
||||
</div>
|
||||
|
||||
{/* 摄像头扫码 */}
|
||||
<div className="mt-3 px-4">
|
||||
<CameraScanner
|
||||
active={cameraActive}
|
||||
onToggle={setCameraActive}
|
||||
onScan={handleScan}
|
||||
error={cameraError}
|
||||
onError={setCameraError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 手动输入 */}
|
||||
<div className="mt-3 px-4">
|
||||
<ManualInput
|
||||
value={serialNumber}
|
||||
onChange={setSerialNumber}
|
||||
onSearch={() => doQuery(serialNumber.trim())}
|
||||
loading={loading}
|
||||
lastScanned={lastScanned}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 查询结果 */}
|
||||
<div className="mt-3 px-4">
|
||||
<QueryResult loading={loading} error={error} product={product} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/admin/AdminDashboard.tsx
Normal file
81
frontend/src/pages/admin/AdminDashboard.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, ClipboardList, Loader2, AlertCircle } from "lucide-react";
|
||||
import { fetchDashboardStats, type DashboardStats } from "../../services/dashboardApi";
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
total,
|
||||
pending,
|
||||
progress,
|
||||
done,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
total: number;
|
||||
pending: number;
|
||||
progress: number;
|
||||
done: number;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-gray-800">{label}</h3>
|
||||
<span className="ml-auto text-2xl font-bold text-gray-800">{total}</span>
|
||||
</div>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-gray-100">
|
||||
{pending > 0 && (
|
||||
<div className="bg-yellow-400" style={{ width: `${(pending / Math.max(total, 1)) * 100}%` }} />
|
||||
)}
|
||||
{progress > 0 && (
|
||||
<div className="bg-blue-500" style={{ width: `${(progress / Math.max(total, 1)) * 100}%` }} />
|
||||
)}
|
||||
{done > 0 && (
|
||||
<div className="bg-green-500" style={{ width: `${(done / Math.max(total, 1)) * 100}%` }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-4 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-yellow-400" />待处理 {pending}</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-blue-500" />进行中 {progress}</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-green-500" />已完成 {done}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardStats()
|
||||
.then(setStats)
|
||||
.catch(() => setError("加载统计数据失败,请确认后端已启动"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"><AlertCircle className="h-4 w-4" />{error}</div>;
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-800">全局生产概览</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">PC端与移动端共享同一后台数据</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<StatCard label="产品统计" total={stats.products_total} pending={stats.products_pending} progress={stats.products_in_progress} done={stats.products_completed} icon={Package} />
|
||||
<StatCard label="任务统计" total={stats.tasks_total} pending={stats.tasks_pending} progress={stats.tasks_in_progress} done={stats.tasks_completed} icon={ClipboardList} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
frontend/src/pages/admin/AdminProductsPage.tsx
Normal file
153
frontend/src/pages/admin/AdminProductsPage.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Printer, RefreshCw, Loader2, QrCode, Plus } from "lucide-react";
|
||||
import api from "../../services/api";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import CreateProductDialog from "./CreateProductDialog";
|
||||
|
||||
const QR_BASE = "/api/v1/products/qrcode";
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
async function loadProducts() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await api.get<ProductResponse[]>("/products/");
|
||||
setProducts(data);
|
||||
} catch {
|
||||
setError("加载产品列表失败,请检查后端服务");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProducts();
|
||||
}, []);
|
||||
|
||||
/** 打印单个二维码 */
|
||||
function handlePrint(serialNumber: string) {
|
||||
const qrUrl = `${QR_BASE}/${serialNumber}`;
|
||||
const w = window.open("", "_blank", "width=400,height=500");
|
||||
if (!w) return;
|
||||
w.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>打印标签 - ${serialNumber}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { display: flex; flex-direction: column; align-items: center; padding: 24px; font-family: monospace; }
|
||||
img { width: 280px; height: 280px; image-rendering: pixelated; }
|
||||
.sn { margin-top: 12px; font-size: 22px; font-weight: bold; letter-spacing: 2px; color: #1f2937; }
|
||||
.hint { margin-top: 8px; font-size: 12px; color: #9ca3af; }
|
||||
@media print { body { padding: 0; } img { width: 260px; height: 260px; } }
|
||||
</style></head>
|
||||
<body>
|
||||
<img src="${qrUrl}" alt="QR-${serialNumber}" />
|
||||
<p class="sn">${serialNumber}</p>
|
||||
<p class="hint">扫描二维码查询生产进度</p>
|
||||
<script>window.onload=()=>window.print()</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
w.document.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 标题栏 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">产品管理</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
查看所有产品序列号并生成二维码用于打印标签
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
创建产品
|
||||
</button>
|
||||
<button
|
||||
onClick={loadProducts}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误 */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载中 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 产品列表 */}
|
||||
{!loading && products.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||
<QrCode className="mb-3 h-12 w-12" />
|
||||
<p>暂无产品数据</p>
|
||||
<p className="mt-1 text-sm">创建产品后将在此显示二维码</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && products.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{products.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col items-center rounded-xl bg-white p-4 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
{/* 二维码 */}
|
||||
<img
|
||||
src={`${QR_BASE}/${p.serial_number}`}
|
||||
alt={`QR-${p.serial_number}`}
|
||||
className="h-40 w-40 rounded-lg border border-gray-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* 序列号 */}
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">
|
||||
{p.serial_number}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400">
|
||||
{p.order_no ?? "—"}
|
||||
</p>
|
||||
|
||||
{/* 打印按钮 */}
|
||||
<button
|
||||
onClick={() => handlePrint(p.serial_number)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-gray-200 py-2 text-xs font-medium text-gray-600 transition-colors hover:bg-blue-50 hover:text-blue-700 hover:border-blue-200"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
打印标签
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<CreateProductDialog
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadProducts}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
frontend/src/pages/admin/CreateProductDialog.tsx
Normal file
189
frontend/src/pages/admin/CreateProductDialog.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Loader2, QrCode } from "lucide-react";
|
||||
import api from "../../services/api";
|
||||
import { listOrders, type OrderOption } from "../../services/orderApi";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
/** 生成 16 位随机 hex 序列号 */
|
||||
function genSerial(): string {
|
||||
const chars = "0123456789ABCDEF";
|
||||
let s = "";
|
||||
for (let i = 0; i < 16; i++) {
|
||||
s += chars[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export default function CreateProductDialog({ open, onClose, onCreated }: Props) {
|
||||
const [orders, setOrders] = useState<OrderOption[]>([]);
|
||||
const [serialNumber, setSerialNumber] = useState(genSerial());
|
||||
const [orderId, setOrderId] = useState("");
|
||||
const [materialId, setMaterialId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createdSn, setCreatedSn] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSerialNumber(genSerial());
|
||||
setError(null);
|
||||
setCreatedSn(null);
|
||||
listOrders()
|
||||
.then(setOrders)
|
||||
.catch(() => setError("加载订单列表失败"));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!orderId) { setError("请选择订单"); return; }
|
||||
if (serialNumber.length !== 16) { setError("序列号必须为16位"); return; }
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.post("/products/", {
|
||||
serial_number: serialNumber,
|
||||
order_id: orderId,
|
||||
material_id: materialId || null,
|
||||
});
|
||||
setCreatedSn(serialNumber);
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
err && typeof err === "object" && "response" in err
|
||||
? (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
|
||||
: null;
|
||||
setError(
|
||||
typeof detail === "string"
|
||||
? detail
|
||||
: JSON.stringify(detail) || "创建失败"
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">创建产品</h3>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 创建成功 — 展示二维码 */}
|
||||
{createdSn ? (
|
||||
<div className="flex flex-col items-center px-6 py-8">
|
||||
<img
|
||||
src={`/api/v1/products/qrcode/${createdSn}`}
|
||||
alt={`QR-${createdSn}`}
|
||||
className="h-52 w-52 rounded-lg border"
|
||||
/>
|
||||
<p className="mt-4 font-mono text-lg font-bold tracking-wider text-gray-800">
|
||||
{createdSn}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-green-600">产品创建成功!</p>
|
||||
<div className="mt-6 flex w-full gap-3">
|
||||
<button
|
||||
onClick={() => window.open(`/api/v1/products/qrcode/${createdSn}`, "_blank")}
|
||||
className="flex-1 rounded-lg border border-gray-200 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
打开二维码
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreatedSn(null);
|
||||
setSerialNumber(genSerial());
|
||||
}}
|
||||
className="flex-1 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
继续创建
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={onClose} className="mt-3 w-full rounded-lg py-2 text-sm text-gray-400 hover:text-gray-600">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* 表单 */
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||
{/* 序列号 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">产品序列号 (16位)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={serialNumber}
|
||||
onChange={(e) => setSerialNumber(e.target.value.toUpperCase())}
|
||||
maxLength={16}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="16位HEX序列号"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSerialNumber(genSerial())}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-xs text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
随机
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 订单 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">所属订单</label>
|
||||
<select
|
||||
value={orderId}
|
||||
onChange={(e) => setOrderId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="">请选择订单</option>
|
||||
{orders.map((o) => (
|
||||
<option key={o.id} value={o.id}>{o.order_no}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 物料 ID */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
物料 ID <span className="text-gray-400">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
value={materialId}
|
||||
onChange={(e) => setMaterialId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="关联老系统物料"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <QrCode className="h-4 w-4" />}
|
||||
创建并生成二维码
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user