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

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

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

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

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

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

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

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

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

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

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

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

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

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

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