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:
2026-09-16 14:29:56 +08:00
parent 6bc6531e6a
commit dcc92b223e
7 changed files with 268 additions and 129 deletions

View File

@ -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";

View File

@ -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>

View File

@ -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,

View File

@ -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;
// ─── 状态中文映射 ─────────────────────────────────────────

View File

@ -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;
}