feat: 生产看板多维度重构(全量在制品、时间维度联动、口径提示)
本次只含后端看板接口与 PC 端页面。移动端的体验优化已在之前的 12 个提交中 入库,不在本提交范围内。 后端 · 服务层 dashboard_service.py - 在制品列表不再按 20 条静默截断:此前 `.limit(limit*2)` 预取再排序切片, 前端又拿数组长度当总数,表现为「卡片 23、列表却写共 20 个」的数据被吞。 现默认返回全量,limit 降级为防御性上限 - 产品流转「已完结」接入右上角时间筛选(since/until),废弃硬编码「本月」。 此前选「今天」也会看到整月产出,业务语义分裂;待流转/流转中仍为实时快照。 Product 表无 completed_at,以名下工序的完成时间锚定「完结时刻」 - 返工任务可见性:未完结卡点永远展示,已了结的仅限本月,且与卡片数字共用 同一份条件,避免「按钮显示 2、点开共 0 条」 - 驳回明细的「返工给」改为真实溯源:直接查派生出的返工任务当前 assignee_id, 不再复刻 reject_task 的推算逻辑(返工任务事后被转交时会显示旧名字) 后端 · 接口层 dashboard.py - /wip-tasks 的 limit 默认 20→500、上限 100→1000 - /my-stats 支持 since/until,新增接收/转交/上传备注口径(与 PC 人员操作统计 逐字段交叉验证一致) PC 端 - AdminDashboard: 在制品计数绑定真实总数并加截断提示、列表改为容器内滚动; 驳回明细只展示「已驳回」行(返工行不再独立成行,其信息已由「返工给」承载); 「待流转」「待接收」图例新增口径 Tooltip,消除「产品数 > 任务数」的误解; 移除已失真的时间筛选提示条 - App.tsx: dayjs 中文 locale 上提到应用根。此前散落在 AdminPeoplePage / AnalyticsDashboard 两个页面里,而路由是 lazy 分割的 —— 直接打开概览页时 那两个模块未加载,日期面板就露出 Sep/Su/Mo - AdminPeoplePage / AnalyticsDashboard: 移除重复的页面级 locale 设置 - dashboardApi.ts: fetchWipTasks 默认值同步为 500
This commit is contained in:
@ -75,10 +75,10 @@ async def my_stats(
|
||||
|
||||
@router.get("/wip-tasks", response_model=list[WipTask])
|
||||
async def wip_tasks(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""在制品看板 — 永远实时的 PENDING/WIP 任务"""
|
||||
"""在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
|
||||
return await get_wip_tasks(db, limit)
|
||||
|
||||
|
||||
|
||||
@ -182,6 +182,59 @@ class ProductMessageList(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 看板「当下性」闸门 —— 返工任务可见性
|
||||
# ============================================================
|
||||
|
||||
def _rework_visible_condition(
|
||||
month_start: datetime,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
):
|
||||
"""构造返工任务在看板上的可见条件。
|
||||
|
||||
⚠️ 卡片数字(get_dashboard_stats.tasks_rework)与明细抽屉
|
||||
(get_rejected_tasks 的 kind="rework")**必须共用这一份**,否则会出现
|
||||
「按钮显示 2、点开却是共 0 条」的自相矛盾。
|
||||
|
||||
背景(2026-09 整改):原实现里返工查询是 `is_rework=True AND status != REJECTED`
|
||||
的纯实时快照,没有任何状态/时间边界,导致上季度甚至更早的「已返工完成」记录
|
||||
无限期挂在品质异常区持续报警 —— 报警永远不消,等于没有报警。
|
||||
|
||||
规则:
|
||||
- 未完结卡点(PENDING / WIP):无论发生在何时都展示。没解决的异常必须
|
||||
一直可见,这正是看板的价值所在。
|
||||
- 已了结的(COMPLETED / ARCHIVED / CANCELED …):只保留「本月 1 日
|
||||
(北京时间)之后」的,并进一步尊重调用方所选时段。
|
||||
|
||||
用 status.notin_(开放态) 而不是枚举已了结态:将来新增状态时会自动落进
|
||||
「已了结」一侧(受时间约束),而不是凭空消失或无限期留存。
|
||||
"""
|
||||
from datetime import timezone
|
||||
from sqlalchemy import and_
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
|
||||
open_states = [TASK_STATUS_PENDING, TASK_STATUS_WIP]
|
||||
open_cond = Task.status.in_(open_states)
|
||||
|
||||
# 已了结项的时间下限 = max(本月 1 日, 调用方给的 since):
|
||||
# 既保证「绝不早于本月」,也不会越过用户选定的时段。
|
||||
# since 可能是裸时间(无时区),先补成 UTC 再比较,否则 max() 会抛 TypeError。
|
||||
floor = month_start
|
||||
if since is not None:
|
||||
floor = max(floor, since if since.tzinfo else since.replace(tzinfo=timezone.utc))
|
||||
|
||||
closed_cond = and_(
|
||||
Task.status.notin_(open_states),
|
||||
Task.completed_at.isnot(None),
|
||||
Task.completed_at >= floor,
|
||||
)
|
||||
if until is not None:
|
||||
closed_cond = and_(closed_cond, Task.completed_at <= until)
|
||||
|
||||
return or_(open_cond, closed_cond)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 看板统计(时间快照语义)
|
||||
# ============================================================
|
||||
@ -205,32 +258,78 @@ async def get_dashboard_stats(
|
||||
)
|
||||
from app.models.notification import Notification
|
||||
from app.models.message import ProductMessage
|
||||
# ── 产品(实时快照,不过滤) ──
|
||||
# ── 产品流转三段 ──
|
||||
# 状态口径(用户确认):废弃 Product.status 恒值判断,
|
||||
# COMPLETED=待仓库收货,ARCHIVED=已入库('在库' 旧命名等价),OUTBOUND=已出库。
|
||||
# 产品流转卡片第三段"已完结"= 待收货 + 已实收 + 旧在库 + 已出库,保证三段和 = 总数。
|
||||
p_total = await db.scalar(select(func.count(Product.id)))
|
||||
#
|
||||
# ⚠️「已完结」= 产出,必须响应用户在右上角选的时间范围(since / until),
|
||||
# 不能钉死在某个固定窗口 —— 否则选「今天」却看到「本月」的产出,业务语义就裂了。
|
||||
# 历史上这里先是无边界(历史全量无限累加),后又改成硬编码「本月」,两次都错。
|
||||
# Product 表没有 completed_at / updated_at(只有 created_at),故用它名下工序的
|
||||
# 完成时间来锚定「完结时刻」:只要有一条 Task.completed_at 落在所选时段内即计入。
|
||||
#
|
||||
# 「待流转 / 流转中」保持实时快照:只要还没完结,不论多久以前建的单都要统计出来 ——
|
||||
# 它们才是车间真实的积压物料,正是看板要盯的东西。
|
||||
from sqlalchemy import and_
|
||||
|
||||
# 时间下限优先取调用方所选时段的起点;未给时段时退回「本月」作为**防御性下限**,
|
||||
# 避免退化成历史全量累加(注意用独立变量,不要覆盖 since —— 下方 t_done / t_rej
|
||||
# 与返工可见性还要用它,改了会连带改变那些口径)。
|
||||
if since is not None:
|
||||
done_since = since
|
||||
else:
|
||||
from app.services.screen_service import _month_bounds
|
||||
done_since = _month_bounds()[0]
|
||||
|
||||
finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库", "已出库"])
|
||||
p_done = await db.scalar(select(func.count(Product.id)).where(finished_cond))
|
||||
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
|
||||
|
||||
# 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数
|
||||
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
|
||||
has_active_task = select(Task.id).where(
|
||||
Task.product_id == Product.id,
|
||||
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
|
||||
).exists()
|
||||
p_progress = await db.scalar(
|
||||
select(func.count(Product.id)).where(not_finished, has_active_task)
|
||||
|
||||
# 所选时段内完结 = 已完结 且 名下有工序在该时段内完成
|
||||
done_time_conds = [
|
||||
Task.product_id == Product.id,
|
||||
Task.completed_at.isnot(None),
|
||||
Task.completed_at >= done_since,
|
||||
]
|
||||
if until is not None:
|
||||
done_time_conds.append(Task.completed_at <= until)
|
||||
|
||||
done_in_range = and_(
|
||||
finished_cond,
|
||||
select(Task.id).where(*done_time_conds).exists(),
|
||||
)
|
||||
|
||||
# 待流转 = 总数 - 在制 - 完结(三段互斥,保证进度条总和=总数)
|
||||
p_pending = max((p_total or 0) - (p_progress or 0) - (p_done or 0), 0)
|
||||
p_unfinished = await db.scalar(select(func.count(Product.id)).where(not_finished)) or 0
|
||||
p_progress = await db.scalar(
|
||||
select(func.count(Product.id)).where(not_finished, has_active_task)
|
||||
) or 0
|
||||
p_done = await db.scalar(select(func.count(Product.id)).where(done_in_range)) or 0
|
||||
|
||||
# ── 任务实时快照(PENDING/WIP/返工 — 永远不过滤) ──
|
||||
# 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结
|
||||
# (三段互斥,保证进度条总和 = 展示总数)
|
||||
p_pending = max(p_unfinished - p_progress, 0)
|
||||
p_total = p_unfinished + p_done
|
||||
|
||||
# ── 任务实时快照(PENDING/WIP — 永远不过滤) ──
|
||||
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
|
||||
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP))
|
||||
t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True)))
|
||||
|
||||
# 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。
|
||||
# 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件,
|
||||
# 保证「品质异常」卡片数字与点开后的明细条数对得上。
|
||||
from app.services.screen_service import _month_bounds
|
||||
month_start_utc, _, _ = _month_bounds()
|
||||
t_rework = await db.scalar(
|
||||
select(func.count(Task.id)).where(
|
||||
Task.is_rework.is_(True),
|
||||
_rework_visible_condition(month_start_utc, since, until),
|
||||
)
|
||||
)
|
||||
|
||||
# ── 任务已完成/驳回(时间可过滤) ──
|
||||
t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED)
|
||||
@ -382,7 +481,16 @@ async def get_my_stats(
|
||||
# 在制品看板(永远实时)
|
||||
# ============================================================
|
||||
|
||||
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]:
|
||||
"""在制品看板 — 永远实时的 PENDING/WIP 任务。
|
||||
|
||||
默认返回**全部**活跃任务,limit 只是防御性安全上限。
|
||||
|
||||
⚠️ 这里曾默认 limit=20,且先 `.limit(limit * 2)` 预取 40 条、按滞留时长排序后
|
||||
再 `[:limit]` 截断 —— 列表被静默砍到 20 条,而卡片数字来自
|
||||
tasks_pending + tasks_in_progress 的真实计数,于是看板上出现
|
||||
「卡片 23、列表却写共 20 个」的数据被吞现象。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
from app.models.product import Product
|
||||
from app.models.holiday import Holiday
|
||||
@ -392,11 +500,11 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
# 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护
|
||||
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.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]))
|
||||
.limit(limit * 2)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
@ -433,7 +541,7 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
))
|
||||
# 统一按滞留时间降序排列(无视 status,纯数值排序)
|
||||
wip_list.sort(key=lambda t: t.duration_hours, reverse=True)
|
||||
return wip_list[:limit]
|
||||
return wip_list[:limit] # limit 仅作上限保护,默认 500 实际不会截断
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -507,10 +615,14 @@ async def get_rejected_tasks(
|
||||
|
||||
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
|
||||
- kind="rejected":被驳回任务,按 completed_at 时间过滤
|
||||
- kind="rework":返工任务(is_rework=True 且非驳回状态),实时快照不过滤时间
|
||||
- kind="rework":返工任务(is_rework=True 且非驳回状态)。
|
||||
未完结(PENDING/WIP)永远展示;已了结的仅限本月 —— 详见
|
||||
_rework_visible_condition。该条件与 get_dashboard_stats 的 t_rework
|
||||
共用,保证卡片数字与明细条数一致。
|
||||
|
||||
返工负责人追溯逻辑与 task_service.reject_task 一致:
|
||||
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
|
||||
返工负责人(rework_assignee)不再复刻 reject_task 的推算逻辑,而是直接查
|
||||
本次驳回派生出的那条返工任务、读其**当前** assignee_id —— 这样返工任务事后
|
||||
被转交/撤回时,看板显示的仍是真实责任人。详见 _derived_rework_assignee。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_REJECTED
|
||||
from app.models.task_log import TaskLog
|
||||
@ -540,11 +652,20 @@ async def get_rejected_tasks(
|
||||
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
|
||||
rejected_rows = (await db.execute(stmt)).all()
|
||||
|
||||
# ── 2. 返工任务(is_rework=True 且当前非驳回状态,实时不过滤时间)──
|
||||
# ── 2. 返工任务(is_rework=True 且当前非驳回状态)──
|
||||
# 原实现此处无任何状态/时间边界,是本页「数据发霉」的根源:上季度甚至更早的
|
||||
# 「已返工完成」记录会无限期留在品质异常区。改由 _rework_visible_condition
|
||||
# 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。
|
||||
from app.services.screen_service import _month_bounds
|
||||
month_start_utc, _, _ = _month_bounds()
|
||||
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)
|
||||
.where(
|
||||
Task.is_rework.is_(True),
|
||||
Task.status != TASK_STATUS_REJECTED,
|
||||
_rework_visible_condition(month_start_utc, since, until),
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
@ -575,41 +696,44 @@ async def get_rejected_tasks(
|
||||
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))
|
||||
# ── 派生返工任务的真实负责人 ──
|
||||
# 不再复刻 reject_task 的推算逻辑去「猜」当时派给了谁 —— 那只是把当时的推导
|
||||
# 重跑一遍,返工任务事后一旦被转交/撤回,看板就会继续显示当初那个旧名字。
|
||||
# 改为去库里定位真正由本次驳回派生出的那条返工任务,读它**当前**的 assignee_id。
|
||||
#
|
||||
# 定位方式:reject_task 建返工任务时只继承了 product_id 与 parent_task_id
|
||||
# (数据模型里没有「派生自哪条任务」的外键),故按「同产品 + 同父节点 +
|
||||
# is_rework」筛出候选,再用「创建时间在驳回之后的最早一条」消歧 —— 若返工任务
|
||||
# 又被驳回,会再派生一条更晚的,取最早即命中本次那条。
|
||||
# 实测两者时间戳完全相等:reject_task 里用的是同一个 now。
|
||||
rework_candidates: dict = {}
|
||||
product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id}
|
||||
if product_ids:
|
||||
cand_rows = await db.execute(
|
||||
select(Task.parent_task_id, Task.product_id, Task.created_at, Task.assignee_id)
|
||||
.where(Task.is_rework.is_(True), Task.product_id.in_(product_ids))
|
||||
.order_by(Task.created_at.asc())
|
||||
)
|
||||
for row in r:
|
||||
if row[1]:
|
||||
parent_assignee[row[0]] = row[1]
|
||||
for parent_id, prod_id, created_at, assignee_id in cand_rows.all():
|
||||
rework_candidates.setdefault((parent_id, prod_id), []).append((created_at, assignee_id))
|
||||
|
||||
def _derived_rework_assignee(task) -> str | None:
|
||||
"""取本次驳回派生出的返工任务的**当前**负责人。"""
|
||||
cands = rework_candidates.get((task.parent_task_id, task.product_id)) or []
|
||||
if not cands:
|
||||
return None # 找不到就如实返回空,不拿推算值冒充
|
||||
for created_at, assignee_id in cands: # 已按 created_at 升序
|
||||
if task.completed_at and created_at and created_at >= task.completed_at:
|
||||
return assignee_id
|
||||
return cands[0][1] # 缺 completed_at 的历史数据:退回最早一条
|
||||
|
||||
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
|
||||
derived_map = {t.id: _derived_rework_assignee(t) for t, *_ in rejected_rows}
|
||||
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(derived_map.get(t.id) or "")
|
||||
raw_ids.add(t.assignee_id or "")
|
||||
raw_ids.discard("")
|
||||
name_map: dict[str, str] = {}
|
||||
@ -620,12 +744,8 @@ async def get_rejected_tasks(
|
||||
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
|
||||
# 直接取派生返工任务的真实负责人(见 _derived_rework_assignee)
|
||||
rework_id = derived_map.get(task.id)
|
||||
rejected_by_id = reject_op.get(task.id) or task.assignee_id
|
||||
items.append(RejectedTask(
|
||||
task_id=str(task.id),
|
||||
|
||||
@ -2,6 +2,24 @@ import { Suspense, lazy } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { App as AntApp, ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
|
||||
// ── dayjs 中文 locale ────────────────────────────────────────────────
|
||||
// 必须设在应用根模块,不能下放到各页面。
|
||||
//
|
||||
// antd 的 ConfigProvider locale 只负责「请选择日期 / 今天 / 此刻」这类**文案**;
|
||||
// 日期面板上的**月份与星期名**是 rc-picker 交给 dayjs 取的 ——
|
||||
// @rc-component/picker/es/generate/dayjs.js 里:
|
||||
// getShortWeekDays: locale => dayjs().locale(parseLocale(locale)).localeData().weekdaysMin()
|
||||
// parseLocale 把 antd 传来的 "zh_CN"(下划线)在 localeMap 未命中后按 '_' 切分,
|
||||
// 得到 "zh",最终查的是 dayjs 的 locale 表 —— 不注册就永远是默认的 en。
|
||||
//
|
||||
// 此前这两行被分别写在 AdminPeoplePage / AnalyticsDashboard 两个页面里,而路由是
|
||||
// lazy() 代码分割:直接打开 /admin/dashboard 或 /admin/matrix 时那两个模块根本没
|
||||
// 被加载,dayjs 仍是 en,面板就露出 Sep / Su / Mo —— 表现为「有的页面中文、有的
|
||||
// 页面英文」。放到根模块可保证任何路由下都先生效。
|
||||
dayjs.locale("zh-cn");
|
||||
|
||||
import { ToastProvider } from "./components/ui/Toast";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
|
||||
@ -2,9 +2,9 @@ import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
|
||||
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
|
||||
Clock, MessageCircle, CheckCircle2, Users,
|
||||
Clock, MessageCircle, CheckCircle2, Users, Info,
|
||||
} from "lucide-react";
|
||||
import { Radio, DatePicker, Drawer, Input } from "antd";
|
||||
import { Radio, DatePicker, Drawer, Input, Tooltip } from "antd";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import {
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations,
|
||||
@ -34,10 +34,16 @@ function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
|
||||
}
|
||||
|
||||
// ─── 进度条(支持3或4段) ─────────────────────────────────
|
||||
function ProgressBar({ a, b, c, total, labels, d }: {
|
||||
function ProgressBar({ a, b, c, total, labels, d, hints }: {
|
||||
a: number; b: number; c: number; total: number;
|
||||
labels: [string, string, string];
|
||||
d?: number;
|
||||
/**
|
||||
* 图例口径说明:label → 悬浮提示文案。
|
||||
* 用于消除「产品数 > 任务数」这类口径差异带来的误判 —— 底层逻辑没问题,
|
||||
* 但不写清楚就极像算错数据。
|
||||
*/
|
||||
hints?: Record<string, string>;
|
||||
}) {
|
||||
if (total === 0) return <div className="py-4 text-center text-xs text-gray-400">暂无数据</div>;
|
||||
const pct = (n: number) => Math.round((n / total) * 100);
|
||||
@ -60,6 +66,11 @@ function ProgressBar({ a, b, c, total, labels, d }: {
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
|
||||
{s.label} {s.n}({pct(s.n)}%)
|
||||
{hints?.[s.label] && (
|
||||
<Tooltip title={hints[s.label]}>
|
||||
<Info className="h-3 w-3 shrink-0 cursor-help text-gray-400 transition-colors hover:text-gray-600" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@ -190,43 +201,34 @@ function CompletedRow({ t }: { t: CompletedTask }) {
|
||||
}
|
||||
|
||||
// ─── 被驳回明细项(卡片式) ──────────────────────────────
|
||||
/**
|
||||
* 驳回明细行。
|
||||
*
|
||||
* 只渲染「已驳回」这一种形态:一次驳回必然派生一条返工任务,两条同时成行会
|
||||
* 让看板看起来「有两条异常」。而本行已经带了「返工给: xxx」,一条就足以说清
|
||||
* 「这道工序被驳回了,现在由谁在返工」的完整语义,返工任务无需再独立成行。
|
||||
*/
|
||||
function RejectedRow({ t }: { t: RejectedTask }) {
|
||||
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 && 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">
|
||||
{isRework ? <RefreshCw className="h-4 w-4 shrink-0 text-orange-500" /> : <AlertTriangle className="h-4 w-4 shrink-0 text-red-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="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">驳回人: {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>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
@ -245,6 +247,9 @@ function RejectedRow({ t }: { t: RejectedTask }) {
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
|
||||
// 在制品列表的真实总数,与「任务状态」卡片的活跃任务同源(待接收 + 进行中)。
|
||||
// 用它而不是 wipTasks.length 做计数,避免接口截断时列表谎报总数。
|
||||
const activeTaskCount = (stats?.tasks_pending ?? 0) + (stats?.tasks_in_progress ?? 0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -288,7 +293,7 @@ export default function AdminDashboard() {
|
||||
const { since, until } = rangeToParams(key, range);
|
||||
Promise.all([
|
||||
fetchDashboardStats(since, until),
|
||||
fetchWipTasks(20),
|
||||
fetchWipTasks(), // 不传 limit:取全量在制品,避免被截断后与卡片数字对不上
|
||||
])
|
||||
.then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); })
|
||||
.catch(() => setError("加载失败,请确认后端已启动"))
|
||||
@ -469,12 +474,6 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示:已完结受时间筛选 */}
|
||||
{dateKey !== "today" && (
|
||||
<div className="rounded-lg bg-blue-50 px-3 py-1.5 text-[11px] text-blue-600">
|
||||
📐 当前时间筛选仅影响<strong>已完成/已驳回</strong>计数,在制品和总数始终为实时快照
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 4 卡片 ═══ */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@ -485,9 +484,12 @@ export default function AdminDashboard() {
|
||||
<h3 className="text-sm font-semibold text-gray-700">📦 产品流转</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
|
||||
<p className="mb-3 text-[11px] text-gray-400">实时快照(不受时间筛选影响)</p>
|
||||
<p className="mb-3 text-[11px] text-gray-400">在制品为实时快照;「已完结」受右上角时间筛选影响</p>
|
||||
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
|
||||
total={stats.products_total} labels={["待流转", "流转中", "已完结"]} />
|
||||
total={stats.products_total} labels={["待流转", "流转中", "已完结"]}
|
||||
hints={{
|
||||
"待流转": "指已建档,但尚未派发首道工序,或当前无任何活跃工序挂载的物理产品。因此待流转数可能会大于待接收任务数。",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 任务状态 */}
|
||||
@ -499,7 +501,10 @@ export default function AdminDashboard() {
|
||||
<p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
|
||||
<p className="mb-3 text-[11px] text-gray-400">PENDING/WIP 实时 | COMPLETED 按时段</p>
|
||||
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
|
||||
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected} />
|
||||
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected}
|
||||
hints={{
|
||||
"待接收": "指系统已生成具体工序单并指派给工人,但工人尚未在手机端点击接单的任务。",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 品质 & 留言 */}
|
||||
@ -571,12 +576,18 @@ export default function AdminDashboard() {
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<Clock className="h-4 w-4 text-orange-500" /> 当前在制品(实时)
|
||||
</h3>
|
||||
<span className="text-[11px] text-gray-400">共 {wipTasks.length} 个</span>
|
||||
{/* 计数取「任务状态」卡片同源的真实活跃数,而不是 wipTasks.length ——
|
||||
后者一旦被接口截断就会比实际少,出现「卡片 23、列表共 20」的假象 */}
|
||||
<span className="text-[11px] text-gray-400">
|
||||
共 {activeTaskCount} 个
|
||||
{wipTasks.length < activeTaskCount && `(仅显示前 ${wipTasks.length} 条)`}
|
||||
</span>
|
||||
</div>
|
||||
{wipTasks.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-gray-400">🎉 暂无滞留任务</div>
|
||||
) : (
|
||||
<div>
|
||||
// 容器内滚动:列表可能很长(全量在制品),不让它把整页撑开
|
||||
<div className="max-h-[420px] overflow-y-auto pr-1">
|
||||
<div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400">
|
||||
<span className="w-14 shrink-0">状态</span>
|
||||
<span className="flex-1">任务 · 负责人</span>
|
||||
@ -692,7 +703,7 @@ export default function AdminDashboard() {
|
||||
|
||||
{/* ═══ 驳回/返工明细抽屉 ═══ */}
|
||||
<Drawer
|
||||
title={<span className="text-base font-bold">🔴 驳回/返工明细 <span className="font-normal text-gray-400">共 {rejectedTasks.length} 条</span></span>}
|
||||
title={<span className="text-base font-bold">🔴 驳回明细 <span className="font-normal text-gray-400">共 {rejectedTasks.filter(t => t.kind === "rejected").length} 条</span></span>}
|
||||
open={rejectedDrawerOpen}
|
||||
onClose={() => setRejectedDrawerOpen(false)}
|
||||
size="large"
|
||||
@ -702,37 +713,29 @@ export default function AdminDashboard() {
|
||||
<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");
|
||||
(() => {
|
||||
// 只展示「已驳回」行:返工任务是驳回自动派生的,独立成行会让同一次
|
||||
// 异常看起来像两条(详见 RejectedRow 顶部说明)。
|
||||
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
|
||||
if (rejected.length === 0) {
|
||||
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 className="py-16 text-center text-sm text-gray-400">
|
||||
该时段暂无驳回记录
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
|
||||
@ -6,8 +6,7 @@ import {
|
||||
import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
dayjs.locale("zh-cn");
|
||||
// dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
|
||||
import {
|
||||
fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory,
|
||||
type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery,
|
||||
|
||||
@ -6,7 +6,7 @@ import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image, Radio
|
||||
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle, CalendarDays } from "lucide-react";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import "dayjs/locale/zh-cn";
|
||||
// dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
|
||||
|
||||
import BaseEChart from "../../components/BaseEChart";
|
||||
import {
|
||||
@ -15,8 +15,6 @@ import {
|
||||
type CapabilityQuery, type FlowQuery, type DeviceRecord, type FlowDevice, type FlowInterval,
|
||||
} from "../../services/analyticsApi";
|
||||
|
||||
dayjs.locale("zh-cn");
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// ─── 状态中文映射 ─────────────────────────────────────────
|
||||
|
||||
@ -164,7 +164,8 @@ export async function fetchDashboardStats(since?: string, until?: string): Promi
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchWipTasks(limit = 20): Promise<WipTask[]> {
|
||||
/** 在制品任务。limit 只是防御性上限,默认值足以覆盖全量 —— 不要拿它做分页截断。 */
|
||||
export async function fetchWipTasks(limit = 500): Promise<WipTask[]> {
|
||||
const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user