Compare commits

...

16 Commits

Author SHA1 Message Date
d9a70793f7 feat(产品管理): 当前人时间着色与任务全景一致
- 当前人时间按滞留时长着色:<1h 绿 / ≥1h 橙 / ≥24h 红
- 与任务全景「当前人滞留」列的配色逻辑统一
2026-08-28 11:33:57 +08:00
90f5718e68 fix(驳回/返工): 抽屉与卡片数字口径一致,同时展示已驳回+返工任务
- 根因:卡片数字含返工任务(tasks_rework 实时快照),但抽屉只列已驳回 → 点开为空
- get_rejected_tasks 扩展返回两类(kind=rejected 按时间过滤 / kind=rework 实时不过滤)
- 前端抽屉按「已驳回」「返工任务」分组展示,数字与明细一一对应
2026-08-28 11:33:54 +08:00
1e6adeb576 fix(二维码): 弹窗打开时锁定背景滚动
- 与流转树弹窗一致,二维码弹窗打开时 body overflow=hidden 防止滚动穿透
2026-08-28 11:33:49 +08:00
88f02cbdcf fix(图片): 记录弹窗图片从页面跳转改为点击放大预览
- TaskFlowView 记录图片 window.open 跳转 → antd Image.PreviewGroup 点击放大
- 与人员看板/效能分析的图片查看模式全局统一
2026-08-28 11:33:46 +08:00
1bb93e0a5f fix(流转树): 弹窗打开时锁定背景滚动,消除滚动穿透
- 通用 Modal 打开时 body overflow=hidden,关闭时恢复
- 修复流转树宽屏弹窗滚动时背景看板跟随滚动的问题
- TaskFlowView 记录弹窗同步加锁(覆盖无外层弹窗的页面内打开场景)
2026-08-28 11:24:59 +08:00
ddb1b4364b feat(任务全景): 新增列显隐控制,可勾选去掉不想要的列
- 工具栏新增「列设置」按钮,弹出 checkbox 列表控制列显示/隐藏
- 勾选=显示,取消=去掉该列(如不需要宏观状态可直接取消勾选)
- 操作列(流转树)固定显示不可隐藏,提供「全部显示」重置
- 表格列数与网格跨度按实际显示列动态计算,原有排序/筛选/拖拽保留
2026-08-28 11:21:22 +08:00
7cfaf4b04b feat(任务全景): 流转树改为宽屏弹窗并放大字体
- TaskFlowView 增加 size prop(sm默认/lg放大),弹窗模式卡片/字体/按钮全部放大
- TaskTreeViewer 通用 Modal 导出并支持 widthClass/bodyClassName 定制
- AdminTasksPage 内嵌展开改为宽屏 Modal(max-w-6xl),保留接收/转交/驳回操作
- 记录弹窗放大且 z-index 提升到 z-[60] 保证覆盖弹窗
- 修复 TaskRecordResponse 无 note 字段的类型错误,清理未使用 import
2026-08-28 11:19:08 +08:00
d92fcc4d4a feat(全局概览): 驳回/返工卡片可点击下钻明细抽屉
- 驳回/返工数字改为可点击按钮,打开明细抽屉(仿完成明细)
- RejectedRow 展示设备/被驳回工序/驳回人/返工给谁/原因/时间,行点击跳转任务全景
- 跟随顶部时间筛选(since/until)
2026-08-28 11:19:03 +08:00
7e00683a21 feat(backend): 新增 /dashboard/rejected-tasks 驳回/返工下钻接口
- RejectedTask schema: 设备/工序/驳回人/返工负责人/原因/时间
- get_rejected_tasks 批量窗口取驳回人(reject log) + 复刻 reject_task 的返工负责人追溯逻辑(create log→父任务→自身)
- 复用 get_display_names 中文名映射、BEIJING_TZ 时间转换
2026-08-28 11:18:58 +08:00
645c594ecd fix(任务状态): REJECTED 标签由「待接收」修正为「已驳回」
- constants/task.ts 的 STATUS_CONFIG 中 REJECTED 误复用 PENDING 文案
- 修复后流转树/扫码页等所有用 getStatusConfig 展示驳回任务的地方显示正确
2026-08-28 11:18:53 +08:00
91f10cf818 fix(docker): track 主库 DNS 冲突 — db 服务名被 MOM 抢占导致密码认证失败
- 修复 MOM 网络后 backend 加入 projects_default,MOM 的 inventory_db 在改网络内别名含 db
- backend 内解析 db → 172.20.0.2(MOM库),asyncpg 用 track 用户连错库 → password authentication failed
- DATABASE_URL host 由服务名 db 改为容器名 track_db(全局唯一),消除解析冲突
- 生产 prod 文件本就使用 track_db_prod,无需改动
- 实测:asyncpg 连接成功、产品接口 HTTP 200、登录链路正常
2026-08-28 10:54:23 +08:00
0bd0fbc2ec fix(docker): MOM 数据库连接超时 — 修正网络挂载与数据库地址
- mom_net 从错误的 inventory-backend_default(空孤岛) 改为 MOM 实际所在的 projects_default
- MOM_DB_HOST 从写死的错误 IP 172.20.0.3(实为 inventory_api) 改为容器名 inventory_db,利用 Docker DNS 自动解析,IP 变动不再受影响
- 重建后实测:DNS 解析正常、psycopg2 直连成功、登录接口走 MOM sys_user 验证正常
2026-08-28 10:49:42 +08:00
19c7c7bbf1 feat(任务全景): 表头排序/筛选/列拖拽 + 新增当前人滞留列
- 状态筛选 tabs 去掉「已入库」并支持多选
- 表头点击排序:身份证/序列号/当前人滞留/最新动态/创建时间,三态循环(升/降/取消)
- 表头漏斗筛选:文本列模糊过滤,枚举列(宏观状态/任务状态/当前位置)多选过滤
- 列头拖拽调整列显示顺序
- 新增「当前人滞留」列,读 active_duration_hours 按天/小时/分钟着色展示
- 修复 handleSearch 引用不存在的 setExpandedProducts(应为此前的展开状态) 导致的查询报错
2026-08-28 10:46:20 +08:00
24c124cd30 feat(产品管理): 卡片跳转 + 时间字段 + 状态筛选多选
- 点击产品卡片跳转 /admin/tasks?sn=身份证,与全局概览看板一致
- 卡片新增「当前人时间」(滞留时长) 与「生产总天数」(自创建)
- 状态筛选 tabs 去掉「已入库」(仓库已显示为已完成),改为多选
- 卡片内编辑/删除/打印按钮 stopPropagation 避免误触跳转
- 清理未使用的 import
2026-08-28 10:46:17 +08:00
d007c7b843 feat(frontend): ProductResponse 类型增加 active_duration_hours
- 与后端 ProductResponse 字段同步,供产品管理页/任务全景页展示滞留时长
2026-08-28 10:46:13 +08:00
90d615cbb0 feat(backend): 产品列表返回当前人滞留时长 active_duration_hours
- ProductResponse 增加 active_duration_hours(小时) 字段
- get_all_products 批量计算每个产品活跃任务(WIP/PENDING)最早接手时间到现在的时长
- 复用 get_people_workload 相同的北京时间口径,前端无需额外请求
2026-08-28 10:46:10 +08:00
13 changed files with 987 additions and 207 deletions

View File

