feat(web): 人员工时台账表格+多维搜索+CSV导出+新流转红点标注+分页中文

This commit is contained in:
2026-08-13 16:04:29 +08:00
parent 91b1426ccb
commit f4fc23be42
3 changed files with 534 additions and 101 deletions

View File

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

View File

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

View File

@ -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> {