Compare commits

..

4 Commits

5 changed files with 64 additions and 53 deletions

View File

@ -342,25 +342,10 @@ async def get_flow_compare(
if not product_rows:
return FlowResponse(devices=[], series=[])
devices = [
FlowDevice(
product_sn=r[1], external_serial=r[2],
material_name=r[3] or "", spec_model=r[4] or "",
lead_time=0.0,
started_at="",
)
for r in product_rows
]
# 显式传入身份证时按输入顺序排列
if product_sns:
order = {sn: i for i, sn in enumerate(product_sns)}
devices.sort(key=lambda d: order.get(d.product_sn, len(order)))
id_to_sn = {r[0]: r[1] for r in product_rows}
sn_to_index = {d.product_sn: i for i, d in enumerate(devices)}
product_ids = [r[0] for r in product_rows]
# ── 这些设备的全部任务(含工序名,用于区间图) ──
# ── 候选设备的全部任务(含工序名,用于区间图) ──
task_rows = (await db.execute(
select(
Task.product_id, Task.assignee_id, Task.task_name,
@ -374,25 +359,57 @@ async def get_flow_compare(
)
)).all()
# ── 解析为区间记录,并计算每台设备的 T0最早与 max_end最晚 ──
# ── 设备级时间筛选 ──
# 语义:时间筛选 = "在该时间有活动的设备",而非"把任务切片只保留该时间"。
# 设备一旦入选,其任务必须完整返回(完整生命周期),绝不因设备在筛选中无新动作
# 而把它的历史任务清空。
if since is not None or until is not None:
active_pids: set = set()
for row in task_rows:
pid = row[0]
start = _to_bj(row[3] or row[4])
end = _to_bj(row[5]) if row[5] else now
if start is None:
continue
if since is not None and end < since:
continue
if until is not None and start > until:
continue
active_pids.add(pid)
product_rows = [r for r in product_rows if r[0] in active_pids]
if not product_rows:
return FlowResponse(devices=[], series=[])
# 显式传入身份证时按输入顺序排列
if product_sns:
order = {sn: i for i, sn in enumerate(product_sns)}
product_rows.sort(key=lambda r: order.get(r[1], len(order)))
devices = [
FlowDevice(
product_sn=r[1], external_serial=r[2],
material_name=r[3] or "", spec_model=r[4] or "",
lead_time=0.0,
started_at="",
)
for r in product_rows
]
sn_to_index = {d.product_sn: i for i, d in enumerate(devices)}
kept_ids = {r[0] for r in product_rows}
# ── 解析为区间记录(完整生命周期,不再按时间切片) ──
intervals: list[dict] = []
t0_by_device: dict[int, datetime] = {}
max_end_by_device: dict[int, datetime] = {}
for row in task_rows:
pid, assignee_id, task_name, received_at, created_at, completed_at, task_type = row
sn = id_to_sn.get(pid)
if sn is None or sn not in sn_to_index:
if pid not in kept_ids:
continue
sn = id_to_sn[pid]
idx = sn_to_index[sn]
start = _to_bj(received_at or created_at)
end = _to_bj(completed_at) if completed_at else now
if start is None:
continue
# 时间筛选:任务区间与 [since, until] 有交集
if since is not None and end is not None and end < since:
continue
if until is not None and start is not None and start > until:
continue
is_main = 0 if task_type == "SPAWN" else 1
intervals.append({
"idx": idx,

View File

@ -4,7 +4,6 @@ import {
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
Clock, MessageCircle, CheckCircle2, Users,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
@ -79,10 +78,9 @@ function durationLabel(h: number) {
}
function WipRow({ t }: { t: WipTask }) {
const nav = useNavigate();
const handleClick = () => {
if (t.product_sn) {
nav(`/admin/tasks?sn=${t.product_sn}`);
window.open(`/admin/tasks?sn=${t.product_sn}`, "_blank");
}
};
return (
@ -162,11 +160,10 @@ function MsgRow({ m }: { m: ProductMessageItem }) {
// ─── 已完成明细项(卡片式) ─────────────────────────────
function CompletedRow({ t }: { t: CompletedTask }) {
const nav = useNavigate();
const completedTime = t.completed_at ? dayjs(t.completed_at).format("YYYY-MM-DD HH:mm") : "";
return (
<div
onClick={() => t.product_sn && nav(`/admin/tasks?sn=${t.product_sn}`)}
onClick={() => t.product_sn && window.open(`/admin/tasks?sn=${t.product_sn}`, "_blank")}
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-emerald-200 hover:shadow-md"
>
<div className="flex items-center gap-2">
@ -191,12 +188,11 @@ function CompletedRow({ t }: { t: CompletedTask }) {
// ─── 被驳回明细项(卡片式) ──────────────────────────────
function RejectedRow({ t }: { t: RejectedTask }) {
const nav = useNavigate();
const timeStr = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : "";
const isRework = t.kind === "rework";
return (
<div
onClick={() => t.product_sn && nav(`/admin/tasks?sn=${t.product_sn}`)}
onClick={() => t.product_sn && window.open(`/admin/tasks?sn=${t.product_sn}`, "_blank")}
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md"
>
<div className="flex items-center gap-2">
@ -286,8 +282,6 @@ export default function AdminDashboard() {
const [opDetailLoading, setOpDetailLoading] = useState(false);
const [opDetailTitle, setOpDetailTitle] = useState("");
const navigate = useNavigate();
// ── 加载主数据 ──
const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | null) => {
setLoading(true);
@ -536,7 +530,7 @@ export default function AdminDashboard() {
</button>
</div>
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<button onClick={() => navigate("/notifications")}
<button onClick={() => window.open("/notifications", "_blank")}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-blue-600">
<Bell className="h-3.5 w-3.5" />
{stats.unread_notifications > 0 ? `(${stats.unread_notifications})` : ""}
@ -596,15 +590,15 @@ export default function AdminDashboard() {
<div className="rounded-xl bg-white p-5 shadow-sm">
<h3 className="mb-4 text-sm font-semibold text-gray-700"> </h3>
<div className="space-y-2">
<button onClick={() => navigate("/admin/products")}
<button onClick={() => window.open("/admin/products", "_blank")}
className="flex w-full items-center justify-between rounded-lg bg-blue-50 px-4 py-3 text-left text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors">
<span>📦 </span><ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => navigate("/admin/tasks")}
<button onClick={() => window.open("/admin/tasks", "_blank")}
className="flex w-full items-center justify-between rounded-lg bg-purple-50 px-4 py-3 text-left text-sm font-medium text-purple-700 hover:bg-purple-100 transition-colors">
<span>📋 </span><ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => navigate("/notifications")}
<button onClick={() => window.open("/notifications", "_blank")}
className="flex w-full items-center justify-between rounded-lg bg-blue-50 px-4 py-3 text-left text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors">
<span>🔔 </span><ArrowRight className="h-4 w-4" />
</button>

View File

@ -1,9 +1,8 @@
/** 人员看板 — 实时在制品 + 历史工时台账 */
import { useEffect, useMemo, useState } from "react";
import {
Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, RefreshCw, Clock, Download,
Users, Package, ChevronDown, ChevronRight, Loader2, AlertCircle, Clock, Download,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
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";
@ -83,8 +82,6 @@ export default function AdminPeoplePage() {
}
});
const navigate = useNavigate();
const load = () => {
setLoading(true);
fetchPeopleWorkload()
@ -338,7 +335,7 @@ export default function AdminPeoplePage() {
{w.devices.map((d) => (
<div
key={d.product_id}
onClick={() => navigate(`/admin/tasks?sn=${d.serial_number}`)}
onClick={() => window.open(`/admin/tasks?sn=${d.serial_number}`, "_blank")}
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"}`}>

View File

@ -1,5 +1,4 @@
import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
Printer, RefreshCw, Loader2, QrCode, Plus, Settings, X,
Package, Hash, Tag, MapPin, Clock, Timer, CalendarDays,
@ -29,7 +28,6 @@ interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWar
export default function AdminProductsPage() {
const { toast } = useToast();
const navigate = useNavigate();
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -229,7 +227,7 @@ export default function AdminProductsPage() {
<div className="border-t border-gray-100 px-5 py-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{group.products.map(p => (
<div key={p.id} onClick={() => navigate(`/admin/tasks?sn=${p.serial_number}`)} className="group relative flex cursor-pointer flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
<div key={p.id} onClick={() => window.open(`/admin/tasks?sn=${p.serial_number}`, "_blank")} className="group relative flex cursor-pointer flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
<button onClick={(e) => { e.stopPropagation(); openEdit(p); }} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-blue-50 hover:text-blue-600"><Pencil className="h-3.5 w-3.5" /></button>
<button onClick={(e) => { e.stopPropagation(); confirmDelete(p); }} className="rounded-lg bg-white p-1.5 text-gray-400 shadow-sm hover:bg-red-50 hover:text-red-500"><Trash2 className="h-3.5 w-3.5" /></button>

View File

@ -315,17 +315,21 @@ export default function AnalyticsDashboard() {
},
},
legend: { top: 0, type: "scroll" },
grid: { left: 48, right: 24, top: 80, bottom: 48 }, // top 加大,避免图例遮挡柱子
grid: { left: 48, right: 24, top: 80, bottom: 96 }, // bottom 加大以容纳 X 轴四行标签(名称/规格/序列号/身份证)
xAxis: {
type: "category",
data: chunkDevices.map((d) => d.product_sn),
axisLabel: {
fontSize: 11, interval: 0, color: "#666", lineHeight: 14,
// 🔧 标签改为「产品名称(截断) + 尾号4位」避免 16 位身份证重叠
fontSize: 11, interval: 0, color: "#666", lineHeight: 15, width: 120, overflow: 'break' as const,
// 🔧 X 轴四行展示(与轨迹流转一致):产品名称 / 规格型号 / 业务序列号(若有) / 身份证
formatter: (sn: string) => {
const dev = chunkDevices.find((d) => d.product_sn === sn);
const name = dev?.material_name ? dev.material_name.slice(0, 6) : sn.slice(0, 6);
return `${name} ${sn.slice(-4)}`;
if (!dev) return sn;
const lines = [dev.material_name || "—"];
if (dev.spec_model) lines.push(dev.spec_model);
if (dev.external_serial) lines.push(dev.external_serial);
lines.push(dev.product_sn);
return lines.join("\n");
},
},
},
@ -418,7 +422,7 @@ export default function AnalyticsDashboard() {
});
// 1. 预计算每台设备的统计:累计投入、空闲时长、人员排序(按 isWorkday 选偏移/时长)
const deviceStats = chunkDevices.map((d, idx) => {
const deviceStats = chunkDevices.map((_, idx) => {
let totalInputHours = 0;
const personHours: Record<string, number> = {};
const intervals: [number, number][] = [];
@ -462,7 +466,8 @@ export default function AnalyticsDashboard() {
});
// 背景主干高度:所选模式下的总生命周期(严丝合缝,消除自然空闲断层)
const leadByMode = chunkDevices.map((d, idx) => {
// 🔧 不再伪造 1h设备该是多长就返回多长0 也是 0绝不显示虚假的 1h 兜底)
const leadByMode = chunkDevices.map((_, idx) => {
let maxEnd = 0;
chunkSeries.forEach((s) => s.data.forEach((t) => {
if (t[0] === idx) {
@ -470,7 +475,7 @@ export default function AnalyticsDashboard() {
if (e > maxEnd) maxEnd = e;
}
}));
return Math.max(maxEnd, 1);
return maxEnd;
});
return {