@ -9,6 +9,7 @@ from app.services.dashboard_service import (
get_dashboard_stats, DashboardStats,
get_wip_tasks, WipTask,
get_completed_tasks, CompletedTask,
get_rejected_tasks, RejectedTask,
get_people_workload, PersonWorkload,
get_people_history, PersonHistoryRecord,
search_product_messages, ProductMessageList,
@ -56,6 +57,19 @@ async def completed_tasks(
return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit)
@router.get("/rejected-tasks", response_model=list[RejectedTask])
async def rejected_tasks(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"),
limit: int = Query(200, ge=1, le=500),
db: AsyncSession = Depends(get_db),
):
"""驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit)
@router.get("/people-workload", response_model=list[PersonWorkload])
async def people_workload(
db: AsyncSession = Depends(get_db),

View File

@ -61,6 +61,8 @@ class ProductResponse(BaseModel):
latest_record_has_images: bool = False
latest_record_assignee_id: str | None = None # 🔧 最新记录操作人(消除并发张冠李戴)
latest_record_assignee_name: str | None = None
# 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时)
active_duration_hours: float | None = None
model_config = {"from_attributes": True}

View File

@ -48,6 +48,21 @@ class CompletedTask(BaseModel):
completed_at: str # 完成时间 ISO
class RejectedTask(BaseModel):
task_id: str
kind: str = "rejected" # "rejected"(已驳回) | "rework"(返工任务)
task_name: str # 工序
product_sn: str # 16位HEX身份证
external_serial: str | None # 业务序列号
material_name: str # 产品名称(物料名称)
spec_model: str # 规格型号
rejected_by: str = "" # 驳回人中文姓名rework 可能无)
rework_assignee: str # 返工任务负责人中文姓名(转交给谁返工)
reject_reason: str | None # 驳回原因
status: str | None = None # 当前状态rework 任务用PENDING/WIP/COMPLETED
rejected_at: str | None # 驳回时间 ISOBEIJING_TZ
class PersonDevice(BaseModel):
product_id: str
serial_number: str # 16位HEX身份证
@ -283,6 +298,173 @@ async def get_completed_tasks(
return items
# ============================================================
# 被驳回任务明细(品质驳回下钻 — 按时段过滤)
# ============================================================
async def get_rejected_tasks(
db: AsyncSession,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 200,
) -> list[RejectedTask]:
"""查询驳回/返工任务明细(上帝视角),用于「驳回/返工」卡片下钻。
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
- kind="rejected":被驳回任务,按 completed_at 时间过滤
- kind="rework"返工任务is_rework=True 且非驳回状态),实时快照不过滤时间
返工负责人追溯逻辑与 task_service.reject_task 一致:
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
"""
from app.models.task import Task, TASK_STATUS_REJECTED
from app.models.task_log import TaskLog
from app.models.product import Product
from app.core.time_utils import BEIJING_TZ
def _to_bj_iso(dt) -> str:
if not dt:
return ""
if dt.tzinfo is None:
from datetime import timezone as dt_timezone
dt = dt.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
else:
dt = dt.astimezone(BEIJING_TZ)
return dt.isoformat()
# ── 1. 已驳回任务(按时间过滤)──
stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id)
.where(Task.status == TASK_STATUS_REJECTED)
)
if since:
stmt = stmt.where(Task.completed_at >= since)
if until:
stmt = stmt.where(Task.completed_at <= until)
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
rejected_rows = (await db.execute(stmt)).all()
# ── 2. 返工任务is_rework=True 且当前非驳回状态,实时不过滤时间)──
rework_stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id)
.where(Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED)
.order_by(Task.created_at.desc())
.limit(limit)
)
rework_rows = (await db.execute(rework_stmt)).all()
# 合并rejected 在前rework 在后
all_rows: list[tuple[str, object]] = [
*[("rejected", t) for t in rejected_rows],
*[("rework", t) for t in rework_rows],
]
# ── 已驳回任务:批量取驳回人 / 最早 create log / 父任务负责人 ──
rejected_ids = [t.id for t, *_ in rejected_rows]
reject_op: dict = {}
if rejected_ids:
sub = (
select(
TaskLog.task_id, TaskLog.operator_id,
func.row_number().over(
partition_by=TaskLog.task_id,
order_by=TaskLog.created_at.desc(),
).label("rn"),
)
.where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "reject")
).subquery()
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
for row in r:
if row[1]:
reject_op[row[0]] = row[1]
create_op: dict = {}
if rejected_ids:
sub = (
select(
TaskLog.task_id, TaskLog.operator_id,
func.row_number().over(
partition_by=TaskLog.task_id,
order_by=TaskLog.created_at.asc(),
).label("rn"),
)
.where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "create")
).subquery()
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
for row in r:
if row[1]:
create_op[row[0]] = row[1]
parent_assignee: dict = {}
parent_ids = [t.parent_task_id for t, *_ in rejected_rows if t.parent_task_id and t.id not in create_op]
if parent_ids:
r = await db.execute(
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
)
for row in r:
if row[1]:
parent_assignee[row[0]] = row[1]
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
raw_ids: set[str] = set()
for kind, (t, *_row) in all_rows:
if kind == "rejected":
raw_ids.add(reject_op.get(t.id) or "")
raw_ids.add(create_op.get(t.id) or "")
if t.id not in create_op and t.parent_task_id:
raw_ids.add(parent_assignee.get(t.parent_task_id) or "")
raw_ids.add(t.assignee_id or "")
raw_ids.discard("")
name_map: dict[str, str] = {}
if raw_ids:
from app.services.mom_cache import get_display_names
name_map = get_display_names(list(raw_ids))
items: list[RejectedTask] = []
for kind, (task, sn, ext, mat, spec) in all_rows:
if kind == "rejected":
# 复刻 reject_task 追溯逻辑create op → 父任务负责人 → 自身
rework_id = create_op.get(task.id)
if not rework_id and task.parent_task_id:
rework_id = parent_assignee.get(task.parent_task_id)
if not rework_id:
rework_id = task.assignee_id
rejected_by_id = reject_op.get(task.id) or task.assignee_id
items.append(RejectedTask(
task_id=str(task.id),
kind="rejected",
task_name=task.task_name,
product_sn=sn or "",
external_serial=ext or None,
material_name=mat or "",
spec_model=spec or "",
rejected_by=name_map.get(rejected_by_id or "", rejected_by_id or ""),
rework_assignee=name_map.get(rework_id or "", rework_id or ""),
reject_reason=task.reject_reason,
status=task.status,
rejected_at=_to_bj_iso(task.completed_at),
))
else:
# 返工任务:负责人即其 assignee
items.append(RejectedTask(
task_id=str(task.id),
kind="rework",
task_name=task.task_name,
product_sn=sn or "",
external_serial=ext or None,
material_name=mat or "",
spec_model=spec or "",
rejected_by="",
rework_assignee=name_map.get(task.assignee_id or "", task.assignee_id or ""),
reject_reason=None,
status=task.status,
rejected_at=_to_bj_iso(task.received_at or task.created_at),
))
return items
# ============================================================
# 人员负载(按人聚合在制品设备 — 独立「人员看板」)
# ============================================================

View File

@ -541,6 +541,34 @@ async def get_all_products(
has_img = bool(row[3] and row[3] != "[]" and row[3] != "null")
latest_record_map[row[0]] = (row[1], row[2], has_img, row[4])
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时
active_duration_map: dict[uuid.UUID, float] = {}
if product_ids:
from sqlalchemy import func as sa_func
from app.core.time_utils import get_beijing_time, BEIJING_TZ
start_stmt = (
select(
Task.product_id,
sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)),
)
.where(
Task.product_id.in_(product_ids),
Task.status.in_(["WIP", "PENDING"]),
)
.group_by(Task.product_id)
)
start_result = await db.execute(start_stmt)
now = get_beijing_time()
for row in start_result:
start = row[1]
if start is None:
continue
if start.tzinfo is None:
start = start.replace(tzinfo=BEIJING_TZ)
else:
start = start.astimezone(BEIJING_TZ)
active_duration_map[row[0]] = round((now - start).total_seconds() / 3600, 1)
return [
ProductResponse(
id=p.id,
@ -575,6 +603,7 @@ async def get_all_products(
merged_name_map.get(latest_record_map.get(p.id, (None, None, False, None))[3])
if latest_record_map.get(p.id, (None, None, False, None))[3] else None
),
active_duration_hours=active_duration_map.get(p.id),
)
for p in products
]

View File

@ -37,11 +37,13 @@ services:
container_name: track_backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@db:5432/track_production
# 🚀 track 主库用容器名 track_db避免与 MOM 的 inventory_db(别名 db) 在同一网络下 DNS 解析冲突
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db:5432/track_production
SECRET_KEY: change-me-in-production
DEBUG: "true"
CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","tauri://localhost"]'
MOM_DB_HOST: 172.20.0.3
# 🚀 MOM 老系统数据库 — 通过容器名解析projects_default 网络内 DNS不再写死 IP
MOM_DB_HOST: inventory_db
MOM_DB_PORT: "5432"
ports:
- "8011:8000"
@ -76,7 +78,7 @@ services:
networks:
mom_net:
external: true
name: inventory-backend_default
name: projects_default
volumes:
track_pgdata:

View File

@ -1,8 +1,9 @@
/**
* 流转树双模式可视化 — 焦点模式 + 全景模式
*/
import { memo, useMemo, useState } from "react";
import { GitBranch, AlertTriangle, Clock, CheckCircle, Flag, FileText, X } from "lucide-react";
import { memo, useMemo, useState, useEffect } from "react";
import { Image } from "antd";
import { FileText, X } from "lucide-react";
import type { TaskResponse } from "../../types/api";
import { TASK_STATUS } from "../../types/api";
import { getStatusConfig } from "../../constants/task";
@ -15,12 +16,12 @@ function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d);
function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY"; }
function active(s: string) { return s === "WIP" || s === "PENDING"; }
/** 微型右箭头 SVG */
function ArrowRight({ color = "#9ca3af" }: { color?: string }) {
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
function ArrowRight({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
}
/** 微型左箭头 SVG */
function ArrowLeft({ color = "#9ca3af" }: { color?: string }) {
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
function ArrowLeft({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
}
function parseImages(s: any): string[] {
if (!s) return [];
@ -48,56 +49,92 @@ const ALL_TASKS = new Set<TaskResponse>();
function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); }
function findParent(child: TaskResponse): TaskResponse | undefined { for (const t of ALL_TASKS) { if (t.id === child.parent_task_id) return t; } return undefined; }
// ============================================================
// 尺寸映射表sm=现状小卡片lg=弹窗放大)
// ============================================================
const SIZE_MAP = {
sm: {
card: "w-44 p-2.5 shadow-sm",
badge: "-top-1.5 right-2 px-1.5 py-px text-[8px]",
title: "mt-1 text-xs",
metaRow: "mt-1",
status: "px-1.5 py-px text-[8px]",
assignee: "text-[9px]",
time: "mt-1 text-[8px]",
sub: "mt-1 text-[8px]",
btnRow: "mt-1.5 pt-1.5",
btn: "py-0.5 text-[8px]",
record: "mt-1 px-1.5 py-0.5 text-[8px]",
recordIcon: "h-2.5 w-2.5",
},
lg: {
card: "w-72 p-4 shadow-md",
badge: "-top-2 right-3 px-2 py-0.5 text-xs",
title: "mt-1.5 text-base",
metaRow: "mt-2",
status: "px-2 py-0.5 text-xs",
assignee: "text-sm",
time: "mt-2 text-xs",
sub: "mt-1.5 text-xs",
btnRow: "mt-2.5 pt-2.5",
btn: "py-1.5 text-sm",
record: "mt-2 px-2.5 py-1 text-xs",
recordIcon: "h-4 w-4",
},
} as const;
// ============================================================
// 极简卡片
// ============================================================
const SlimCard = memo(function SlimCard({
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId,
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm",
}: {
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
onViewRecords?: (t: TaskResponse) => void;
rootMainId?: string;
size?: "sm" | "lg";
}) {
const cfg = getStatusConfig(task.status);
const sz = SIZE_MAP[size];
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
const main = isMain(task);
const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id;
return (
<div className={`relative w-44 shrink-0 rounded-lg border bg-white p-2.5 shadow-sm ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
<div className={`absolute -top-1.5 right-2 rounded px-1.5 py-px text-[8px] font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
<p className="mt-1 text-xs font-bold text-gray-800 truncate">{task.task_name}</p>
<div className="mt-1 flex items-center gap-1">
<span className={`rounded-full px-1.5 py-px text-[8px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
<span className="text-[9px] text-gray-400 truncate">{assigneeName || task.assignee_id || "—"}</span>
<div className={`relative shrink-0 rounded-lg border bg-white ${sz.card} ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
<div className={`absolute ${sz.badge} rounded font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
<p className={`${sz.title} font-bold text-gray-800 truncate`}>{task.task_name}</p>
<div className={`${sz.metaRow} flex items-center gap-1`}>
<span className={`rounded-full ${sz.status} font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
<span className={`${sz.assignee} text-gray-400 truncate`}>{assigneeName || task.assignee_id || "—"}</span>
</div>
{/* 单行时间 */}
<p className="mt-1 text-[8px] text-gray-300">
<p className={`${sz.time} text-gray-300`}>
{fmtTime(task.created_at).split(" ")[0]}
{task.completed_at ? `${fmtTime(task.completed_at).split(" ")[0]}` : " → 至今"}
</p>
{legacy && <p className="mt-1 text-[8px] text-orange-500">: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
{isNestedSpawn && <p className="mt-1 text-[8px] text-purple-500">: {findParent(task)?.assignee_id || "—"}</p>}
{legacy && <p className={`${sz.sub} text-orange-500`}>: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
{isNestedSpawn && <p className={`${sz.sub} text-purple-500`}>: {findParent(task)?.assignee_id || "—"}</p>}
{/* 操作按钮 */}
{active && isOwner && (
<div className="mt-1.5 flex gap-1 border-t border-gray-100 pt-1.5">
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-0.5 text-[8px] text-blue-600"></button>}
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-0.5 text-[8px] text-green-600"></button>}
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-0.5 text-[8px] text-red-500"></button>
<div className={`${sz.btnRow} flex gap-1 border-t border-gray-100`}>
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className={`flex-1 rounded border border-blue-200 bg-blue-50 ${sz.btn} text-blue-600`}></button>}
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-green-200 bg-green-50 ${sz.btn} text-green-600`}></button>}
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-red-200 bg-red-50 ${sz.btn} text-red-500`}></button>
</div>
)}
{active && !isOwner && isManager && (
<div className="mt-1.5 flex gap-1 border-t border-orange-100 pt-1.5">
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600"></button>
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600"></button>
<div className={`${sz.btnRow} flex gap-1 border-t border-orange-100`}>
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}></button>
<button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}></button>
</div>
)}
{/* 记录 */}
{task.records && task.records.length > 0 && (
<div onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }} className="mt-1 cursor-pointer rounded bg-blue-50 px-1.5 py-0.5 text-[8px] text-blue-600 hover:bg-blue-100">
<FileText className="mr-0.5 inline h-2.5 w-2.5" />{task.records.length}
<div onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }} className={`${sz.record} cursor-pointer rounded bg-blue-50 text-blue-600 hover:bg-blue-100`}>
<FileText className={`mr-0.5 inline ${sz.recordIcon}`} />{task.records.length}
</div>
)}
</div>
@ -111,12 +148,21 @@ interface TaskFlowViewProps {
tasks: TaskResponse[]; onAction: (t: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeNames?: Record<string, string>;
size?: "sm" | "lg";
}
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames, size = "sm" }: TaskFlowViewProps) {
const [showFullMap, setShowFullMap] = useState(false);
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
// 🚀 记录弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
useEffect(() => {
if (!recordsTask) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [recordsTask]);
// 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染
const { allMains, childMap } = useMemo(() => {
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
@ -139,12 +185,13 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
}, [tasks]);
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => {
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): React.JSX.Element => {
const kids = childMap[node.id] || [];
const big = size === "lg";
const arrow = side === 'left'
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} /><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
: (<div className="flex items-center"><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} /></div>);
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} />;
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>);
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} />;
const kidsContainer = kids.length > 0 ? (
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
@ -178,7 +225,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
{/* 模式切换 */}
<div className="mb-3 flex justify-center">
<button onClick={() => setShowFullMap(!showFullMap)}
className="rounded-full bg-gray-100 px-4 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-200 transition-colors">
className={`rounded-full bg-gray-100 font-medium text-gray-600 hover:bg-gray-200 transition-colors ${size === "lg" ? "px-5 py-2 text-sm" : "px-4 py-1.5 text-xs"}`}>
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
</button>
</div>
@ -195,7 +242,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
return (
<div key={mainTask.id} className="relative">
<div className="absolute left-1/2 top-0 bottom-0 w-0.5 bg-gray-200 -translate-x-1/2 z-0" />
<div className={`absolute left-1/2 top-0 bottom-0 -translate-x-1/2 z-0 bg-gray-200 ${size === "lg" ? "w-1" : "w-0.5"}`} />
<div className="flex flex-row items-start w-full">
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
@ -204,9 +251,9 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
{/* 中央 */}
<div className="shrink-0 z-10 relative">
<SlimCard task={mainTask} active={active(mainTask.status)}
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} />
{active(mainTask.status) && (
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
)}
</div>
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
@ -217,14 +264,14 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
<div className="flex justify-center py-2">
<span className="text-[10px] text-gray-300"></span>
<span className={`text-gray-300 ${size === "lg" ? "text-sm" : "text-[10px]"}`}></span>
</div>
)}
</div>
);
})}
{visibleMains.length === 0 && (
<p className="text-center text-xs text-gray-400 py-8">
<p className={`text-center text-gray-400 py-8 ${size === "lg" ? "text-sm" : "text-xs"}`}>
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
</p>
)}
@ -232,17 +279,21 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
{/* 记录弹窗 */}
{recordsTask && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setRecordsTask(null)} />
<div className="relative z-10 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-xl bg-white p-5 shadow-2xl">
<div className="mb-3 flex items-center justify-between"><h3 className="text-sm font-bold"> {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
<div className={`relative z-10 mx-4 max-h-[80vh] w-full overflow-y-auto rounded-xl bg-white shadow-2xl ${size === "lg" ? "max-w-2xl p-6" : "max-w-md p-5"}`}>
<div className={`mb-3 flex items-center justify-between`}><h3 className={`font-bold ${size === "lg" ? "text-lg" : "text-sm"}`}> {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
{(recordsTask.records || []).length === 0 ? <p className="py-8 text-center text-sm text-gray-400"></p> :
<div className="space-y-2">{[...recordsTask.records!].reverse().map((r, i) => (
<div key={r.id} className="flex gap-2">
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} />
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className="text-[10px] text-gray-400">{fmtTime(r.created_at)}</p>
{(r.note || r.remark) && <p className="mt-0.5 text-xs text-gray-700">{r.note || r.remark}</p>}
{(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className="h-14 w-14 rounded border object-cover cursor-pointer hover:opacity-80 transition-opacity" onClick={() => window.open(imageUrl(img))} />)}</div>; })()}
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className={`text-gray-400 ${size === "lg" ? "text-xs" : "text-[10px]"}`}>{fmtTime(r.created_at)}</p>
{r.remark && <p className={`mt-0.5 text-gray-700 ${size === "lg" ? "text-sm" : "text-xs"}`}>{r.remark}</p>}
{(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return (
<Image.PreviewGroup>
<div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <Image key={j} src={imageUrl(img)} width={size === "lg" ? 96 : 56} height={size === "lg" ? 96 : 56} className="rounded border object-cover" style={{ objectFit: "cover" }} />)}</div>
</Image.PreviewGroup>
); })()}
</div>
</div>
))}</div>}

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, memo } from "react";
import { useState, useCallback, useMemo, useEffect, memo } from "react";
import {
Search,
Loader2,
@ -25,17 +25,29 @@ import { getStatusConfig } from "../../constants/task";
// 通用 Modal 容器
// ============================================================
const Modal = memo(function Modal({
export const Modal = memo(function Modal({
open,
onClose,
title,
children,
widthClass = "max-w-md",
bodyClassName = "",
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
widthClass?: string;
bodyClassName?: string;
}) {
// 🚀 弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [open]);
if (!open) return null;
return (
@ -46,7 +58,7 @@ const Modal = memo(function Modal({
onClick={onClose}
/>
{/* 弹窗 */}
<div className="relative z-10 mx-4 w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
<div className={`relative z-10 mx-4 w-full ${widthClass} rounded-xl bg-white p-6 shadow-2xl ${bodyClassName}`}>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-bold text-gray-800">{title}</h3>
<button

View File

@ -36,7 +36,7 @@ export const STATUS_CONFIG: Record<string, StatusStyle> = {
bg: "bg-red-50",
text: "text-red-700",
ring: "ring-red-400",
label: "待接收",
label: "已驳回",
},
[TASK_STATUS.ARCHIVED]: {
bg: "bg-gray-50",

View File

@ -7,8 +7,8 @@ import { useNavigate } from "react-router-dom";
import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask,
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask,
} from "../../services/dashboardApi";
const { RangePicker } = DatePicker;
@ -188,6 +188,59 @@ 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}`)}
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">
{isRework ? <RefreshCw className="h-4 w-4 shrink-0 text-orange-500" /> : <AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />}
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
<span className="text-xs text-gray-500">|</span>
{isRework ? (
<span className="shrink-0 rounded-full bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
{t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"}
</span>
) : (
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600"></span>
)}
<span className="text-xs text-gray-500 shrink-0">
{isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`}
</span>
<span className="ml-auto shrink-0 text-xs text-gray-400">{timeStr}</span>
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
{!isRework && (
<>
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
🔁 : {t.rework_assignee}
</span>
<span className="text-gray-300">|</span>
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>: {t.reject_reason || "—"}</span>
</>
)}
{isRework && (
<span className="truncate text-orange-600/80">🔁 · {t.rework_assignee} </span>
)}
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
<span className="text-gray-300">|</span>
<span>{t.spec_model || "无规格"}</span>
<span className="text-gray-300">|</span>
<span>: {t.external_serial || "未录入"}</span>
<span className="text-gray-300">|</span>
<span className="font-mono text-gray-300">: {t.product_sn}</span>
</div>
</div>
);
}
// ─── 主组件 ───────────────────────────────────────────────
export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
@ -211,6 +264,11 @@ export default function AdminDashboard() {
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
const [completedLoading, setCompletedLoading] = useState(false);
// 驳回/返工明细抽屉
const [rejectedDrawerOpen, setRejectedDrawerOpen] = useState(false);
const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]);
const [rejectedLoading, setRejectedLoading] = useState(false);
const navigate = useNavigate();
// ── 加载主数据 ──
@ -255,6 +313,16 @@ export default function AdminDashboard() {
.finally(() => setCompletedLoading(false));
};
const openRejectedDrawer = () => {
setRejectedDrawerOpen(true);
setRejectedLoading(true);
const { since, until } = rangeToParams(dateKey, customRange);
fetchRejectedTasks(since, until)
.then(setRejectedTasks)
.catch(() => setRejectedTasks([]))
.finally(() => setRejectedLoading(false));
};
const onMsgSearch = (value: string) => {
setMsgKeyword(value);
loadMessages(value);
@ -358,10 +426,16 @@ export default function AdminDashboard() {
<h3 className="text-sm font-semibold text-gray-700"> </h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-red-50 p-3 text-center">
{/* 驳回/返工 — 可点击打开明细抽屉 */}
<button
onClick={openRejectedDrawer}
className="rounded-lg bg-red-50 p-3 text-center border-0 cursor-pointer transition-colors hover:bg-red-100"
>
<p className="text-xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
<p className="text-[11px] text-red-500">/</p>
</div>
<p className="flex items-center justify-center gap-1 text-[11px] text-red-500">
<AlertTriangle className="h-3 w-3" />/
</p>
</button>
{/* 留言 — 可点击打开抽屉 */}
<button
onClick={openMsgDrawer}
@ -527,6 +601,52 @@ export default function AdminDashboard() {
</div>
)}
</Drawer>
{/* ═══ 驳回/返工明细抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">🔴 / <span className="font-normal text-gray-400"> {rejectedTasks.length} </span></span>}
open={rejectedDrawerOpen}
onClose={() => setRejectedDrawerOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
{rejectedLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-red-500" />
</div>
) : rejectedTasks.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">
/
</div>
) : (
<div className="space-y-4">
{(() => {
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
const rework = rejectedTasks.filter(t => t.kind === "rework");
return (
<>
{rejected.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
<AlertTriangle className="h-3.5 w-3.5" /> · {rejected.length}
</h4>
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div>
)}
{rework.length > 0 && (
<div>
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-orange-600">
<RefreshCw className="h-3.5 w-3.5" /> · {rework.length}
</h4>
{rework.map(t => <RejectedRow key={t.task_id} t={t} />)}
</div>
)}
</>
);
})()}
</div>
)}
</Drawer>
</div>
);
}

View File

@ -1,32 +1,32 @@
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, Pencil, Trash2, Save, AlertTriangle,
Package, Hash, Tag, MapPin, Clock, Timer, CalendarDays,
Pencil, Trash2, Save, AlertTriangle,
Search, ChevronDown, ChevronRight, Warehouse, Barcode,
} from "lucide-react";
import api from "../../services/api";
import type { ProductResponse } from "../../types/admin";
import CreateProductDialog from "./CreateProductDialog";
import {
getLabelPreview, executePrint, type LabelPreviewRequest,
getLabelPreview, executePrint,
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
const QR_BASE = "/api/v1/products/qrcode";
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
];
interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWarehouse: boolean; }
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);
@ -34,7 +34,7 @@ export default function AdminProductsPage() {
// 搜索 & 筛选 & 分组
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
@ -73,13 +73,13 @@ export default function AdminProductsPage() {
function handleSearch(e?: React.FormEvent) { e?.preventDefault(); loadProducts(); }
// ---- 分组 + 状态过滤 ----
// ---- 分组 + 状态过滤(多选)----
const productGroups = useMemo<ProductGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilter) {
if (statusFilters.size > 0) {
const s = (p.macro_status || p.status).toUpperCase();
if (s !== statusFilter) continue;
if (!statusFilters.has(s)) continue;
}
const key = groupBy === "device"
? (p.material_name || p.material_id || "未命名设备")
@ -91,7 +91,15 @@ export default function AdminProductsPage() {
groupKey, products: prods,
allInWarehouse: prods.every(p => p.current_location_id === "virtual_warehouse"),
}));
}, [products, statusFilter, groupBy]);
}, [products, statusFilters, groupBy]);
function toggleStatusFilter(key: string) {
setStatusFilters(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// 🔧 默认全部展开productGroups 变化时自动展开所有面板
useEffect(() => {
@ -161,12 +169,19 @@ export default function AdminProductsPage() {
</button>
</form>
<div className="mt-3 flex gap-1.5 flex-wrap">
{STATUS_TABS.map(tab => (
<button key={tab.key} onClick={() => setStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${statusFilter === tab.key ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
{tab.label}
</button>
))}
<button onClick={() => setStatusFilters(new Set())}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${statusFilters.size === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
</button>
{STATUS_TABS.map(tab => {
const active = statusFilters.has(tab.key);
return (
<button key={tab.key} onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${active ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
{tab.label}
</button>
);
})}
</div>
</div>
@ -210,10 +225,10 @@ 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} className="group relative flex flex-col rounded-xl bg-white shadow-sm ring-1 ring-gray-100 transition-shadow hover:shadow-md">
<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 className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 z-10">
<button onClick={() => 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={() => 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>
<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>
</div>
<div className="flex flex-col items-center px-4 pt-5 pb-3">
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
@ -225,10 +240,21 @@ export default function AdminProductsPage() {
<InfoRow icon={Hash} label="规格型号" value={p.spec_model || "—"} />
<InfoRow icon={Tag} label="订单编号" value={p.order_no || "—"} />
<InfoRow icon={MapPin} label="当前位置" value={p.current_location_name || p.current_location_id || "—"} />
<div className="flex items-center gap-2 text-xs">
<Timer className="h-3 w-3 shrink-0 text-gray-400" />
<span className="shrink-0 text-gray-400"></span>
{(() => {
const h = p.active_duration_hours;
if (h == null) return <span className="truncate font-medium text-gray-700"></span>;
const cls = h >= 24 ? "bg-red-50 text-red-600" : h >= 1 ? "bg-orange-50 text-orange-600" : "bg-emerald-50 text-emerald-600";
return <span className={`rounded-md px-1.5 py-0.5 font-bold ${cls}`}>{formatDuration(h)}</span>;
})()}
</div>
<InfoRow icon={CalendarDays} label="生产总天数" value={formatProductionDays(p.created_at)} />
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
</div>
<div className="border-t border-gray-50 px-4 py-3">
<button onClick={() => handleOpenPrint(p)} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
<button onClick={(e) => { e.stopPropagation(); handleOpenPrint(p); }} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
<Printer className="h-3.5 w-3.5" />
</button>
</div>
@ -294,3 +320,17 @@ export default function AdminProductsPage() {
function InfoRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return <div className="flex items-center gap-2 text-xs"><Icon className="h-3 w-3 shrink-0 text-gray-400" /><span className="shrink-0 text-gray-400">{label}</span><span className="truncate font-medium text-gray-700">{value}</span></div>;
}
/** 滞留时长格式化:小时 → 天/小时/分钟 */
function formatDuration(hours: number | null): string {
if (hours == null) return "—";
if (hours >= 24) return `${Math.round(hours / 24)}`;
if (hours >= 1) return `${Math.round(hours)}小时`;
return `${Math.max(1, Math.round(hours * 60))}分钟`;
}
/** 生产总天数从产品创建created_at到现在向上取整最少 1 天 */
function formatProductionDays(createdAt: string): string {
const days = Math.max(1, Math.ceil((Date.now() - new Date(createdAt).getTime()) / 86400000));
return `${days}`;
}

View File

@ -3,9 +3,9 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
} from "lucide-react";
import { Tooltip } from "antd";
import { Tooltip, Popover, Checkbox, Input } from "antd";
import api from "../../services/api";
import { scanProduct } from "../../services/productApi";
import {
@ -13,27 +13,47 @@ import {
} from "../../services/taskApi";
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse, TaskResponse } from "../../types/api";
import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
import { getStatusConfig } from "../../constants/task";
import { useAuth } from "../../contexts/AuthContext";
const STATUS_TABS = [
{ key: "", label: "全部" },
{ key: "PENDING", label: "待接收" },
{ key: "WIP", label: "进行中" },
{ key: "COMPLETED", label: "已完成" },
{ key: "ARCHIVED", label: "已入库" },
];
type SortOrder = "asc" | "desc";
interface ColumnDef {
key: string;
label: string;
colSpan: number;
sortable?: boolean;
sortValue?: (p: ProductResponse) => string | number;
filterType?: "text" | "enum";
getFilterValue?: (p: ProductResponse) => string;
enumOptions?: { value: string; label: string }[];
render: (p: ProductResponse) => React.ReactNode;
}
interface OrderGroup {
orderNo: string;
products: ProductResponse[];
allInWarehouse: boolean;
}
/** 滞留时长格式化:小时 → 天/小时/分钟 */
function formatDuration(hours: number | null): string {
if (hours == null) return "—";
if (hours >= 24) return `${Math.round(hours / 24)}`;
if (hours >= 1) return `${Math.round(hours)}小时`;
return `${Math.max(1, Math.round(hours * 60))}分钟`;
}
export default function AdminTasksPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
@ -41,7 +61,7 @@ export default function AdminTasksPage() {
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(false);
@ -60,6 +80,121 @@ export default function AdminTasksPage() {
const [submitting, setSubmitting] = useState(false);
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
// ---- 列配置10列----
const columns: ColumnDef[] = [
{
key: "serial_number", label: "产品身份证", colSpan: 2,
sortable: true, sortValue: (p) => p.serial_number,
filterType: "text", getFilterValue: (p) => p.serial_number,
render: (p) => (
<div className="font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
),
},
{
key: "external_serial", label: "序列号", colSpan: 1,
sortable: true, sortValue: (p) => p.external_serial || "",
filterType: "text", getFilterValue: (p) => p.external_serial || "",
render: (p) => <div className="font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>,
},
{
key: "spec", label: "规格型号", colSpan: 2,
filterType: "text", getFilterValue: (p) => p.spec_model || p.material_name || p.material_id || "",
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
},
{
key: "overall_status", label: "宏观状态", colSpan: 1,
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
enumOptions: ["备货", "生产", "测试", "维修", "在库"].map((v) => ({ value: v, label: v })),
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>,
},
{
key: "status", label: "任务状态", colSpan: 1,
filterType: "enum", getFilterValue: (p) => (p.macro_status || p.status).toUpperCase(),
enumOptions: [
{ value: "PENDING", label: "待接收" },
{ value: "WIP", label: "进行中" },
{ value: "COMPLETED", label: "已完成" },
],
render: (p) => {
const statusCfg = getStatusConfig(p.macro_status || p.status);
return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span>;
},
},
{
key: "location", label: "当前位置", colSpan: 1,
filterType: "enum",
getFilterValue: (p) => p.current_location_id === "virtual_warehouse" ? "仓库" : (p.current_location_name || p.current_location_id || "—"),
render: (p) => (
<div className="text-xs text-gray-500 truncate">
{p.current_location_id === "virtual_warehouse" ? <span className="inline-flex items-center gap-1 text-purple-600">🏭 </span> : (p.current_location_name || p.current_location_id || "—")}
</div>
),
},
{
key: "active_duration", label: "当前人滞留", colSpan: 1,
sortable: true, sortValue: (p) => p.active_duration_hours ?? -1,
render: (p) => {
const h = p.active_duration_hours;
if (h == null) return <span className="text-xs text-gray-300"></span>;
const cls = h >= 24 ? "bg-red-50 text-red-600" : h >= 1 ? "bg-orange-50 text-orange-600" : "bg-emerald-50 text-emerald-600";
return <span className={`rounded-md px-1.5 py-0.5 text-xs font-bold ${cls}`}>{formatDuration(h)}</span>;
},
},
{
key: "latest", label: "最新动态", colSpan: 2,
sortable: true, sortValue: (p) => p.latest_record_time ? new Date(p.latest_record_time).getTime() : 0,
filterType: "text",
getFilterValue: (p) => {
const t = p.latest_record_time ? new Date(p.latest_record_time).toLocaleString("zh-CN") : "";
return `${p.latest_record_assignee_name || ""} ${p.latest_record_content || ""} ${t}`;
},
render: (p) => (
p.latest_record_time ? (
<Tooltip title={(p.latest_record_assignee_name ? `${p.latest_record_assignee_name}: ` : "") + (p.latest_record_content || "") + (p.latest_record_has_images ? " [含图片]" : "")}>
<div className="cursor-default">
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
<div className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-gray-600">
{p.latest_record_has_images && <span className="shrink-0">📷</span>}
<span className="truncate">
{p.latest_record_assignee_name && <span className="font-medium text-gray-700">{p.latest_record_assignee_name}: </span>}
{p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")}
</span>
</div>
</div>
</Tooltip>
) : <span className="text-gray-300"></span>
),
},
{
key: "created_at", label: "创建时间", colSpan: 2,
sortable: true, sortValue: (p) => new Date(p.created_at).getTime(),
filterType: "text", getFilterValue: (p) => new Date(p.created_at).toLocaleDateString("zh-CN"),
render: (p) => <div className="text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>,
},
{
key: "actions", label: "操作", colSpan: 2,
render: (p) => {
const isTreeLoading = treeLoading[p.serial_number];
return (
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
</button>
);
},
},
];
// ---- 列顺序 / 行排序 / 列筛选状态 ----
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
const [textFilters, setTextFilters] = useState<Record<string, string>>({});
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>({});
const [dragCol, setDragCol] = useState<string | null>(null);
const [dragOverCol, setDragOverCol] = useState<string | null>(null);
// 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示)
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
// 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树
const autoSn = searchParams.get("sn") || "";
@ -85,9 +220,9 @@ export default function AdminTasksPage() {
if (autoSn) {
setKeyword(autoSn);
loadProducts(autoSn).then((data) => {
// 产品加载完成后自动开流转树
// 产品加载完成后自动开流转树弹窗
const found = data.find((p: ProductResponse) => p.serial_number === autoSn);
if (found) toggleProductTree(found.serial_number);
if (found) openTreeModal(found.serial_number);
});
} else {
loadProducts(keyword);
@ -96,19 +231,40 @@ export default function AdminTasksPage() {
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();
setExpandedProducts(new Set());
setExpandedOrders(new Set());
setTaskTrees({});
setActiveTreeProductId(null);
loadProducts(keyword);
}
// ---- 按订单/设备分组 + 本地状态过滤 ----
// 🚀 二维码弹窗打开时锁定背景滚动(防止滚动穿透)
useEffect(() => {
if (!qrSerial) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [qrSerial]);
// ---- 按订单/设备分组 + 状态过滤 + 列筛选 + 组内排序 ----
const orderGroups = useMemo<OrderGroup[]>(() => {
const map = new Map<string, ProductResponse[]>();
for (const p of products) {
if (statusFilter) {
if (statusFilters.size > 0) {
const currentStatus = (p.macro_status || p.status).toUpperCase();
if (currentStatus !== statusFilter) continue;
if (!statusFilters.has(currentStatus)) continue;
}
// 列筛选(所有列)
let skip = false;
for (const col of columns) {
if (col.filterType === "text") {
const kw = (textFilters[col.key] || "").trim().toLowerCase();
if (kw && !(col.getFilterValue!(p) || "").toLowerCase().includes(kw)) { skip = true; break; }
} else if (col.filterType === "enum") {
const set = enumFilters[col.key];
if (set && set.size > 0 && !set.has(col.getFilterValue!(p))) { skip = true; break; }
}
}
if (skip) continue;
const key = groupBy === "device"
? (p.material_name || p.material_id || "未命名设备")
: (p.order_no || "未绑定订单");
@ -117,14 +273,96 @@ export default function AdminTasksPage() {
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([orderNo, prods]) => ({
orderNo,
products: prods,
allInWarehouse: prods.every(
(p) => p.current_location_id === "virtual_warehouse"
),
}));
}, [products, statusFilter, groupBy]);
.map(([orderNo, prods]) => {
// 组内排序(升/降)
let sorted = prods;
if (sort) {
const col = columns.find((c) => c.key === sort!.key);
if (col?.sortValue) {
sorted = [...prods].sort((a, b) => {
const va = col.sortValue!(a);
const vb = col.sortValue!(b);
const cmp = (typeof va === "number" && typeof vb === "number")
? va - vb
: String(va ?? "").localeCompare(String(vb ?? ""));
return sort!.order === "asc" ? cmp : -cmp;
});
}
}
return {
orderNo,
products: sorted,
allInWarehouse: prods.every(
(p) => p.current_location_id === "virtual_warehouse"
),
};
});
}, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps
function toggleStatusFilter(key: string) {
setStatusFilters((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// ---- 列头排序:无 → 升序 → 降序 → 无 ----
function toggleSort(key: string) {
setSort((prev) => {
if (!prev || prev.key !== key) return { key, order: "asc" };
if (prev.order === "asc") return { key, order: "desc" };
return null;
});
}
// ---- 列显隐:勾选=显示,取消=隐藏(操作列始终显示) ----
function toggleColumnVisible(key: string) {
setHiddenColumns((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
// ---- 枚举列多选筛选 ----
function toggleEnumFilter(key: string, value: string) {
setEnumFilters((prev) => {
const cur = prev[key] || new Set<string>();
const next = new Set(cur);
if (next.has(value)) next.delete(value); else next.add(value);
const copy = { ...prev };
if (next.size === 0) delete copy[key];
else copy[key] = next;
return copy;
});
}
// ---- 列头拖拽换序 ----
function onDragStart(e: React.DragEvent, key: string) {
setDragCol(key);
e.dataTransfer.effectAllowed = "move";
}
function onDragOver(e: React.DragEvent, key: string) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverCol(key);
}
function onDrop(e: React.DragEvent, targetKey: string) {
e.preventDefault();
if (!dragCol || dragCol === targetKey) { setDragCol(null); setDragOverCol(null); return; }
setColumnOrder((prev) => {
const next = [...prev];
const from = next.indexOf(dragCol);
const to = next.indexOf(targetKey);
if (from < 0 || to < 0) return prev;
next.splice(from, 1);
next.splice(to, 0, dragCol);
return next;
});
setDragCol(null);
setDragOverCol(null);
}
// 🔧 默认全部展开
useEffect(() => {
@ -141,14 +379,8 @@ export default function AdminTasksPage() {
});
}
// ---- 排他展开(手风琴模式 ----
async function toggleProductTree(serialNumber: string) {
// 点击已展开的树 → 收起
if (activeTreeProductId === serialNumber) {
setActiveTreeProductId(null);
return;
}
// 展开新的 → 自动收起旧的
// ---- 流转树宽屏弹窗(懒加载,缓存到 taskTrees ----
function openTreeModal(serialNumber: string) {
setActiveTreeProductId(serialNumber);
if (!taskTrees[serialNumber]) {
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
@ -158,6 +390,9 @@ export default function AdminTasksPage() {
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
}
}
function closeTreeModal() {
setActiveTreeProductId(null);
}
// ---- 任务操作 ----
const refreshProductTree = useCallback(async (serialNumber: string) => {
@ -227,6 +462,67 @@ export default function AdminTasksPage() {
// ---- 渲染 ----
const modalTask = modalTarget?.task ?? null;
// 按当前列顺序渲染的列
const orderedColumns = columnOrder
.map((key) => columns.find((c) => c.key === key))
.filter((c): c is ColumnDef => !!c);
// 🔧 实际显示的列(排除被隐藏的)+ 动态总跨度
const visibleColumns = orderedColumns.filter((c) => !hiddenColumns.has(c.key));
const visibleSpan = visibleColumns.reduce((s, c) => s + c.colSpan, 0);
// 列设置面板内容(列显隐)
function renderColumnPanel() {
const toggleable = columns.filter((c) => c.key !== "actions");
return (
<div className="max-h-80 w-48 overflow-auto p-2">
{toggleable.map((col) => (
<label key={col.key} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
<Checkbox checked={!hiddenColumns.has(col.key)} onChange={() => toggleColumnVisible(col.key)} />
<span className="truncate">{col.label}</span>
</label>
))}
<div className="mt-1 flex items-center justify-between border-t border-gray-100 pt-1.5">
<button onClick={() => setHiddenColumns(new Set())} className="text-xs text-blue-600 hover:underline"></button>
<span className="text-[10px] text-gray-300"></span>
</div>
</div>
);
}
// 列筛选面板内容
function renderFilterPanel(col: ColumnDef) {
if (col.filterType === "text") {
return (
<div className="w-52 p-2">
<Input
size="small"
placeholder={`筛选${col.label}`}
value={textFilters[col.key] || ""}
onChange={(e) => setTextFilters((prev) => ({ ...prev, [col.key]: e.target.value }))}
allowClear
/>
</div>
);
}
// enum优先固定选项否则从当前数据动态去重
const options = col.enumOptions
|| Array.from(new Set(products.map((p) => col.getFilterValue!(p)))).filter(Boolean).map((v) => ({ value: v, label: v }));
const selected = enumFilters[col.key] || new Set<string>();
return (
<div className="max-h-64 w-44 overflow-auto p-2">
{options.length === 0 ? (
<div className="py-2 text-center text-xs text-gray-400"></div>
) : options.map((opt) => (
<label key={opt.value} className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 text-xs text-gray-700 hover:bg-gray-50">
<Checkbox checked={selected.has(opt.value)} onChange={() => toggleEnumFilter(col.key, opt.value)} />
<span className="truncate">{opt.label}</span>
</label>
))}
</div>
);
}
return (
<div>
{/* ---- 标题 ---- */}
@ -249,7 +545,12 @@ export default function AdminTasksPage() {
</button>
))}
</div>
<div className="flex gap-1">
<div className="flex items-center gap-1">
<Popover trigger="click" placement="bottomRight" content={renderColumnPanel()}>
<button className="flex items-center gap-1 rounded px-2 py-1 text-[11px] text-gray-500 hover:bg-gray-100 hover:text-blue-600">
<Columns className="h-3 w-3" />
</button>
</Popover>
<button onClick={() => setExpandedOrders(new Set(orderGroups.map(g => g.orderNo)))}
className="rounded px-2 py-1 text-[11px] text-blue-600 hover:bg-blue-50"></button>
<button onClick={() => setExpandedOrders(new Set())}
@ -286,21 +587,34 @@ export default function AdminTasksPage() {
</button>
</form>
{/* 状态筛选 Tabs */}
{/* 状态筛选 Tabs(多选) */}
<div className="mt-3 flex gap-1.5 flex-wrap">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilter === tab.key
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{tab.label}
</button>
))}
<button
onClick={() => setStatusFilters(new Set())}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
statusFilters.size === 0
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
</button>
{STATUS_TABS.map((tab) => {
const active = statusFilters.has(tab.key);
return (
<button
key={tab.key}
onClick={() => toggleStatusFilter(tab.key)}
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
active
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{tab.label}
</button>
);
})}
</div>
</div>
@ -377,102 +691,61 @@ export default function AdminTasksPage() {
{/* 订单展开内容 */}
{isOpen && (
<div className="border-t border-gray-100">
{/* 表头 */}
<div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: "repeat(16, minmax(0, 1fr))" }}>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
{/* 表头(可排序 / 可筛选 / 可拖拽换列 / 可显隐) */}
<div className="grid gap-2 bg-gray-50 px-5 py-2 text-xs font-medium text-gray-500" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
{visibleColumns.map((col) => {
const sorted = sort?.key === col.key;
const hasFilter = col.filterType === "text"
? !!(textFilters[col.key] || "").trim()
: !!enumFilters[col.key]?.size;
return (
<div
key={col.key}
draggable
onDragStart={(e) => onDragStart(e, col.key)}
onDragOver={(e) => onDragOver(e, col.key)}
onDrop={(e) => onDrop(e, col.key)}
onDragEnd={() => { setDragCol(null); setDragOverCol(null); }}
className={`flex select-none items-center gap-1 rounded ${dragOverCol === col.key ? "bg-blue-100 ring-2 ring-blue-300" : ""}`}
style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}
>
<span
onClick={col.sortable ? () => toggleSort(col.key) : undefined}
className={`flex items-center gap-0.5 ${col.sortable ? "cursor-pointer hover:text-blue-600" : ""}`}
>
{col.label}
{col.sortable && (
sorted
? (sort!.order === "asc" ? <ArrowUp className="h-3 w-3 text-blue-600" /> : <ArrowDown className="h-3 w-3 text-blue-600" />)
: <ArrowUpDown className="h-3 w-3 text-gray-300" />
)}
</span>
{col.filterType && (
<Popover trigger="click" placement="bottomLeft" content={renderFilterPanel(col)}>
<button
onClick={(e) => e.stopPropagation()}
className={`rounded p-0.5 hover:bg-gray-200 ${hasFilter ? "text-blue-600" : "text-gray-400"}`}
>
<Filter className="h-3 w-3" />
</button>
</Popover>
)}
</div>
);
})}
</div>
{/* 产品行 */}
{group.products.map((p) => {
const productExpanded = activeTreeProductId === p.serial_number;
const isTreeLoading = treeLoading[p.serial_number];
const tree = taskTrees[p.serial_number];
// 综合状态:优先流转树状态,兜底产品状态
// 单一数据源:后端预计算 macro_status兜底产品 status
const currentStatus = p.macro_status || p.status;
const statusCfg = getStatusConfig(currentStatus);
return (
<div key={p.id}>
<div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: "repeat(16, minmax(0, 1fr))" }}>
<div className="col-span-2 font-mono text-xs font-semibold text-gray-800 tracking-wider cursor-pointer hover:text-blue-600 underline decoration-dotted" onClick={() => setQrSerial(p.serial_number)} title="点击查看二维码">{p.serial_number}</div>
<div className="col-span-1 font-mono text-xs text-gray-600 truncate">{p.external_serial || "—"}</div>
<div className="col-span-2 text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>
<div className="col-span-1"><span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span></div>
<div className="col-span-1"><span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${statusCfg.bg} ${statusCfg.text}`}>{statusCfg.label}</span></div>
<div className="col-span-1 text-xs text-gray-500 truncate">{p.current_location_id === "virtual_warehouse" ? (<span className="inline-flex items-center gap-1 text-purple-600">🏭 </span>) : (p.current_location_name || p.current_location_id || "—")}</div>
{/* 最新动态 */}
<div className="col-span-2 text-xs">
{p.latest_record_time ? (
<Tooltip title={(p.latest_record_assignee_name ? `${p.latest_record_assignee_name}: ` : "") + (p.latest_record_content || "") + (p.latest_record_has_images ? " [含图片]" : "")}>
<div className="cursor-default">
<div className="text-[10px] text-gray-400">{new Date(p.latest_record_time).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</div>
<div className="mt-0.5 flex items-center gap-1 truncate text-[11px] text-gray-600">
{p.latest_record_has_images && <span className="shrink-0">📷</span>}
<span className="truncate">
{p.latest_record_assignee_name && <span className="font-medium text-gray-700">{p.latest_record_assignee_name}: </span>}
{p.latest_record_content || (p.latest_record_has_images ? "图片记录" : "—")}
</span>
</div>
</div>
</Tooltip>
) : <span className="text-gray-300"></span>}
</div>
<div className="col-span-2 text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString("zh-CN")}</div>
<div className="col-span-2">
<button
onClick={() => toggleProductTree(p.serial_number)}
className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors"
>
{isTreeLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : productExpanded ? (
<X className="h-3 w-3" />
) : (
<GitBranch className="h-3 w-3" />
)}
{productExpanded ? "收起" : "流转树"}
</button>
</div>
<div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: `repeat(${visibleSpan}, minmax(0, 1fr))` }}>
{visibleColumns.map((col) => (
<div key={col.key} style={{ gridColumn: `span ${col.colSpan} / span ${col.colSpan}` }}>
{col.render(p)}
</div>
))}
</div>
{/* 展开的流转树 — 卡片堆叠视图 */}
{productExpanded && tree && (
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
<GitBranch className="h-3.5 w-3.5" />
{p.serial_number}
</h4>
{tree.task_tree && tree.task_tree.length > 0 ? (
<TaskFlowView
tasks={tree.task_tree}
onAction={setModalTarget}
currentUser={currentUser}
assigneeNames={tree.assignee_names}
/>
) : (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-xs text-gray-400"></p>
</div>
)}
</div>
)}
{productExpanded && isTreeLoading && (
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
<p className="mt-2 text-xs text-gray-400">...</p>
</div>
)}
</div>
);
})}
@ -497,6 +770,37 @@ export default function AdminTasksPage() {
</div>
)}
{/* 🔧 流转树宽屏弹窗 */}
{activeTreeProductId && (
<Modal
open
onClose={closeTreeModal}
title={`🔀 流转树 — ${activeTreeProductId}`}
widthClass="max-w-6xl"
bodyClassName="max-h-[90vh] overflow-y-auto"
>
{treeLoading[activeTreeProductId] ? (
<div className="flex justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
</div>
) : taskTrees[activeTreeProductId]?.task_tree?.length ? (
<TaskFlowView
tasks={taskTrees[activeTreeProductId].task_tree}
onAction={setModalTarget}
currentUser={currentUser}
assigneeNames={taskTrees[activeTreeProductId].assignee_names}
size="lg"
/>
) : (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<GitBranch className="mb-3 h-12 w-12 text-gray-300" />
<p className="text-sm font-medium text-gray-500"></p>
<p className="mt-1 text-xs text-gray-400"></p>
</div>
)}
</Modal>
)}
{/* ---- 弹窗 ---- */}
<ReceiveConfirmModal
open={modalTarget?.action === "receive"}

View File

@ -39,6 +39,21 @@ export interface CompletedTask {
completed_at: string;
}
export interface RejectedTask {
task_id: string;
kind: "rejected" | "rework";
task_name: string;
product_sn: string;
external_serial: string | null;
material_name: string;
spec_model: string;
rejected_by: string;
rework_assignee: string;
reject_reason: string | null;
status: string | null;
rejected_at: string | null;
}
export interface PersonDevice {
product_id: string;
serial_number: string;
@ -119,6 +134,14 @@ export async function fetchCompletedTasks(since?: string, until?: string): Promi
return data;
}
export async function fetchRejectedTasks(since?: string, until?: string): Promise<RejectedTask[]> {
const params: Record<string, string> = {};
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<RejectedTask[]>("/dashboard/rejected-tasks", { params });
return data;
}
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
return data;

View File

@ -23,6 +23,7 @@ export interface ProductResponse {
latest_record_has_images: boolean;
latest_record_assignee_id: string | null;
latest_record_assignee_name: string | null;
active_duration_hours: number | null;
}
/** MOM 物料选项 */