Compare commits
4 Commits
fe495ad707
...
b4795181ee
| Author | SHA1 | Date | |
|---|---|---|---|
| b4795181ee | |||
| 09984951fb | |||
| f65f6e12c6 | |||
| cd5a904a16 |
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"}`}>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user