diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 52111f5..f2c98af 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,8 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
+import { App as AntApp } from "antd";
import { ToastProvider } from "./components/ui/Toast";
+import { AuthProvider } from "./contexts/AuthContext";
import AppLayout from "./components/layout/AppLayout";
import ScanPage from "./pages/ScanPage";
import MyTasksPage from "./pages/MyTasksPage";
@@ -8,33 +10,43 @@ import NotificationsPage from "./pages/NotificationsPage";
import ProfilePage from "./pages/ProfilePage";
import AdminLayout from "./components/layout/AdminLayout";
+import AdminLoginPage from "./pages/admin/AdminLoginPage";
import AdminDashboard from "./pages/admin/AdminDashboard";
import AdminProductsPage from "./pages/admin/AdminProductsPage";
import AdminTasksPage from "./pages/admin/AdminTasksPage";
+import AdminPrintConfigPage from "./pages/admin/AdminPrintConfigPage";
export default function App() {
return (
-
-
-
- {/* 移动端 */}
- } />
- }>
- } />
- } />
- } />
- } />
-
+
+
+
+
+
+ {/* 移动端 */}
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+
- {/* PC 管理端 */}
- } />
- }>
- } />
- } />
- } />
-
-
-
-
+ {/* PC 管理端 — 登录页(独立,无侧边栏) */}
+ } />
+
+ {/* PC 管理端 — 需要登录 */}
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
);
}
diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx
index 478e917..6be1ab3 100644
--- a/frontend/src/components/layout/AdminLayout.tsx
+++ b/frontend/src/components/layout/AdminLayout.tsx
@@ -1,5 +1,6 @@
-import { NavLink, Outlet, useLocation } from "react-router-dom";
-import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch } from "lucide-react";
+import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
+import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User } from "lucide-react";
+import { useAuth } from "../../contexts/AuthContext";
const MENU = [
{
@@ -24,6 +25,27 @@ const MENU = [
export default function AdminLayout() {
const location = useLocation();
+ const navigate = useNavigate();
+ const { user, logout, isAuthenticated, loading } = useAuth();
+
+ // 认证加载中
+ if (loading) {
+ return (
+
+ );
+ }
+
+ // 未登录 → 跳转登录页
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ function handleLogout() {
+ logout();
+ navigate("/admin/login", { replace: true });
+ }
return (
@@ -74,7 +96,7 @@ export default function AdminLayout() {
-
+
管理端
@@ -83,6 +105,28 @@ export default function AdminLayout() {
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
+
+ {/* 用户信息 + 登出 */}
+
+
+
+
+ {user?.display_name ?? user?.username ?? "—"}
+
+ {user?.role && (
+
+ {user.role}
+
+ )}
+
+
+
diff --git a/frontend/src/components/scan/ProductCard.tsx b/frontend/src/components/scan/ProductCard.tsx
index 1222a88..b0660e0 100644
--- a/frontend/src/components/scan/ProductCard.tsx
+++ b/frontend/src/components/scan/ProductCard.tsx
@@ -1,20 +1,23 @@
/** 产品信息卡片 */
import { Package } from "lucide-react";
import type { ProductScanResponse } from "../../types/api";
+import { TASK_STATUS } from "../../types/api";
const STATUS_LABELS: Record
= {
- pending: "待处理",
- in_progress: "进行中",
- completed: "已完成",
- cancelled: "已取消",
+ [TASK_STATUS.PENDING]: "待接收",
+ [TASK_STATUS.WIP]: "进行中",
+ [TASK_STATUS.COMPLETED]: "已完成",
+ [TASK_STATUS.REJECTED]: "已驳回",
+ [TASK_STATUS.ARCHIVED]: "已入库",
};
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";
+ case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
+ case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
+ case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
+ case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
+ default: return "bg-gray-100 text-gray-600";
}
}
@@ -38,13 +41,23 @@ export default function ProductCard({ product }: ProductCardProps) {
{product.serial_number}
-
订单编号
-
{product.order_no}
+
物料名称
+
{product.material_name || product.material_id || "—"}
- {product.material_id && (
+
+
规格型号
+
{product.spec_model || "—"}
+
+
+
订单编号
+
{product.order_no || "—"}
+
+ {product.current_location_id && (
-
物料 ID
-
{product.material_id}
+
当前位置
+
+ {product.current_location_id === "virtual_warehouse" ? "🏭 仓库" : product.current_location_id}
+
)}
diff --git a/frontend/src/components/scan/QueryResult.tsx b/frontend/src/components/scan/QueryResult.tsx
index 2df73dc..95a97e6 100644
--- a/frontend/src/components/scan/QueryResult.tsx
+++ b/frontend/src/components/scan/QueryResult.tsx
@@ -33,7 +33,7 @@ export default function QueryResult({ loading, error, product }: QueryResultProp
return (
);
}
diff --git a/frontend/src/components/scan/TaskListCard.tsx b/frontend/src/components/scan/TaskListCard.tsx
index 2e19577..84d5dec 100644
--- a/frontend/src/components/scan/TaskListCard.tsx
+++ b/frontend/src/components/scan/TaskListCard.tsx
@@ -1,25 +1,71 @@
-/** 任务进度列表卡片 */
-import { ClipboardList, ChevronRight } from "lucide-react";
-import type { TaskSummary } from "../../types/api";
+/** 任务进度列表卡片 — 递归渲染 task_tree */
+import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
+import type { TaskResponse, TaskSummary } from "../../types/api";
+import { TASK_STATUS } from "../../types/api";
const STATUS_LABELS: Record = {
- pending: "待处理",
- in_progress: "进行中",
- completed: "已完成",
- cancelled: "已取消",
+ [TASK_STATUS.PENDING]: "待接收",
+ [TASK_STATUS.WIP]: "进行中",
+ [TASK_STATUS.COMPLETED]: "已完成",
+ [TASK_STATUS.REJECTED]: "已驳回",
+ [TASK_STATUS.ARCHIVED]: "已入库",
};
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";
+ case TASK_STATUS.PENDING: return "bg-yellow-100 text-yellow-700";
+ case TASK_STATUS.WIP: return "bg-blue-100 text-blue-700";
+ case TASK_STATUS.COMPLETED: return "bg-green-100 text-green-700";
+ case TASK_STATUS.REJECTED: return "bg-red-100 text-red-700";
+ default: return "bg-gray-100 text-gray-600";
}
}
interface TaskListCardProps {
- tasks: TaskSummary[];
+ tasks: TaskResponse[];
+}
+
+interface TaskNodeProps {
+ task: TaskResponse;
+ depth: number;
+}
+
+function TaskNode({ task, depth }: TaskNodeProps) {
+ return (
+ <>
+
+
+
+ {task.is_rework && (
+
+ 返工
+
+ )}
+ {task.child_tasks && task.child_tasks.length > 1 && (
+
+ 裂变×{task.child_tasks.length}
+
+ )}
+
{task.task_name}
+
+
+ 负责人: {task.assignee_id ?? "未分配"}
+ {task.reject_reason && 驳回: {task.reject_reason}}
+
+
+
+ {STATUS_LABELS[task.status] ?? task.status}
+
+
+ {task.child_tasks?.map((child) => (
+
+ ))}
+ >
+ );
+}
+
+function countAll(tasks: TaskResponse[]): number {
+ return tasks.reduce((s, t) => s + 1 + (t.child_tasks ? countAll(t.child_tasks) : 0), 0);
}
export default function TaskListCard({ tasks }: TaskListCardProps) {
@@ -27,27 +73,15 @@ export default function TaskListCard({ tasks }: TaskListCardProps) {
-
当前进度
- {tasks.length} 个任务
+ 任务流转树
+ {countAll(tasks)} 个任务
-
{tasks.length === 0 ? (
暂无关联任务
) : (
{tasks.map((task) => (
-
-
-
{task.task_name}
-
- 负责人: {task.assignee_id ?? "未分配"}
-
-
-
- {STATUS_LABELS[task.status] ?? task.status}
-
-
-
+
))}
)}
diff --git a/frontend/src/pages/ScanPage.tsx b/frontend/src/pages/ScanPage.tsx
index 88c982d..0508629 100644
--- a/frontend/src/pages/ScanPage.tsx
+++ b/frontend/src/pages/ScanPage.tsx
@@ -75,6 +75,18 @@ export default function ScanPage() {
/>
+ {/* 宏观状态栏 */}
+ {product && (
+
+
+ 宏观状态
+
+ {product.overall_status || "未设定"}
+
+
+
+ )}
+
{/* 查询结果 */}
diff --git a/frontend/src/pages/admin/AdminPrintConfigPage.tsx b/frontend/src/pages/admin/AdminPrintConfigPage.tsx
new file mode 100644
index 0000000..7c362db
--- /dev/null
+++ b/frontend/src/pages/admin/AdminPrintConfigPage.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useState } from "react";
+import { Settings, Save, Loader2, Wifi, WifiOff } from "lucide-react";
+import {
+ getPrinterConfig,
+ updatePrinterConfig,
+ type PrinterConfig,
+} from "../../services/printApi";
+import { useToast } from "../../components/ui/Toast";
+
+export default function AdminPrintConfigPage() {
+ const { toast } = useToast();
+
+ const [config, setConfig] = useState
(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ // 表单字段
+ const [ip, setIp] = useState("");
+ const [port, setPort] = useState(9100);
+ const [enabled, setEnabled] = useState(true);
+
+ useEffect(() => {
+ loadConfig();
+ }, []);
+
+ async function loadConfig() {
+ setLoading(true);
+ try {
+ const cfg = await getPrinterConfig();
+ setConfig(cfg);
+ const lp = cfg.label_printer;
+ setIp(lp.ip);
+ setPort(lp.port);
+ setEnabled(lp.enabled ?? true);
+ } catch (err: any) {
+ toast(err?.response?.data?.detail ?? "加载配置失败", "error");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault();
+ if (!ip.trim()) {
+ toast("请输入打印机 IP 地址", "error");
+ return;
+ }
+ setSaving(true);
+ try {
+ const result = await updatePrinterConfig(ip.trim(), port, enabled);
+ toast(result.message, "success");
+ } catch (err: any) {
+ toast(err?.response?.data?.detail ?? "保存失败", "error");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
打印机设置
+
+ 配置标签打印机(热敏打标机)的 IP 地址和端口,协议: TSPL
+
+
+
+
+
+
+ {/* 当前状态 */}
+ {config?.label_printer && (
+
+
+ 当前配置: {config.label_printer.ip}:{config.label_printer.port}
+ {" · "}
+ {config.label_printer.enabled ? (
+ 已启用
+ ) : (
+ 已禁用
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/AdminProductsPage.tsx b/frontend/src/pages/admin/AdminProductsPage.tsx
index ed347e9..ac50e35 100644
--- a/frontend/src/pages/admin/AdminProductsPage.tsx
+++ b/frontend/src/pages/admin/AdminProductsPage.tsx
@@ -1,17 +1,31 @@
import { useEffect, useState } from "react";
-import { Printer, RefreshCw, Loader2, QrCode, Plus } from "lucide-react";
+import { Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X, Loader } from "lucide-react";
import api from "../../services/api";
import type { ProductResponse } from "../../types/admin";
import CreateProductDialog from "./CreateProductDialog";
+import {
+ getLabelPreview,
+ executePrint,
+ type LabelPreviewRequest,
+} from "../../services/printApi";
+import { useToast } from "../../components/ui/Toast";
const QR_BASE = "/api/v1/products/qrcode";
export default function AdminProductsPage() {
+ const { toast } = useToast();
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreate, setShowCreate] = useState(false);
+ // 打印弹窗状态
+ const [printTarget, setPrintTarget] = useState(null);
+ const [previewUrl, setPreviewUrl] = useState(null);
+ const [printLoading, setPrintLoading] = useState(false);
+ const [printCopies, setPrintCopies] = useState(1);
+ const [printing, setPrinting] = useState(false);
+
async function loadProducts() {
setLoading(true);
setError(null);
@@ -29,8 +43,54 @@ export default function AdminProductsPage() {
loadProducts();
}, []);
- /** 打印单个二维码 */
- function handlePrint(serialNumber: string) {
+ // ---- 打印标签 ----
+
+ async function handleOpenPrint(product: ProductResponse) {
+ setPrintTarget(product);
+ setPreviewUrl(null);
+ setPrintCopies(1);
+ setPrintLoading(true);
+
+ try {
+ const payload: LabelPreviewRequest = {
+ serial_number: product.serial_number,
+ material_name: product.material_name ?? product.material_id ?? "",
+ spec_model: product.spec_model ?? "",
+ order_no: product.order_no ?? "",
+ };
+ const url = await getLabelPreview(payload);
+ setPreviewUrl(url);
+ } catch (err: any) {
+ toast(err?.response?.data?.detail ?? err?.message ?? "生成预览失败", "error");
+ setPrintTarget(null);
+ } finally {
+ setPrintLoading(false);
+ }
+ }
+
+ async function handleConfirmPrint() {
+ if (!printTarget) return;
+ setPrinting(true);
+ try {
+ const result = await executePrint({
+ serial_number: printTarget.serial_number,
+ material_name: printTarget.material_name ?? printTarget.material_id ?? "",
+ spec_model: printTarget.spec_model ?? "",
+ order_no: printTarget.order_no ?? "",
+ copies: printCopies,
+ });
+ toast(result.message, "success");
+ setPrintTarget(null);
+ } catch (err: any) {
+ toast(err?.response?.data?.detail ?? err?.message ?? "打印失败", "error");
+ } finally {
+ setPrinting(false);
+ }
+ }
+
+ // ---- 打印二维码(兼容旧功能) ----
+
+ function handlePrintQr(serialNumber: string) {
const qrUrl = `${QR_BASE}/${serialNumber}`;
const w = window.open("", "_blank", "width=400,height=500");
if (!w) return;
@@ -68,6 +128,13 @@ export default function AdminProductsPage() {
+
+
+
)}
+
setShowCreate(false)}
onCreated={loadProducts}
/>
+
+ {/* ============================================================ */}
+ {/* 打印预览弹窗 */}
+ {/* ============================================================ */}
+ {printTarget && (
+
+
!printing && setPrintTarget(null)}
+ />
+
+
+
+ 标签打印预览
+
+ setPrintTarget(null)}
+ disabled={printing}
+ className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50"
+ >
+
+
+
+
+ {/* 预览图 */}
+
+ {printLoading || !previewUrl ? (
+
+
+
+ ) : (
+

+ )}
+
+
+
+ {printTarget.serial_number}
+
+
+ {/* 份数选择 */}
+
+
打印份数
+
+ setPrintCopies((c) => Math.max(1, c - 1))}
+ className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
+ >
+ −
+
+
+ {printCopies}
+
+ setPrintCopies((c) => Math.min(100, c + 1))}
+ className="rounded border border-gray-200 px-2.5 py-1 text-sm hover:bg-gray-50"
+ >
+ +
+
+
+
+
+ {/* 按钮 */}
+
+ setPrintTarget(null)}
+ disabled={printing}
+ className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
+ >
+ 取消
+
+
+ {printing && }
+ 确认打印
+
+
+
+
+ )}
);
}
diff --git a/frontend/src/pages/admin/CreateProductDialog.tsx b/frontend/src/pages/admin/CreateProductDialog.tsx
index d717e55..9c08f31 100644
--- a/frontend/src/pages/admin/CreateProductDialog.tsx
+++ b/frontend/src/pages/admin/CreateProductDialog.tsx
@@ -1,7 +1,12 @@
-import { useState, useEffect } from "react";
-import { X, Loader2, QrCode } from "lucide-react";
+import { useState, useEffect, useRef, useCallback } from "react";
+import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
+import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
import api from "../../services/api";
-import { listOrders, type OrderOption } from "../../services/orderApi";
+import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
+
+// ============================================================
+// 类型
+// ============================================================
interface Props {
open: boolean;
@@ -9,181 +14,444 @@ interface Props {
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;
+interface SelectedMaterial {
+ material_id: string;
+ material_name: string;
+ spec_model: string;
+ category: string;
+ material_type: string;
}
+// ============================================================
+// 主组件
+// ============================================================
+
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 { message } = App.useApp();
+
+ // ---- 搜索 & 分组摘要 ----
+ const [keyword, setKeyword] = useState("");
+ const [summary, setSummary] = useState([]);
+ const [summaryLoading, setSummaryLoading] = useState(false);
+
+ // ---- 手风琴展开 keys ----
+ const [activeKeys, setActiveKeys] = useState([]);
+
+ // ---- 缓存 (对标老系统 groupCache / groupLoadingMap) ----
+ const groupCache = useRef