feat(frontend): 管理端页面 — 任务全景树/创建产品/标签打印/认证守卫

App.tsx:
- AntApp + AuthProvider + ToastProvider 三层包裹
- /admin/login 独立登录页
- /admin/* 路由认证守卫

AdminLayout:
- 未登录→跳转登录; 顶部栏显示用户名+登出
- 侧边栏: 全局概览/产品管理/任务全景

CreateProductDialog (Ant Design 重写):
- MOM物料手风琴选择器 (Collapse+Table)
- 分组懒加载 + useRef缓存 + 防抖搜索
- HEX ID只读展示 + 序列号/订单号选填

AdminProductsPage:
- 打印预览弹窗 (Base64标签预览 + 份数选择)
- 打印机设置入口 → AdminPrintConfigPage

扫码组件对齐:
- ProductCard: material_name/spec_model/current_location
- TaskListCard: 递归 task_tree + 状态配色 + 返工/裂变标签
- QueryResult: task_tree 优先
- ScanPage: 宏观状态栏
This commit is contained in:
2026-08-05 14:01:36 +08:00
parent d04085fc38
commit 59c182a743
9 changed files with 919 additions and 220 deletions

View File

@ -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 (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
);
}
// 未登录 → 跳转登录页
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />;
}
function handleLogout() {
logout();
navigate("/admin/login", { replace: true });
}
return (
<div className="flex min-h-screen bg-gray-50">
@ -74,7 +96,7 @@ export default function AdminLayout() {
</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="sticky top-0 z-30 flex items-center justify-between 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>
@ -83,6 +105,28 @@ export default function AdminLayout() {
{MENU.find((m) => location.pathname.startsWith(m.path))?.title ?? "页面"}
</span>
</div>
{/* 用户信息 + 登出 */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<User className="h-3.5 w-3.5" />
<span className="font-medium text-gray-700">
{user?.display_name ?? user?.username ?? "—"}
</span>
{user?.role && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">
{user.role}
</span>
)}
</div>
<button
onClick={handleLogout}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<LogOut className="h-3 w-3" />
退出
</button>
</div>
</div>
<div className="p-6">
<Outlet />

View File

@ -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<string, string> = {
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) {
<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>
<span className="text-gray-400">物料名称</span>
<p className="font-medium text-gray-800">{product.material_name || product.material_id || "—"}</p>
</div>
{product.material_id && (
<div>
<span className="text-gray-400">规格型号</span>
<p className="font-medium text-gray-800">{product.spec_model || "—"}</p>
</div>
<div>
<span className="text-gray-400">订单编号</span>
<p className="font-medium text-gray-800">{product.order_no || "—"}</p>
</div>
{product.current_location_id && (
<div>
<span className="text-gray-400">物料 ID</span>
<p className="font-medium text-gray-800">{product.material_id}</p>
<span className="text-gray-400">当前位置</span>
<p className={`font-medium ${product.current_location_id === "virtual_warehouse" ? "text-purple-600" : "text-gray-800"}`}>
{product.current_location_id === "virtual_warehouse" ? "🏭 仓库" : product.current_location_id}
</p>
</div>
)}
</div>

View File

@ -33,7 +33,7 @@ export default function QueryResult({ loading, error, product }: QueryResultProp
return (
<div className="space-y-4">
<ProductCard product={product} />
<TaskListCard tasks={product.top_level_tasks} />
<TaskListCard tasks={product.task_tree || product.top_level_tasks} />
</div>
);
}

View File

@ -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<string, string> = {
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 (
<>
<div className="flex items-center gap-3 px-4 py-3 border-t border-gray-50" style={{ paddingLeft: 16 + depth * 16 }}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
{task.is_rework && (
<span className="inline-flex items-center gap-0.5 rounded bg-red-600 px-1 py-0.5 text-[10px] font-bold text-white">
<AlertTriangle className="h-2.5 w-2.5" />返工
</span>
)}
{task.child_tasks && task.child_tasks.length > 1 && (
<span className="inline-flex items-center gap-0.5 rounded bg-purple-100 px-1 py-0.5 text-[10px] font-medium text-purple-700">
<GitBranch className="h-2.5 w-2.5" />裂变×{task.child_tasks.length}
</span>
)}
<p className="truncate text-sm font-medium text-gray-800">{task.task_name}</p>
</div>
<p className="text-xs text-gray-400">
负责人: {task.assignee_id ?? "未分配"}
{task.reject_reason && <span className="ml-2 text-red-500">驳回: {task.reject_reason}</span>}
</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>
</div>
{task.child_tasks?.map((child) => (
<TaskNode key={child.id} task={child} depth={depth + 1} />
))}
</>
);
}
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) {
<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>
<h3 className="font-semibold text-gray-800">任务流转树</h3>
<span className="ml-auto text-xs text-gray-400">{countAll(tasks)} 个任务</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>
<TaskNode key={task.id} task={task} depth={0} />
))}
</div>
)}