feat(web): 人员工时台账表格+多维搜索+CSV导出+新流转红点标注+分页中文
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
import { Suspense, lazy } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { App as AntApp } from "antd";
|
||||
import { App as AntApp, ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
|
||||
import { ToastProvider } from "./components/ui/Toast";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
@ -25,7 +26,8 @@ const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPa
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AntApp>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AntApp>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
@ -57,6 +59,7 @@ export default function App() {
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</AntApp>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,18 +1,88 @@
|
||||
/** 人员看板 — 按负责人聚合当前在制品设备 */
|
||||
/** 人员看板 — 实时在制品 + 历史工时台账 */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw,
|
||||
Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw, Clock, Download,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { fetchPeopleWorkload, type PersonWorkload } from "../../services/dashboardApi";
|
||||
import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
dayjs.locale("zh-cn");
|
||||
import {
|
||||
fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory,
|
||||
type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery,
|
||||
} from "../../services/dashboardApi";
|
||||
import { getTask } from "../../services/taskApi";
|
||||
import type { TaskRecordResponse } from "../../types/api";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#6366f1"];
|
||||
|
||||
// ─── 滞留时长 SLA 预警颜色 / 标签 ───────────────────────
|
||||
function durationColor(h: number) {
|
||||
if (h >= 48) return "text-red-600 bg-red-100 font-bold";
|
||||
if (h >= 24) return "text-orange-600 bg-orange-50";
|
||||
return "text-emerald-600 bg-emerald-50";
|
||||
}
|
||||
function durationLabel(h: number) {
|
||||
if (h >= 24) return `${Math.round(h / 24)}天`;
|
||||
if (h >= 1) return `${h}小时`;
|
||||
return `${Math.round(h * 60)}分钟`;
|
||||
}
|
||||
|
||||
// ─── 图片 URL 拼接(对齐 TaskFlowView) ──────────────────
|
||||
function imageUrl(u: string) {
|
||||
if (!u) return "";
|
||||
if (u.startsWith("http")) return u;
|
||||
const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, "");
|
||||
const path = u.startsWith("/") ? u : "/" + u;
|
||||
if (path.startsWith("/api/")) {
|
||||
const origin = base.replace(/\/api(\/v\d+)?$/, "");
|
||||
return origin + path;
|
||||
}
|
||||
return base + path;
|
||||
}
|
||||
|
||||
// ─── 任务状态徽章 ───────────────────────────────────────
|
||||
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
WIP: { label: "进行中", cls: "bg-blue-100 text-blue-700" },
|
||||
PENDING: { label: "待接收", cls: "bg-amber-100 text-amber-700" },
|
||||
COMPLETED: { label: "已完成", cls: "bg-green-100 text-green-700" },
|
||||
};
|
||||
|
||||
export default function AdminPeoplePage() {
|
||||
const [mode, setMode] = useState<"realtime" | "history">("realtime");
|
||||
|
||||
// 实时
|
||||
const [workloads, setWorkloads] = useState<PersonWorkload[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
// 历史
|
||||
const [records, setRecords] = useState<PersonHistoryRecord[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [historyRange, setHistoryRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [fAssignee, setFAssignee] = useState("");
|
||||
const [fSpec, setFSpec] = useState("");
|
||||
const [fSn, setFSn] = useState("");
|
||||
const [fTask, setFTask] = useState("");
|
||||
const [recordDetail, setRecordDetail] = useState<{ open: boolean; taskName: string; records: TaskRecordResponse[] }>({
|
||||
open: false, taskName: "", records: [],
|
||||
});
|
||||
// 已读记录数(task_id -> 已读的 record_count),用于「新流转」红点标注
|
||||
const [readCounts, setReadCounts] = useState<Record<string, number>>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("people_history_read_counts") || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = () => {
|
||||
@ -23,12 +93,100 @@ export default function AdminPeoplePage() {
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const buildQuery = (range: [Dayjs, Dayjs] | null): PeopleHistoryQuery => {
|
||||
const q: PeopleHistoryQuery = {};
|
||||
if (range) {
|
||||
q.since = range[0].startOf("day").toISOString();
|
||||
q.until = range[1].endOf("day").toISOString();
|
||||
}
|
||||
if (fAssignee.trim()) q.assignee_id = fAssignee.trim();
|
||||
if (fSpec.trim()) q.spec_model = fSpec.trim();
|
||||
if (fSn.trim()) q.product_sn = fSn.trim();
|
||||
if (fTask.trim()) q.task_name = fTask.trim();
|
||||
return q;
|
||||
};
|
||||
|
||||
const loadHistory = (range: [Dayjs, Dayjs] | null) => {
|
||||
setHistoryLoading(true);
|
||||
fetchPeopleHistory(buildQuery(range))
|
||||
.then((data) => { setRecords(data); setHistoryError(null); })
|
||||
.catch(() => setHistoryError("加载工时台账失败"))
|
||||
.finally(() => setHistoryLoading(false));
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await downloadPeopleHistory(buildQuery(historyRange));
|
||||
} catch {
|
||||
setHistoryError("导出失败,请稍后重试");
|
||||
}
|
||||
};
|
||||
|
||||
const openRecordDetail = async (record: PersonHistoryRecord) => {
|
||||
try {
|
||||
const task = await getTask(record.task_id);
|
||||
setRecordDetail({
|
||||
open: true,
|
||||
taskName: record.task_name || record.task_id,
|
||||
records: task.records || [],
|
||||
});
|
||||
// 标记该任务为已读(记录数同步)
|
||||
const next = { ...readCounts, [record.task_id]: record.record_count };
|
||||
setReadCounts(next);
|
||||
localStorage.setItem("people_history_read_counts", JSON.stringify(next));
|
||||
} catch {
|
||||
setHistoryError("加载记录详情失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFAssignee("");
|
||||
setFSpec("");
|
||||
setFSn("");
|
||||
setFTask("");
|
||||
setHistoryRange(null);
|
||||
loadHistory(null);
|
||||
};
|
||||
|
||||
const markAllRead = () => {
|
||||
const next: Record<string, number> = {};
|
||||
for (const r of records) next[r.task_id] = r.record_count;
|
||||
setReadCounts(next);
|
||||
localStorage.setItem("people_history_read_counts", JSON.stringify(next));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const totalDevices = useMemo(
|
||||
() => workloads.reduce((sum, w) => sum + w.device_count, 0),
|
||||
[workloads],
|
||||
);
|
||||
// 自动搜索:模式切换 / 筛选条件 / 时间范围变化时触发(防抖 300ms)
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
const timer = setTimeout(() => loadHistory(historyRange), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [mode, fAssignee, fSpec, fSn, fTask, historyRange]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const totalDevices = useMemo(() => {
|
||||
const uniqueIds = new Set<string>();
|
||||
for (const w of workloads) {
|
||||
for (const d of w.devices) uniqueIds.add(d.product_id);
|
||||
}
|
||||
return uniqueIds.size;
|
||||
}, [workloads]);
|
||||
|
||||
const totalHistoryDevices = useMemo(() => {
|
||||
const uniqueIds = new Set<string>();
|
||||
for (const r of records) uniqueIds.add(r.product_sn);
|
||||
return uniqueIds.size;
|
||||
}, [records]);
|
||||
|
||||
// 搜索下拉选项(从当前结果去重提取)
|
||||
const assigneeOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const r of records) if (r.assignee_id && !seen.has(r.assignee_id)) seen.set(r.assignee_id, r.assignee_name || r.assignee_id);
|
||||
return Array.from(seen.entries()).map(([id, name]) => ({ value: id, label: `${name} (${id})` }));
|
||||
}, [records]);
|
||||
const specOptions = useMemo(() => Array.from(new Set(records.map(r => r.spec_model).filter(Boolean))).map(v => ({ value: v })), [records]);
|
||||
const snOptions = useMemo(() => Array.from(new Set(records.map(r => r.product_sn).filter(Boolean))).map(v => ({ value: v })), [records]);
|
||||
const taskOptions = useMemo(() => Array.from(new Set(records.map(r => r.task_name).filter(Boolean))).map(v => ({ value: v })), [records]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded((prev) => {
|
||||
@ -39,115 +197,331 @@ export default function AdminPeoplePage() {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 历史表格列 ───────────────────────────────────────
|
||||
const columns: ColumnsType<PersonHistoryRecord> = [
|
||||
{
|
||||
title: "状态", dataIndex: "status", key: "status", width: 100,
|
||||
render: (s: string, record: PersonHistoryRecord) => {
|
||||
const cfg = STATUS_MAP[s] || { label: s, cls: "bg-gray-100 text-gray-600" };
|
||||
const unread = record.record_count > (readCounts[record.task_id] || 0);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-bold ${cfg.cls}`}>{cfg.label}</span>
|
||||
{unread && <span className="h-2 w-2 shrink-0 rounded-full bg-red-500" title="有新的流转记录" />}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: "负责人", dataIndex: "assignee_name", key: "assignee_name", width: 110 },
|
||||
{ title: "设备身份证", dataIndex: "product_sn", key: "product_sn", width: 190, render: (v: string) => <span className="font-mono text-xs">{v}</span> },
|
||||
{ title: "规格型号", dataIndex: "spec_model", key: "spec_model", width: 130, render: (v: string) => v || "—" },
|
||||
{ title: "任务名", dataIndex: "task_name", key: "task_name", ellipsis: true },
|
||||
{ title: "开始时间", dataIndex: "received_at", key: "received_at", width: 120, render: (v: string) => v || "—" },
|
||||
{ title: "结束时间", dataIndex: "completed_at", key: "completed_at", width: 120, render: (v: string) => v || <span className="text-blue-500">进行中</span> },
|
||||
{
|
||||
title: "总耗时", dataIndex: "duration_hours", key: "duration_hours", width: 120,
|
||||
sorter: (a, b) => a.duration_hours - b.duration_hours,
|
||||
render: (h: number) => (
|
||||
<span className={`rounded-md px-2 py-0.5 text-xs font-bold ${durationColor(h)}`}>
|
||||
{durationLabel(h)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "最新动态/备注", dataIndex: "latest_valid_remark", key: "latest_valid_remark",
|
||||
render: (_: unknown, record: PersonHistoryRecord) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="flex-1 truncate text-xs text-gray-600">{record.latest_valid_remark || "—"}</span>
|
||||
{record.record_count > 0 && (
|
||||
<a
|
||||
className="shrink-0 text-xs text-blue-600 hover:underline"
|
||||
onClick={(e) => { e.stopPropagation(); openRecordDetail(record); }}
|
||||
>
|
||||
({record.record_count}条)
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页头 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">👥 人员看板</h2>
|
||||
<p className="mt-0.5 text-sm text-gray-400">按负责人聚合当前在制品设备 · 实时快照</p>
|
||||
<p className="mt-0.5 text-sm text-gray-400">实时在制品 / 历史工时台账</p>
|
||||
</div>
|
||||
<button onClick={load} className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-gray-500 hover:bg-gray-100">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<Radio.Group
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="realtime">实时</Radio.Button>
|
||||
<Radio.Button value="history">历史</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{/* 统计概览 */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">在岗人员</h3>
|
||||
{/* ═══ 实时模式 ═══ */}
|
||||
{mode === "realtime" && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">当前负责人</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-800">{workloads.length}<span className="text-sm font-normal text-gray-400"> 人</span></p>
|
||||
<p className="text-[11px] text-gray-400">当前有在制品的负责人</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm sm:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">在制品设备总量</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-emerald-600">{totalDevices}<span className="text-sm font-normal text-gray-400"> 台</span></p>
|
||||
<p className="text-[11px] text-gray-400">按唯一产品去重统计;并发设备会在多个负责人名下各显示一次</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-800">{workloads.length}<span className="text-sm font-normal text-gray-400"> 人</span></p>
|
||||
<p className="text-[11px] text-gray-400">持有在制品的负责人数</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm sm:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">在制品设备总量</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-emerald-600">{totalDevices}<span className="text-sm font-normal text-gray-400"> 台</span></p>
|
||||
<p className="text-[11px] text-gray-400">一台设备若被多人并发处理,会在多人名下各计一次</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载 / 错误 / 空态 */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<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>
|
||||
)}
|
||||
{!loading && !error && workloads.length === 0 && (
|
||||
<div className="py-20 text-center text-gray-400">🎉 当前无人在制品</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<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>
|
||||
)}
|
||||
{!loading && !error && workloads.length === 0 && (
|
||||
<div className="py-20 text-center text-gray-400">🎉 当前无人在制品</div>
|
||||
)}
|
||||
|
||||
{/* 人员列表 */}
|
||||
{!loading && !error && workloads.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{workloads.map((w) => {
|
||||
const isOpen = expanded.has(w.assignee_id);
|
||||
const initial = (w.assignee_name || w.assignee_id || "?").charAt(0);
|
||||
const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length];
|
||||
return (
|
||||
<div key={w.assignee_id} className="overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
{/* 人员头部 */}
|
||||
<button
|
||||
onClick={() => toggle(w.assignee_id)}
|
||||
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-gray-800">{w.assignee_name || w.assignee_id}</span>
|
||||
<span className="ml-2 text-xs text-gray-400 font-mono">{w.assignee_id}</span>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${w.device_count > 0 ? "bg-blue-50 text-blue-700" : "bg-gray-100 text-gray-500"}`}>
|
||||
{w.device_count} 台
|
||||
</span>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 shrink-0 text-gray-400" /> : <ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />}
|
||||
</button>
|
||||
{!loading && !error && workloads.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{workloads.map((w) => {
|
||||
const isOpen = expanded.has(w.assignee_id);
|
||||
const initial = (w.assignee_name || w.assignee_id || "?").charAt(0);
|
||||
const color = AVATAR_COLORS[(initial.charCodeAt(0) || 0) % AVATAR_COLORS.length];
|
||||
return (
|
||||
<div key={w.assignee_id} className="overflow-hidden rounded-xl bg-white shadow-sm">
|
||||
<button
|
||||
onClick={() => toggle(w.assignee_id)}
|
||||
className="flex w-full items-center gap-3 px-5 py-3.5 text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-semibold text-gray-800">{w.assignee_name || w.assignee_id}</span>
|
||||
<span className="ml-2 text-xs text-gray-400 font-mono">{w.assignee_id}</span>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${w.device_count > 0 ? "bg-blue-50 text-blue-700" : "bg-gray-100 text-gray-500"}`}>
|
||||
{w.device_count} 台
|
||||
</span>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4 shrink-0 text-gray-400" /> : <ChevronRight className="h-4 w-4 shrink-0 text-gray-400" />}
|
||||
</button>
|
||||
|
||||
{/* 展开明细 */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-5 py-3">
|
||||
{w.devices.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-gray-400">无设备</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{w.devices.map((d) => (
|
||||
<div
|
||||
key={d.product_id}
|
||||
onClick={() => navigate(`/admin/tasks?sn=${d.serial_number}`)}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-2.5 transition-shadow hover:border-blue-200 hover:shadow-md"
|
||||
>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${d.task_status === "WIP" ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{d.task_status === "WIP" ? "进行中" : "待接收"}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-800 truncate">{d.material_name || "未知设备"}</span>
|
||||
<span className="text-xs text-gray-500">{d.spec_model || "无规格"}</span>
|
||||
<span className="text-xs text-gray-400">序列号: {d.external_serial || "未录入"}</span>
|
||||
<span className="ml-auto font-mono text-xs text-gray-300">身份证: {d.serial_number}</span>
|
||||
{isOpen && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-5 py-3">
|
||||
{w.devices.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-gray-400">无设备</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{w.devices.map((d) => (
|
||||
<div
|
||||
key={d.product_id}
|
||||
onClick={() => navigate(`/admin/tasks?sn=${d.serial_number}`)}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-2.5 transition-shadow hover:border-blue-200 hover:shadow-md"
|
||||
>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${d.task_status === "WIP" ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"}`}>
|
||||
{d.task_status === "WIP" ? "进行中" : "待接收"}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-800 truncate">{d.material_name || "未知设备"}</span>
|
||||
<span className="text-xs text-gray-500">{d.spec_model || "无规格"}</span>
|
||||
<span className="text-xs text-gray-400">序列号: {d.external_serial || "未录入"}</span>
|
||||
<span className="font-mono text-xs text-gray-300">身份证: {d.serial_number}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<span className={`rounded-md px-2 py-0.5 text-xs font-bold ${durationColor(d.duration_hours)}`}>
|
||||
<Clock className="mr-0.5 inline h-3 w-3" />
|
||||
{durationLabel(d.duration_hours)}
|
||||
</span>
|
||||
{d.received_at && <span className="text-xs text-gray-400">{d.received_at}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══ 历史模式(工时台账) ═══ */}
|
||||
{mode === "history" && (
|
||||
<>
|
||||
{/* 概览 */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">生产设备总量</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-emerald-600">{totalHistoryDevices}<span className="text-sm font-normal text-gray-400"> 台</span></p>
|
||||
<p className="text-[11px] text-gray-400">按唯一身份证去重统计</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">工序记录</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-800">{records.length}<span className="text-sm font-normal text-gray-400"> 条</span></p>
|
||||
<p className="text-[11px] text-gray-400">含进行中与已完成</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RangePicker
|
||||
size="small"
|
||||
value={historyRange as any}
|
||||
onChange={(dates) => setHistoryRange(dates as [Dayjs, Dayjs] | null)}
|
||||
disabledDate={(d) => d.isAfter(dayjs(), "day")}
|
||||
style={{ width: 240 }}
|
||||
placeholder={["开始", "结束"]}
|
||||
/>
|
||||
<AutoComplete
|
||||
size="small"
|
||||
placeholder="负责人"
|
||||
value={fAssignee}
|
||||
onChange={setFAssignee}
|
||||
options={assigneeOptions}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<AutoComplete
|
||||
size="small"
|
||||
placeholder="规格型号"
|
||||
value={fSpec}
|
||||
onChange={setFSpec}
|
||||
options={specOptions}
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<AutoComplete
|
||||
size="small"
|
||||
placeholder="身份证"
|
||||
value={fSn}
|
||||
onChange={setFSn}
|
||||
options={snOptions}
|
||||
allowClear
|
||||
popupMatchSelectWidth={false}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<AutoComplete
|
||||
size="small"
|
||||
placeholder="任务名"
|
||||
value={fTask}
|
||||
onChange={setFTask}
|
||||
options={taskOptions}
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
value={pageSize}
|
||||
onChange={setPageSize}
|
||||
options={[
|
||||
{ value: 50, label: "50 条/页" },
|
||||
{ value: 100, label: "100 条/页" },
|
||||
{ value: 500, label: "500 条/页" },
|
||||
{ value: 1000, label: "1000 条/页" },
|
||||
]}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
<Button size="small" icon={<Download className="h-3 w-3" />} onClick={handleExport}>
|
||||
导出
|
||||
</Button>
|
||||
<Button size="small" onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button size="small" onClick={markAllRead}>
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
{historyError && (
|
||||
<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" />{historyError}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<Table<PersonHistoryRecord>
|
||||
rowKey="task_id"
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
loading={historyLoading}
|
||||
size="small"
|
||||
pagination={{ pageSize, showTotal: (t) => `共 ${t} 条` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 记录详情弹窗 */}
|
||||
<Modal
|
||||
title={`记录详情 — ${recordDetail.taskName}`}
|
||||
open={recordDetail.open}
|
||||
onCancel={() => setRecordDetail((s) => ({ ...s, open: false }))}
|
||||
footer={null}
|
||||
width={560}
|
||||
>
|
||||
{recordDetail.records.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-gray-400">暂无记录</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={[...recordDetail.records].reverse().map((r) => ({
|
||||
color: "gray",
|
||||
children: (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400">{r.created_at ? dayjs(r.created_at).format("MM-DD HH:mm") : ""}</div>
|
||||
<div className="mt-0.5 text-sm text-gray-700">{r.remark || "—"}</div>
|
||||
{r.images && r.images.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{r.images.map((img, j) => (
|
||||
<Image
|
||||
key={j}
|
||||
src={imageUrl(img)}
|
||||
alt={`记录图片 ${j + 1}`}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded border object-cover"
|
||||
style={{ objectFit: "cover" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -46,6 +46,8 @@ export interface PersonDevice {
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
task_status: string;
|
||||
duration_hours: number;
|
||||
received_at: string | null;
|
||||
}
|
||||
|
||||
export interface PersonWorkload {
|
||||
@ -55,6 +57,32 @@ export interface PersonWorkload {
|
||||
devices: PersonDevice[];
|
||||
}
|
||||
|
||||
export interface PersonHistoryRecord {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
assignee_id: string;
|
||||
assignee_name: string;
|
||||
product_sn: string;
|
||||
external_serial: string | null;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
status: string;
|
||||
received_at: string | null;
|
||||
completed_at: string | null;
|
||||
duration_hours: number;
|
||||
latest_valid_remark: string | null;
|
||||
record_count: number;
|
||||
}
|
||||
|
||||
export interface PeopleHistoryQuery {
|
||||
since?: string;
|
||||
until?: string;
|
||||
assignee_id?: string;
|
||||
spec_model?: string;
|
||||
product_sn?: string;
|
||||
task_name?: string;
|
||||
}
|
||||
|
||||
export interface ProductMessageItem {
|
||||
id: string;
|
||||
content: string;
|
||||
@ -96,6 +124,34 @@ export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchPeopleHistory(query: PeopleHistoryQuery = {}): Promise<PersonHistoryRecord[]> {
|
||||
const params: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v) params[k] = v;
|
||||
}
|
||||
const { data } = await api.get<PersonHistoryRecord[]>("/dashboard/people-history", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function downloadPeopleHistory(query: PeopleHistoryQuery = {}): Promise<void> {
|
||||
const params: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v) params[k] = v;
|
||||
}
|
||||
const res = await api.get("/dashboard/people-history/export", {
|
||||
params,
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(res.data as Blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "people_history.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function fetchDashboardMessages(
|
||||
keyword = "", skip = 0, limit = 30,
|
||||
): Promise<ProductMessageList> {
|
||||
|
||||
Reference in New Issue
Block a user