feat: 实现前端管理看板与移动端扫码视图组件

This commit is contained in:
2026-08-04 17:10:01 +08:00
parent d18d9dbaac
commit 340a09007e
15 changed files with 1225 additions and 0 deletions

View 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>
);
}