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