feat: 实现前端管理看板与移动端扫码视图组件
This commit is contained in:
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