From dcc92b223eac95f33db6427d9e8144d8eb17a74b Mon Sep 17 00:00:00 2001 From: duxingchen Date: Wed, 16 Sep 2026 14:29:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=9F=E4=BA=A7=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E5=A4=9A=E7=BB=B4=E5=BA=A6=E9=87=8D=E6=9E=84=EF=BC=88=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=9C=A8=E5=88=B6=E5=93=81=E3=80=81=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BB=B4=E5=BA=A6=E8=81=94=E5=8A=A8=E3=80=81=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次只含后端看板接口与 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 --- backend/app/api/v1/endpoints/dashboard.py | 4 +- backend/app/services/dashboard_service.py | 226 ++++++++++++++---- frontend/src/App.tsx | 18 ++ frontend/src/pages/admin/AdminDashboard.tsx | 139 +++++------ frontend/src/pages/admin/AdminPeoplePage.tsx | 3 +- .../src/pages/admin/AnalyticsDashboard.tsx | 4 +- frontend/src/services/dashboardApi.ts | 3 +- 7 files changed, 268 insertions(+), 129 deletions(-) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index c20a4d4..0bed07d 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -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) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 9ef4abe..67a349b 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -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), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42ee41c..c075be8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 6ac7eb5..f204c0a 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -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; }) { if (total === 0) return
暂无数据
; const pct = (n: number) => Math.round((n / total) * 100); @@ -60,6 +66,11 @@ function ProgressBar({ a, b, c, total, labels, d }: { {s.label} {s.n}({pct(s.n)}%) + {hints?.[s.label] && ( + + + + )} ))} @@ -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 (
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" >
- {isRework ? : } + {t.task_name} | - {isRework ? ( - - {t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"} - - ) : ( - 已驳回 - )} - - {isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`} - + 已驳回 + 驳回人: {t.rejected_by} {timeStr}
- {!isRework && ( - <> - - 🔁 返工给: {t.rework_assignee} - - | - 原因: {t.reject_reason || "—"} - - )} - {isRework && ( - 🔁 返工任务 · {t.rework_assignee} 负责 - )} + + 🔁 返工给: {t.rework_assignee} + + | + 原因: {t.reject_reason || "—"}
{t.material_name || "未知设备"} @@ -245,6 +247,9 @@ function RejectedRow({ t }: { t: RejectedTask }) { export default function AdminDashboard() { const [stats, setStats] = useState(null); const [wipTasks, setWipTasks] = useState([]); + // 在制品列表的真实总数,与「任务状态」卡片的活跃任务同源(待接收 + 进行中)。 + // 用它而不是 wipTasks.length 做计数,避免接口截断时列表谎报总数。 + const activeTaskCount = (stats?.tasks_pending ?? 0) + (stats?.tasks_in_progress ?? 0); const [loading, setLoading] = useState(true); const [error, setError] = useState(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() {
- {/* 提示:已完结受时间筛选 */} - {dateKey !== "today" && ( -
- 📐 当前时间筛选仅影响已完成/已驳回计数,在制品和总数始终为实时快照 -
- )} {/* ═══ 4 卡片 ═══ */}
@@ -485,9 +484,12 @@ export default function AdminDashboard() {

📦 产品流转

{stats.products_total}

-

实时快照(不受时间筛选影响)

+

在制品为实时快照;「已完结」受右上角时间筛选影响

+ total={stats.products_total} labels={["待流转", "流转中", "已完结"]} + hints={{ + "待流转": "指已建档,但尚未派发首道工序,或当前无任何活跃工序挂载的物理产品。因此待流转数可能会大于待接收任务数。", + }} /> {/* 任务状态 */} @@ -499,7 +501,10 @@ export default function AdminDashboard() {

{stats.tasks_total}

PENDING/WIP 实时 | COMPLETED 按时段

+ total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected} + hints={{ + "待接收": "指系统已生成具体工序单并指派给工人,但工人尚未在手机端点击接单的任务。", + }} /> {/* 品质 & 留言 */} @@ -571,12 +576,18 @@ export default function AdminDashboard() {

当前在制品(实时)

- 共 {wipTasks.length} 个 + {/* 计数取「任务状态」卡片同源的真实活跃数,而不是 wipTasks.length —— + 后者一旦被接口截断就会比实际少,出现「卡片 23、列表共 20」的假象 */} + + 共 {activeTaskCount} 个 + {wipTasks.length < activeTaskCount && `(仅显示前 ${wipTasks.length} 条)`} + {wipTasks.length === 0 ? (
🎉 暂无滞留任务
) : ( -
+ // 容器内滚动:列表可能很长(全量在制品),不让它把整页撑开 +
状态 任务 · 负责人 @@ -692,7 +703,7 @@ export default function AdminDashboard() { {/* ═══ 驳回/返工明细抽屉 ═══ */} 🔴 驳回/返工明细 共 {rejectedTasks.length} 条} + title={🔴 驳回明细 共 {rejectedTasks.filter(t => t.kind === "rejected").length} 条} open={rejectedDrawerOpen} onClose={() => setRejectedDrawerOpen(false)} size="large" @@ -702,37 +713,29 @@ export default function AdminDashboard() {
- ) : rejectedTasks.length === 0 ? ( -
- 该时段暂无驳回/返工记录 -
) : ( -
- {(() => { - 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 && ( -
-

- 已驳回 · {rejected.length} 条 -

- {rejected.map(t => )} -
- )} - {rework.length > 0 && ( -
-

- 返工任务 · {rework.length} 条 -

- {rework.map(t => )} -
- )} - +
+ 该时段暂无驳回记录 +
); - })()} -
+ } + return ( +
+
+

+ 已驳回 · {rejected.length} 条 +

+ {rejected.map(t => )} +
+
+ ); + })() )}
diff --git a/frontend/src/pages/admin/AdminPeoplePage.tsx b/frontend/src/pages/admin/AdminPeoplePage.tsx index b528b04..f4bac52 100644 --- a/frontend/src/pages/admin/AdminPeoplePage.tsx +++ b/frontend/src/pages/admin/AdminPeoplePage.tsx @@ -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, diff --git a/frontend/src/pages/admin/AnalyticsDashboard.tsx b/frontend/src/pages/admin/AnalyticsDashboard.tsx index 40831a7..076c799 100644 --- a/frontend/src/pages/admin/AnalyticsDashboard.tsx +++ b/frontend/src/pages/admin/AnalyticsDashboard.tsx @@ -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; // ─── 状态中文映射 ───────────────────────────────────────── diff --git a/frontend/src/services/dashboardApi.ts b/frontend/src/services/dashboardApi.ts index 0374467..5f66bf6 100644 --- a/frontend/src/services/dashboardApi.ts +++ b/frontend/src/services/dashboardApi.ts @@ -164,7 +164,8 @@ export async function fetchDashboardStats(since?: string, until?: string): Promi return data; } -export async function fetchWipTasks(limit = 20): Promise { +/** 在制品任务。limit 只是防御性上限,默认值足以覆盖全量 —— 不要拿它做分页截断。 */ +export async function fetchWipTasks(limit = 500): Promise { const { data } = await api.get("/dashboard/wip-tasks", { params: { limit } }); return data; }