From 340a09007e6d4f12fc63b0900d7e24bf5f3f4b04 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Tue, 4 Aug 2026 17:10:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=9C=8B=E6=9D=BF=E4=B8=8E=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E6=89=AB=E7=A0=81=E8=A7=86=E5=9B=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/QrScanner/QrScanner.tsx | 224 ++++++++++++++++++ .../src/components/layout/AdminLayout.tsx | 87 +++++++ frontend/src/components/layout/AppLayout.tsx | 49 ++++ .../src/components/scan/CameraScanner.tsx | 58 +++++ frontend/src/components/scan/ManualInput.tsx | 56 +++++ frontend/src/components/scan/ProductCard.tsx | 53 +++++ frontend/src/components/scan/QueryResult.tsx | 42 ++++ frontend/src/components/scan/TaskListCard.tsx | 56 +++++ frontend/src/pages/MyTasksPage.tsx | 29 +++ frontend/src/pages/NotificationsPage.tsx | 29 +++ frontend/src/pages/ProfilePage.tsx | 35 +++ frontend/src/pages/ScanPage.tsx | 84 +++++++ frontend/src/pages/admin/AdminDashboard.tsx | 81 +++++++ .../src/pages/admin/AdminProductsPage.tsx | 153 ++++++++++++ .../src/pages/admin/CreateProductDialog.tsx | 189 +++++++++++++++ 15 files changed, 1225 insertions(+) create mode 100644 frontend/src/components/QrScanner/QrScanner.tsx create mode 100644 frontend/src/components/layout/AdminLayout.tsx create mode 100644 frontend/src/components/layout/AppLayout.tsx create mode 100644 frontend/src/components/scan/CameraScanner.tsx create mode 100644 frontend/src/components/scan/ManualInput.tsx create mode 100644 frontend/src/components/scan/ProductCard.tsx create mode 100644 frontend/src/components/scan/QueryResult.tsx create mode 100644 frontend/src/components/scan/TaskListCard.tsx create mode 100644 frontend/src/pages/MyTasksPage.tsx create mode 100644 frontend/src/pages/NotificationsPage.tsx create mode 100644 frontend/src/pages/ProfilePage.tsx create mode 100644 frontend/src/pages/ScanPage.tsx create mode 100644 frontend/src/pages/admin/AdminDashboard.tsx create mode 100644 frontend/src/pages/admin/AdminProductsPage.tsx create mode 100644 frontend/src/pages/admin/CreateProductDialog.tsx diff --git a/frontend/src/components/QrScanner/QrScanner.tsx b/frontend/src/components/QrScanner/QrScanner.tsx new file mode 100644 index 0000000..3b96710 --- /dev/null +++ b/frontend/src/components/QrScanner/QrScanner.tsx @@ -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(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([]); + 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 ( +
+
+ + {!active && ( +
+ +

点击按钮启动后置摄像头

+
+ )} + + {status === "starting" && ( +
+ +
+ )} + + {status === "running" && ( + <> + {/* 当前摄像头标签 */} +
+ + {cameras.length > 0 && currentCameraIdx < cameras.length + ? cameras[currentCameraIdx].label + : "摄像头"} · 扫描中 + +
+ + {/* 切换摄像头按钮 — 只在有多个摄像头时显示 */} + {cameras.length >= 2 && ( + + )} + + )} + + {status === "error" && ( +
+

摄像头不可用

+

{errorMsg}

+
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx new file mode 100644 index 0000000..18c7dd1 --- /dev/null +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -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 ( +
+ {/* 侧边栏 */} + + +
+
+
+ + 管理端 + / + + {MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"} + +
+
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..644dd94 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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 ( +
+ {/* ======== 主内容区 ======== */} +
+ +
+ + {/* ======== 底部导航栏 ======== */} + +
+ ); +} diff --git a/frontend/src/components/scan/CameraScanner.tsx b/frontend/src/components/scan/CameraScanner.tsx new file mode 100644 index 0000000..bcc02c4 --- /dev/null +++ b/frontend/src/components/scan/CameraScanner.tsx @@ -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 ( +
+ + +
+ {!active ? ( + + ) : ( + + )} +
+ + {error && ( +
+ 摄像头不可用:{error} +
+ 请在手机浏览器中打开此页面使用扫码,或使用下方手动输入。 +
+ )} +
+ ); +} diff --git a/frontend/src/components/scan/ManualInput.tsx b/frontend/src/components/scan/ManualInput.tsx new file mode 100644 index 0000000..de36ca2 --- /dev/null +++ b/frontend/src/components/scan/ManualInput.tsx @@ -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 ( +
+
+ 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" + /> + +
+ {lastScanned && ( +

+ 最近扫描: {lastScanned} +

+ )} +
+ ); +} diff --git a/frontend/src/components/scan/ProductCard.tsx b/frontend/src/components/scan/ProductCard.tsx new file mode 100644 index 0000000..1222a88 --- /dev/null +++ b/frontend/src/components/scan/ProductCard.tsx @@ -0,0 +1,53 @@ +/** 产品信息卡片 */ +import { Package } from "lucide-react"; +import type { ProductScanResponse } from "../../types/api"; + +const STATUS_LABELS: Record = { + 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 ( +
+
+ +

产品信息

+ + {STATUS_LABELS[product.status] ?? product.status} + +
+
+
+ 序列号 +

{product.serial_number}

+
+
+ 订单编号 +

{product.order_no}

+
+ {product.material_id && ( +
+ 物料 ID +

{product.material_id}

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/scan/QueryResult.tsx b/frontend/src/components/scan/QueryResult.tsx new file mode 100644 index 0000000..2df73dc --- /dev/null +++ b/frontend/src/components/scan/QueryResult.tsx @@ -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 ( +
+ + 查询中... +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (product) { + return ( +
+ + +
+ ); + } + + return null; +} diff --git a/frontend/src/components/scan/TaskListCard.tsx b/frontend/src/components/scan/TaskListCard.tsx new file mode 100644 index 0000000..2e19577 --- /dev/null +++ b/frontend/src/components/scan/TaskListCard.tsx @@ -0,0 +1,56 @@ +/** 任务进度列表卡片 */ +import { ClipboardList, ChevronRight } from "lucide-react"; +import type { TaskSummary } from "../../types/api"; + +const STATUS_LABELS: Record = { + 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 ( +
+
+ +

当前进度

+ {tasks.length} 个任务 +
+ + {tasks.length === 0 ? ( +
暂无关联任务
+ ) : ( +
+ {tasks.map((task) => ( +
+
+

{task.task_name}

+

+ 负责人: {task.assignee_id ?? "未分配"} +

+
+ + {STATUS_LABELS[task.status] ?? task.status} + + +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/MyTasksPage.tsx b/frontend/src/pages/MyTasksPage.tsx new file mode 100644 index 0000000..382560e --- /dev/null +++ b/frontend/src/pages/MyTasksPage.tsx @@ -0,0 +1,29 @@ +/** 待办 — 我的任务列表 */ +export default function MyTasksPage() { + return ( +
+

我的任务

+

待处理和进行中的任务

+ + {/* 占位空状态 */} +
+
+ + + +
+

暂无待办任务

+
+
+ ); +} diff --git a/frontend/src/pages/NotificationsPage.tsx b/frontend/src/pages/NotificationsPage.tsx new file mode 100644 index 0000000..5b8cdb5 --- /dev/null +++ b/frontend/src/pages/NotificationsPage.tsx @@ -0,0 +1,29 @@ +/** 消息 — 通知推送 */ +export default function NotificationsPage() { + return ( +
+

消息通知

+

任务流转和系统通知

+ + {/* 占位空状态 */} +
+
+ + + +
+

暂无新消息

+
+
+ ); +} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..b7e75c3 --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -0,0 +1,35 @@ +/** 我的 — 个人中心 */ +export default function ProfilePage() { + return ( +
+ {/* 用户信息卡片 */} +
+
+ 张 +
+
+

张三

+

操作员

+
+ + + +
+ + {/* 菜单列表占位 */} +
+ {["工作统计", "设置", "帮助与反馈", "关于"].map((item) => ( +
+ {item} + + + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/ScanPage.tsx b/frontend/src/pages/ScanPage.tsx new file mode 100644 index 0000000..88c982d --- /dev/null +++ b/frontend/src/pages/ScanPage.tsx @@ -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(null); + const [serialNumber, setSerialNumber] = useState(""); + const [lastScanned, setLastScanned] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [product, setProduct] = useState(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 ( +
+
+ +

扫码干活

+
+ + {/* 摄像头扫码 */} +
+ +
+ + {/* 手动输入 */} +
+ doQuery(serialNumber.trim())} + loading={loading} + lastScanned={lastScanned} + /> +
+ + {/* 查询结果 */} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx new file mode 100644 index 0000000..004a9a2 --- /dev/null +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -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 ( +
+
+ +

{label}

+ {total} +
+
+ {pending > 0 && ( +
+ )} + {progress > 0 && ( +
+ )} + {done > 0 && ( +
+ )} +
+
+ 待处理 {pending} + 进行中 {progress} + 已完成 {done} +
+
+ ); +} + +export default function AdminDashboard() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchDashboardStats() + .then(setStats) + .catch(() => setError("加载统计数据失败,请确认后端已启动")) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return
; + } + + if (error) { + return
{error}
; + } + + if (!stats) return null; + + return ( +
+
+

全局生产概览

+

PC端与移动端共享同一后台数据

+
+
+ + +
+
+ ); +} diff --git a/frontend/src/pages/admin/AdminProductsPage.tsx b/frontend/src/pages/admin/AdminProductsPage.tsx new file mode 100644 index 0000000..ed347e9 --- /dev/null +++ b/frontend/src/pages/admin/AdminProductsPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + + async function loadProducts() { + setLoading(true); + setError(null); + try { + const { data } = await api.get("/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(` + + + 打印标签 - ${serialNumber} + + + QR-${serialNumber} +

${serialNumber}

+

扫描二维码查询生产进度

+ + + + `); + w.document.close(); + } + + return ( +
+ {/* 标题栏 */} +
+
+

产品管理

+

+ 查看所有产品序列号并生成二维码用于打印标签 +

+
+
+ + +
+
+ + {/* 错误 */} + {error && ( +
+ {error} +
+ )} + + {/* 加载中 */} + {loading && ( +
+ +
+ )} + + {/* 产品列表 */} + {!loading && products.length === 0 && !error && ( +
+ +

暂无产品数据

+

创建产品后将在此显示二维码

+
+ )} + + {!loading && products.length > 0 && ( +
+ {products.map((p) => ( +
+ {/* 二维码 */} + {`QR-${p.serial_number}`} + + {/* 序列号 */} +

+ {p.serial_number} +

+

+ {p.order_no ?? "—"} +

+ + {/* 打印按钮 */} + +
+ ))} +
+ )} + setShowCreate(false)} + onCreated={loadProducts} + /> +
+ ); +} diff --git a/frontend/src/pages/admin/CreateProductDialog.tsx b/frontend/src/pages/admin/CreateProductDialog.tsx new file mode 100644 index 0000000..d717e55 --- /dev/null +++ b/frontend/src/pages/admin/CreateProductDialog.tsx @@ -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([]); + const [serialNumber, setSerialNumber] = useState(genSerial()); + const [orderId, setOrderId] = useState(""); + const [materialId, setMaterialId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdSn, setCreatedSn] = useState(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 ( +
+
+ {/* 标题 */} +
+

创建产品

+ +
+ + {/* 创建成功 — 展示二维码 */} + {createdSn ? ( +
+ {`QR-${createdSn}`} +

+ {createdSn} +

+

产品创建成功!

+
+ + +
+ +
+ ) : ( + /* 表单 */ +
+ {/* 序列号 */} +
+ +
+ 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 + /> + +
+
+ + {/* 订单 */} +
+ + +
+ + {/* 物料 ID */} +
+ + 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="关联老系统物料" + /> +
+ + {error && ( +
{error}
+ )} + + +
+ )} +
+
+ ); +}