Compare commits

...

35 Commits

Author SHA1 Message Date
c19dbc2e7a feat(透视表): 表格底部增加每列数量合计行
- 每块透视表底部 summary 行,统计各工序列的设备数合计 + 行总计
- 固定底部,方便查看每列总量
2026-08-28 17:00:02 +08:00
c3e03bb177 fix(透视表): 不再把任意已完成工序强制归在库
- 设备当前工序 = 最新主任务的工序名(task_name),不区分状态
- 「在库」只在该设备最新主任务真为在库工序时出现
- 完成的测试/生产等工序按原工序显示,不再误归在库
2026-08-28 16:57:02 +08:00
b57ae19b44 fix(透视表): 每台设备只按当前工序统计一次,修复待确认重复计数
- 取每台设备最新主任务作为当前状态
- 活跃任务(WIP/PENDING)→归属对应工序(待确认=转交未接收)
- 完成态任务→归属「在库」(生产完成)
- 上一步已完成+下一步待确认时只算一次,不再重复
- 时间筛选按设备最新主任务创建时间
2026-08-28 16:54:43 +08:00
0dcdb5dad7 fix(透视表): WIP 分布矩阵只统计主分支
- get_wip_matrix 过滤 parent_task_id IS NULL 或 TRANSFER/RECOVERY 的主线任务
- 排除 SPAWN 协助分支与返工任务,避免同一设备/工序重复计数
2026-08-28 16:51:46 +08:00
8c3c0c4d90 refactor(透视表): 取消按人员分布,时间筛选对齐全局概览
- 移除按人员/按工序切换,固定按工序分布
- 时间筛选改为与全局概览一致: 今天/近7天/近30天/自定义(Radio+RangePicker)
2026-08-28 16:36:45 +08:00
9d08c50ef6 feat(透视表): 列数超6时分块垂直排列 + 规格型号列升降序
- 人员/工序列超过 CHUNK_MATRIX(6) 时按列分块,多张透视表垂直向下排列,告别一行十几个人员横向超长
- 规格型号列支持点击升降序排序(中文字典序)
- 每块独立合计
2026-08-28 16:35:26 +08:00
41dd257260 feat(透视表): 工序分布包含在库/完成 + 增加时间筛选
- wip-matrix 去掉状态过滤,全量分布,工序包含「在库」(生产完成)
- 后端加 since/until 按任务接手/创建时间过滤
- 前端 MatrixBoard 加 RangePicker 日期筛选 + 全部清除
2026-08-28 16:32:01 +08:00
fdd7465afb feat(前端): 新增 WIP 分布矩阵透视表页面
- MatrixBoard.tsx: Radio切换 按人员/按工序,动态交叉表转换(规格型号为行)
- 单元格显示设备数量+主负责人,首列规格固定左、末列合计固定右,bordered Excel风格
- 路由 /admin/matrix 注册,侧边栏新增「WIP 分布矩阵」入口
2026-08-28 16:25:26 +08:00
7547a886cc feat(backend): 新增 /dashboard/wip-matrix 在制品交叉聚合接口
- 规格型号×人员/工序 的设备数量聚合(WIP/PENDING任务)
- dimension 参数: assignee(中文姓名)/task_name(工序名)
- 返回扁平数组含 count + assignees(该交叉点主负责人列表)
2026-08-28 16:25:20 +08:00
d9ec15b72f refactor(效能分析): 人员视图改为数据切片瀑布流,告别横向滚动
- 设备按 CHUNK_SIZE=6 切块,向下追加渲染多个 ECharts 图(垂直瀑布流)
- capabilityOption 改为 getCapabilityOption 工厂函数,每块独立构建 Option
- 去除横向 overflow 与分页器,网页原生向下延伸
- grid.top 加大到 80 避免图例遮挡,X 轴标签简化(序列号+身份证)
- 点击柱子下钻支持块内索引→全局设备映射,Tooltip 读取对应块数据
2026-08-28 16:17:37 +08:00
e62855df43 feat(效能分析): 人员视图设备过多时分页显示,解决超宽无滚动
- 每页显示20台设备,底部上一页/下一页翻页器
- 数据/图按页切片,筛选变化自动回第1页
- 图宽限制在当前页设备数,看板始终可见
2026-08-28 16:10:43 +08:00
1d4023a27c fix(效能分析): 自然天/工作日切换按钮移至各视图顶部,全选长图也能看到
- 人员视图、轨迹流转 tab 内容顶部各加切换按钮
- 全选所有人导致图很长时,按钮固定在视图顶部不被挤出屏幕
2026-08-28 15:51:53 +08:00
907473b18c feat(效能分析): 人员视图也支持自然天/工作日耗时切换
- capability 接口加 mode 参数,natural 用自然小时、workdays 用工作小时
- 人员视图柱状图随顶部按钮切换,排除休息日的耗时
- 切换按钮自动重新请求 capability 与 flow
2026-08-28 15:38:32 +08:00
4447a1f52d feat(效能分析): 轨迹流转图时间轴支持工作日/自然天切换
- flow 接口加 mode 参数,workdays 模式把时间偏移换算为排除周末/节假日的工作小时
- 轨迹流转图的横轴随顶部按钮(自然天/工作日)切换,休息日被压缩
- 按钮切换自动重新请求 flow
2026-08-28 15:34:17 +08:00
7f1858387c feat(前端): 产品卡片双生产总天数 + 效能分析设备总天数按钮切换
- 产品管理卡片同时显示「生产总天数(自然天)」和「生产总天数(工作日)」
- 效能分析页新增「设备生产总天数」统计卡片(设备数/平均/最长),顶部按钮切换 自然天/工作日
- 顺手修复 AnalyticsDashboard Tabs onChange 类型错误
2026-08-28 15:29:01 +08:00
b09187ac6e feat(backend): 生产总天数返回自然天+工作日两个口径
- ProductResponse 加 production_days(自然天)/production_days_workdays(工作日)
- get_all_products 计算:自然天自创建至今,工作日用 working_duration_hours 排除周末/节假日
- FlowDevice 加 total_days/total_workdays(设备生产总天数,自最早介入至今)
2026-08-28 15:28:56 +08:00
451e24c34c fix(workload): get_people_workload 内嵌 _to_bj 改用 to_beijing,修复 BEIJING_TZ 未定义
- 移除 import 后内嵌 _to_bj 仍引用 BEIJING_TZ 导致 NameError
2026-08-28 15:13:18 +08:00
bad0941d67 fix(时长): 所有时长计算排除非工作日 + 修复两处时区多算8小时
- get_wip_tasks/get_people_workload/get_people_history/product active_duration_hours/analytics 均改用 working_duration_hours
- 修复 get_wip_tasks 与 active_duration_hours 的 naive 时间误标北京时间 bug(DB实存UTC,原多算8小时)
- 每处读取 holidays 表排除配置的放假日期
2026-08-28 15:08:10 +08:00
ad4b55dece feat(工作日): 新增工作小时计算工具 + 节假日表与管理接口
- time_utils 新增 to_beijing(统一naive按UTC转北京时间) + working_duration_hours(排除周末/节假日)
- Holiday 模型 + alembic 迁移建 holidays 表
- GET/POST/DELETE /holidays 节假日管理接口,可随时配置放假日期
2026-08-28 15:08:05 +08:00
1a5716a1ab feat(操作统计): 抽屉内增加独立时间筛选
- 抽屉内提供 今天/近7天/近30天/自定义 时间选择,默认同步顶部当前值
- 切换时间自动刷新统计与明细,无需关闭看板调整顶部时间
2026-08-28 14:40:58 +08:00
788d7a7f78 fix(操作统计): 全不选生效——默认全选,全不选后显示空态提示
- 打开抽屉默认全选所有人(宫格高亮+显示全部)
- 点「全不选」清空选择,表格显示『未选择任何人员』空态,不再退化为全部
- 首次加载自动全选;用户操作过筛选后不覆盖
2026-08-28 13:54:43 +08:00
d0fc8cdecf feat(操作统计): 点击接收/转交/上传备注数字下钻查看明细
- 表格中接收/转交/上传备注数字改为可点击(蓝色下划线),点击打开明细抽屉
- 明细展示:任务名/备注/设备/身份证/时间,跟随顶部时间筛选
2026-08-28 13:36:28 +08:00
7205de369a feat(backend): 新增 /dashboard/user-operations/detail 操作明细下钻接口
- 按人+操作类型(receive/transfer/record)查询明细:任务/产品/备注/时间
- record 明细与该人统计口径一致(名下任务手动备注,排除系统自动)
2026-08-28 13:36:23 +08:00
554608b948 feat(操作统计): 人员筛选增加全选/全不选按钮 2026-08-28 13:33:08 +08:00
5722fe159a feat(操作统计): 人员筛选改为人名宫格多选 + 表头升降序
- 人员选择从下拉改为移动端风格的人名宫格网格,支持多选(可同时看多人),全部=清空
- 接收/转交/上传备注/总次数 表头点击升降序(三态:无→升→降→无)
- 移除不再使用的 antd Select
2026-08-28 13:31:03 +08:00
477a187fc1 fix(操作统计): 上传备注改为按任务负责人归因,历史数据可回溯
- 备注统计不用 task_logs(历史无record日志),改为 task_records 按任务 assignee 归因
- 排除系统自动生成的备注(以'['开头的接收/转交/撤回等),只计手动上传
- 移除 add_task_record 冗余的 record 日志(不再用于统计)
- 不改数据库,历史182条备注也能正确归属到人
2026-08-28 13:26:26 +08:00
41c19242cf fix(操作统计): 返回全部人员而非仅有操作的人
- get_user_operations 先取全部 IRIS 人员清单,再合并各自操作次数
- 无操作的显示 0,避免只看得到当前有操作记录的人(如只看自己)
2026-08-28 13:22:56 +08:00
9471c1b7c5 feat(全局概览): 人员操作统计改为快捷入口内入口+抽屉弹窗展示
- 位置调整:快捷入口面板内新增「人员操作统计」入口按钮(替代整行区域)
- 点击打开抽屉,展示全部人员操作次数
- 支持人员下拉选择:默认全部,可选某一人查看
- 跟随顶部时间筛选联动
2026-08-28 13:19:45 +08:00
72fbb8b501 feat(全局概览): 快捷入口下方新增人员操作统计区域
- 表格展示每人 接收/转交/上传备注/总次数,按总次数降序
- 跟随顶部时间筛选(今天/近7天/近30天/自定义)联动刷新
2026-08-28 13:14:13 +08:00
b85188e625 feat(backend): 新增 /dashboard/user-operations 人员操作统计接口
- add_task_record 补 action_type=record 日志,使上传备注可统计到人
- get_user_operations 按人聚合 接收(receive)/转交(complete)/上传备注(record) 次数,时间筛选,中文名映射
- 复用 get_display_names,不新增数据库字段
2026-08-28 13:14:09 +08:00
dd14185ab4 feat(APP): 记录页实现编辑记录弹窗,替代占位的「返回详情页」提示
- 点击✏️直接弹出编辑弹窗:修改备注文本 + 图片预览/删除/新增上传
- 保存走 PUT /records/{id} 更新,本地列表同步刷新
- 删除图片同样走5秒倒计时双重确认
2026-08-28 13:02:29 +08:00
d5315cb9a1 fix(APP): 撤回转交也加入5秒倒计时确认
- 撤回转交与其他破坏性操作(删除/结束分支/驳回)一致,确认前需5秒等待防误触
2026-08-28 12:12:27 +08:00
8537bce30c fix(APP): 倒计时期间确认按钮禁用虚化 + 收窄倒计时范围
- 5秒倒计时期间确认按钮虚化禁用(点不了),只有取消可用,倒计时结束按钮解锁再点击才执行
- 收窄范围:仅 删除记录/删除图片/结束分支/驳回 保留5秒等待;接收/转交/派发/保存/创建/撤回转交 恢复直接确认
2026-08-28 12:05:13 +08:00
d8e08497f6 fix(APP): 记录页删除也改为5秒倒计时双重确认
- records.vue 删除记录从 uni.showModal 改为自定义倒计时确认弹窗
- 取消逻辑由 Vue 控制,杜绝原生弹窗取消失效
2026-08-28 11:49:48 +08:00
5f8eeda818 fix(APP): 操作确认改为5秒倒计时双重确认,杜绝误触
- 接收/驳回/转交/派发/保存记录/创建首道工序 确认按钮:首次点击进入5秒倒计时,期间再点=取消反悔,倒计时结束再点才执行
- 结束分支/撤回转交/删除记录/删除图片:改为自定义倒计时确认弹窗,替换 uni.showModal(APP端原生弹窗取消行为异常)
- 修复 canDeleteImage 权限误判(严格===只比username→宽松双向比较),负责人不再看不到删图按钮
- 页面卸载清理定时器;操作失败增加错误提示消除静默
2026-08-28 11:49:43 +08:00
24 changed files with 1567 additions and 121 deletions

View File

@ -0,0 +1,32 @@
"""add holidays table
Revision ID: h1h2h3h4h5h6
Revises: g1h2i3j4k5l6
Create Date: 2026-08-28 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'h1h2h3h4h5h6'
down_revision: Union[str, Sequence[str], None] = 'g1h2i3j4k5l6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'holidays',
sa.Column('id', sa.Integer(), autoincrement=True, primary_key=True),
sa.Column('day', sa.Date(), nullable=False, comment='放假日期'),
sa.Column('name', sa.String(length=100), nullable=True, comment='放假说明(如国庆节)'),
)
op.create_index('ix_holidays_day', 'holidays', ['day'], unique=True)
def downgrade() -> None:
op.drop_index('ix_holidays_day', table_name='holidays')
op.drop_table('holidays')

View File

@ -21,6 +21,7 @@ async def capability_profile(
spec_models: str | None = Query(None, description="规格型号,逗号分隔(可选)"),
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
mode: str = Query("workdays", description="耗时口径: workdays(工作小时,默认) / natural(自然小时)"),
db: AsyncSession = Depends(get_db),
):
"""个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时)。"""
@ -30,7 +31,7 @@ async def capability_profile(
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
return await get_capability_profile(
db, assignee_ids=ids, spec_models=specs,
since=since_dt, until=until_dt,
since=since_dt, until=until_dt, mode=mode,
)
@ -38,12 +39,13 @@ async def capability_profile(
async def flow_compare(
product_sns: str | None = Query(None, description="身份证,逗号分隔"),
spec_models: str | None = Query(None, description="规格型号,逗号分隔(无 product_sns 时按型号取最近设备)"),
mode: str = Query("natural", description="时间口径: natural(自然小时) / workdays(工作小时,排除周末节假日)"),
db: AsyncSession = Depends(get_db),
):
"""设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈)。"""
sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
return await get_flow_compare(db, product_sns=sns, spec_models=specs)
return await get_flow_compare(db, product_sns=sns, spec_models=specs, mode=mode)
@router.get("/options", response_model=AnalyticsOptions)

View File

@ -10,6 +10,9 @@ from app.services.dashboard_service import (
get_wip_tasks, WipTask,
get_completed_tasks, CompletedTask,
get_rejected_tasks, RejectedTask,
get_user_operations, UserOperation,
get_user_operation_detail, OperationDetail,
get_wip_matrix, WipMatrixRow,
get_people_workload, PersonWorkload,
get_people_history, PersonHistoryRecord,
search_product_messages, ProductMessageList,
@ -70,6 +73,47 @@ async def rejected_tasks(
return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit)
@router.get("/user-operations", response_model=list[UserOperation])
async def user_operations(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"),
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_user_operations(db, since=since_dt, until=until_dt)
@router.get("/user-operations/detail", response_model=list[OperationDetail])
async def user_operations_detail(
user_id: str = Query(..., description="人员ID(username)"),
action_type: str = Query(..., description="操作类型: receive/transfer/record"),
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
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_user_operation_detail(
db, user_id, action_type, since=since_dt, until=until_dt,
)
@router.get("/wip-matrix", response_model=list[WipMatrixRow])
async def wip_matrix(
dimension: str = Query("assignee", description="聚合维度: assignee(人员) / task_name(工序)"),
since: str | None = Query(None, description="起始日期 ISO"),
until: str | None = Query(None, description="截止日期 ISO"),
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_wip_matrix(db, dimension=dimension, since=since_dt, until=until_dt)
@router.get("/people-workload", response_model=list[PersonWorkload])
async def people_workload(
db: AsyncSession = Depends(get_db),

View File

@ -0,0 +1,60 @@
"""节假日管理 — 工作日时长计算所需的放假日期配置"""
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.holiday import Holiday
from app.services.auth_service import get_current_user
router = APIRouter(prefix="/holidays", tags=["节假日管理"])
class HolidayCreate(BaseModel):
day: date = Field(..., description="放假日期")
name: str | None = Field(None, max_length=100, description="放假说明")
@router.get("/")
async def list_holidays(
db: AsyncSession = Depends(get_db),
):
"""获取全部放假日期(按日期正序)"""
result = await db.execute(select(Holiday).order_by(Holiday.day.asc()))
return [
{"id": h.id, "date": h.day.isoformat(), "name": h.name}
for h in result.scalars().all()
]
@router.post("/", status_code=201)
async def create_holiday(
data: HolidayCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""添加一个放假日期"""
exists = await db.scalar(select(Holiday).where(Holiday.day == data.day))
if exists:
raise HTTPException(status_code=400, detail=f"{data.day} 已在节假日列表中")
h = Holiday(day=data.day, name=data.name)
db.add(h)
await db.commit()
await db.refresh(h)
return {"id": h.id, "date": h.day.isoformat(), "name": h.name}
@router.delete("/{holiday_id}", status_code=204)
async def delete_holiday(
holiday_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""删除一个放假日期"""
h = await db.get(Holiday, holiday_id)
if not h:
raise HTTPException(status_code=404, detail="节假日不存在")
await db.delete(h)
await db.commit()

View File

@ -13,6 +13,7 @@ from app.api.v1.endpoints.records import router as records_router
from app.api.v1.endpoints.notifications import router as notifications_router
from app.api.v1.endpoints.app_version import router as app_version_router
from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.holidays import router as holidays_router
api_router = APIRouter()
@ -29,3 +30,4 @@ api_router.include_router(records_router)
api_router.include_router(notifications_router)
api_router.include_router(app_version_router)
api_router.include_router(analytics_router)
api_router.include_router(holidays_router)

View File

@ -1,5 +1,5 @@
"""全局北京时间 (UTC+8)"""
from datetime import datetime
"""全局北京时间 (UTC+8) 与工作日时长计算"""
from datetime import datetime, date, time, timedelta, timezone
from zoneinfo import ZoneInfo
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
@ -8,3 +8,48 @@ BEIJING_TZ = ZoneInfo("Asia/Shanghai")
def get_beijing_time() -> datetime:
"""返回当前北京时间"""
return datetime.now(BEIJING_TZ)
def to_beijing(dt: datetime | None) -> datetime | None:
"""将任意 datetime 统一转为北京时间 aware。
- naive 时间按 UTC 处理(数据库 timestamptz 实存 UTCSQLAlchemy 读出常为 naive
- 带时区时间直接 astimezone 到北京
"""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
return dt.astimezone(BEIJING_TZ)
def working_duration_hours(
start: datetime | None,
end: datetime | None,
holidays: set[date] | None = None,
) -> float:
"""计算 start~end 之间排除周末与节假日的工作小时数。
- 周末(周六/周日)整天排除
- holidays 中配置的放假日期整天排除
- 其余日期按 24 小时连续计(一天内的时间都算)
- start/end 可为 naive按 UTC 转)或 aware 北京时间
"""
if not holidays:
holidays = set()
s = to_beijing(start)
e = to_beijing(end)
if s is None or e is None or e <= s:
return 0.0
total = 0.0
day = s.date()
last = e.date()
while day <= last:
if day.weekday() < 5 and day not in holidays:
seg_start = max(s, datetime.combine(day, time.min, tzinfo=BEIJING_TZ))
seg_end = min(e, datetime.combine(day, time.max, tzinfo=BEIJING_TZ))
if seg_end > seg_start:
total += (seg_end - seg_start).total_seconds() / 3600
day += timedelta(days=1)
return round(total, 1)

View File

@ -7,6 +7,7 @@ from app.models.task_log import TaskLog
from app.models.notification import Notification
from app.models.app_version import AppVersion
from app.models.message import ProductMessage
from app.models.holiday import Holiday
__all__ = [
"Base",
"ProductionOrder",
@ -17,4 +18,5 @@ __all__ = [
"Notification",
"AppVersion",
"ProductMessage",
"Holiday",
]

View File

@ -0,0 +1,21 @@
"""节假日模型 — 放假日期配置,用于工作日时长计算"""
from datetime import date
from sqlalchemy import Date, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Holiday(Base):
__tablename__ = "holidays"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
day: Mapped[date] = mapped_column(
Date, unique=True, index=True, comment="放假日期",
)
name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="放假说明(如国庆节)",
)
def __repr__(self) -> str:
return f"<Holiday {self.day}>"

View File

@ -63,6 +63,9 @@ class ProductResponse(BaseModel):
latest_record_assignee_name: str | None = None
# 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时)
active_duration_hours: float | None = None
# 🔧 生产总天数(自创建至今)
production_days: int = 0 # 自然天
production_days_workdays: int = 0 # 工作日(排除周末/节假日)
model_config = {"from_attributes": True}

View File

@ -54,6 +54,8 @@ class FlowDevice(BaseModel):
spec_model: str
lead_time: float # 设备生命周期总时长(小时)= max_end - min_start
started_at: str # 设备最早介入时间T0格式 MM-DD HH:mm
total_days: int = 0 # 设备生产总天数(自然天,自最早介入至今)
total_workdays: int = 0 # 设备生产总天数(工作日,排除周末/节假日)
class FlowSeries(BaseModel):
@ -114,11 +116,15 @@ def _to_bj(dt: datetime | None) -> datetime | None:
return dt
def _duration_hours(start: datetime | None, end: datetime) -> float:
"""计算单台耗时(小时),无开始时间返回 0。"""
def _duration_hours(start: datetime | None, end: datetime, holidays: set = None, mode: str = "workdays") -> float:
"""计算单台耗时(小时),无开始时间返回 0。
mode=workdays: 排除周末/节假日的工作小时mode=natural: 自然小时。"""
if not start:
return 0.0
return (end - start).total_seconds() / 3600
if mode == "natural":
return (end - start).total_seconds() / 3600
from app.core.time_utils import working_duration_hours
return working_duration_hours(start, end, holidays)
# ============================================================
@ -131,6 +137,7 @@ async def get_capability_profile(
spec_models: list[str] | None = None,
since: datetime | None = None,
until: datetime | None = None,
mode: str = "workdays",
) -> CapabilityResponse:
"""
个人能力图谱单机颗粒度X 轴 = 设备身份证,每个负责人一条柱状 series。
@ -142,10 +149,15 @@ async def get_capability_profile(
Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED,
)
from app.models.product import Product
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time
now = get_beijing_time()
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
stmt = (
select(
Task.assignee_id, Task.status,
@ -196,7 +208,7 @@ async def get_capability_profile(
"hours": 0.0, "prio": 9,
"first_start": None, "last_completed": None,
})
entry["hours"] += _duration_hours(start, end)
entry["hours"] += _duration_hours(start, end, holidays, mode)
if start and (entry["first_start"] is None or start < entry["first_start"]):
entry["first_start"] = start
completed_dt = _to_bj(row[4]) if row[4] else None
@ -272,6 +284,7 @@ async def get_flow_compare(
db: AsyncSession,
product_sns: list[str] | None = None,
spec_models: list[str] | None = None,
mode: str = "natural",
) -> FlowResponse:
"""
查询每台设备上各操作人的任务时间区间,拼装为「生命周期时间轴」区间图:
@ -388,22 +401,50 @@ async def get_flow_compare(
idx: round((max_end_by_device[idx] - t0_by_device[idx]).total_seconds() / 3600, 1)
for idx in t0_by_device
}
# 🔧 设备生产总天数(自然天 + 工作日,自最早介入至今)
import math
from app.core.time_utils import to_beijing as _tb, working_duration_hours as _wdh
from app.models.holiday import Holiday as _Holiday
hres = await db.execute(select(_Holiday.day))
_holidays = {r[0] for r in hres}
def _total_days(t0):
t0_bj = _tb(t0)
if not t0_bj:
return 1, 1
natural = max(1, math.ceil((now - t0_bj).total_seconds() / 86400))
workdays = max(1, math.ceil(_wdh(t0_bj, now, _holidays) / 24))
return natural, workdays
days_map = {i: _total_days(t0_by_device[i]) for i in t0_by_device}
devices = [
FlowDevice(
product_sn=d.product_sn, external_serial=d.external_serial,
material_name=d.material_name, spec_model=d.spec_model,
lead_time=lead_time_by_index.get(i, 0.0),
started_at=t0_by_device[i].strftime("%m-%d %H:%M") if i in t0_by_device else "",
total_days=days_map.get(i, (1, 1))[0],
total_workdays=days_map.get(i, (1, 1))[1],
)
for i, d in enumerate(devices)
]
# ── 按人分组,转为相对 T0 的小时偏移区间 ──
by_assignee: dict[str, list[list]] = {}
# 工作日模式:把时间偏移换算为「排除周末/节假日的工作小时」,压缩休息日
if mode == "workdays":
from app.core.time_utils import working_duration_hours as _wdh
from app.models.holiday import Holiday as _Holiday
hres = await db.execute(select(_Holiday.day))
_holidays = {r[0] for r in hres}
for it in intervals:
t0 = t0_by_device[it["idx"]]
start_offset = round((it["start"] - t0).total_seconds() / 3600, 1)
end_offset = round((it["end"] - t0).total_seconds() / 3600, 1)
if mode == "workdays":
start_offset = _wdh(t0, it["start"], _holidays)
end_offset = _wdh(t0, it["end"], _holidays)
else:
start_offset = round((it["start"] - t0).total_seconds() / 3600, 1)
end_offset = round((it["end"] - t0).total_seconds() / 3600, 1)
duration = round(end_offset - start_offset, 1)
by_assignee.setdefault(it["assignee_id"], []).append(
[it["idx"], start_offset, end_offset, it["task_name"], duration, it["is_main"]]

View File

@ -63,6 +63,30 @@ class RejectedTask(BaseModel):
rejected_at: str | None # 驳回时间 ISOBEIJING_TZ
class UserOperation(BaseModel):
user_id: str # 登录名 username
user_name: str # 中文姓名
receive_count: int = 0 # 接收次数
transfer_count: int = 0 # 转交次数action=complete
record_count: int = 0 # 上传备注次数action=record
total: int = 0 # 总操作次数
class OperationDetail(BaseModel):
task_name: str # 任务/工序名
product_sn: str # 产品身份证
material_name: str # 设备名称
remark: str | None # 备注/说明
time: str # 操作时间 ISOBEIJING_TZ
class WipMatrixRow(BaseModel):
spec_model: str # 规格型号Y 轴)
dimension_key: str # 人员姓名 或 工序名称X 轴)
count: int = 0 # 该交叉点的设备数量
assignees: list[str] = [] # 该交叉点涉及的主负责人(中文名,去重)
class PersonDevice(BaseModel):
product_id: str
serial_number: str # 16位HEX身份证
@ -192,7 +216,12 @@ async def get_dashboard_stats(
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product
from app.core.time_utils import get_beijing_time, BEIJING_TZ
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
@ -214,12 +243,9 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
for task, product_sn, ext_sn, mat_name, spec in rows:
start = task.received_at or task.created_at
if start:
if start.tzinfo is None:
start = start.replace(tzinfo=BEIJING_TZ)
else:
start = start.astimezone(BEIJING_TZ)
hours = round((now - start).total_seconds() / 3600, 1)
recv_str = start.strftime("%m-%d %H:%M")
start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间修复多算8小时
hours = working_duration_hours(start_bj, now, holidays)
recv_str = start_bj.strftime("%m-%d %H:%M")
else:
hours = 0
recv_str = ""
@ -465,6 +491,304 @@ async def get_rejected_tasks(
return items
# ============================================================
# 人员操作统计(接收/转交/上传备注 — 按人聚合,时间可筛选)
# ============================================================
async def get_user_operations(
db: AsyncSession,
since: datetime | None = None,
until: datetime | None = None,
) -> list[UserOperation]:
"""上帝视角 — 统计**全部人员**的操作次数(按时段过滤)。
返回所有 IRIS 部门人员(该时段无操作的计 0并追加有操作记录但不在
人员清单中的账号(如历史/已离职)。
操作口径(均不修改数据库):
- 接收: task_logs.action_type='receive'(按操作人)
- 转交: task_logs.action_type='complete'(按操作人)
- 上传备注: task_records 按**任务负责人**归因(排除系统自动生成的
'[' 开头的备注,如 "[接收] 操作员已确认接收"),历史数据可回溯
"""
from app.models.task_log import TaskLog
from app.models.task import Task, TaskRecord
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text, or_
# ── 1. 接收 / 转交task_logs 按 operator_id 聚合)──
rcv = func.count().filter(TaskLog.action_type == "receive")
cpl = func.count().filter(TaskLog.action_type == "complete")
stmt = (
select(TaskLog.operator_id, rcv.label("receive"), cpl.label("complete"))
.where(
TaskLog.action_type.in_(["receive", "complete"]),
TaskLog.operator_id.isnot(None),
)
.group_by(TaskLog.operator_id)
)
if since:
stmt = stmt.where(TaskLog.created_at >= since)
if until:
stmt = stmt.where(TaskLog.created_at <= until)
op_rows = (await db.execute(stmt)).all()
op_map = {r[0]: (r[1] or 0, r[2] or 0) for r in op_rows}
# ── 2. 上传备注task_records 按任务 assignee 归因,排除系统自动备注)──
rcd_stmt = (
select(Task.assignee_id, func.count(TaskRecord.id))
.join(Task, TaskRecord.task_id == Task.id)
.where(
Task.assignee_id.isnot(None),
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
)
.group_by(Task.assignee_id)
)
if since:
rcd_stmt = rcd_stmt.where(TaskRecord.created_at >= since)
if until:
rcd_stmt = rcd_stmt.where(TaskRecord.created_at <= until)
rcd_rows = (await db.execute(rcd_stmt)).all()
record_map = {r[0]: r[1] or 0 for r in rcd_rows}
# ── 3. 获取全部 MOM 用户清单IRIS 部门)──
users: list[dict] = []
try:
dbm = MomSessionLocal()
try:
rows = dbm.execute(text("""
SELECT username, SPLIT_PART(username, '/', 1) AS full_name
FROM sys_user WHERE department = 'IRIS'
""")).fetchall()
except Exception:
rows = dbm.execute(text("""
SELECT username, SPLIT_PART(username, '/', 1) AS full_name
FROM sys_user
""")).fetchall()
finally:
dbm.close()
for row in rows:
short = row.username.split("/")[-1] if "/" in row.username else row.username
users.append({"username": short, "full_name": row.full_name or short})
except Exception:
users = []
# ── 4. 合并:所有人员 + 有操作但不在清单的账号 ──
merged: dict[str, dict] = {}
for u in users:
merged[u["username"]] = {"name": u["full_name"], "rcv": 0, "cpl": 0, "rcd": 0}
for uid in set(op_map.keys()) | set(record_map.keys()):
if uid not in merged:
merged[uid] = {"name": uid, "rcv": 0, "cpl": 0, "rcd": 0}
r, c = op_map.get(uid, (0, 0))
merged[uid]["rcv"] = r
merged[uid]["cpl"] = c
merged[uid]["rcd"] = record_map.get(uid, 0)
# ── 5. 组装 + 排序(总次数降序,无操作的排在最后)──
items: list[UserOperation] = []
for uid, v in merged.items():
items.append(UserOperation(
user_id=uid,
user_name=v["name"],
receive_count=v["rcv"],
transfer_count=v["cpl"],
record_count=v["rcd"],
total=v["rcv"] + v["cpl"] + v["rcd"],
))
items.sort(key=lambda x: x.total, reverse=True)
return items
# ============================================================
# 在制品分布透视表WIP Matrix规格型号 × 人员/工序)
# ============================================================
async def get_wip_matrix(
db: AsyncSession,
dimension: str = "assignee",
since: datetime | None = None,
until: datetime | None = None,
) -> list[WipMatrixRow]:
"""生产分布透视表Y=规格型号X=人员 或 工序,单元格=设备数量。
核心口径:**每台设备只统计一次**,按它「当前所处工序」归属——
1. 取该设备最新的一条主分支任务parent_task_id IS NULL 或 TRANSFER/RECOVERY
2. 设备当前工序 = 该最新主任务的工序名task_name不区分状态
- 「待确认」(PENDING) = 别人转给我但未接收
- 「在库」(COMPLETED) = 真正入库/生产完成
- 其他已完成工序(如「测试」完成)按原工序显示,不强制归「在库」
这样一台设备在「上一步已完成 + 下一步待确认」时只算一次(待确认),不会重复计数。
since/until 按设备最新主任务的创建时间过滤。
dimension:
- assignee: 按当前任务负责人聚合dimension_key 为中文姓名)
- task_name: 按当前工序聚合dimension_key 为工序名,附主负责人)
"""
from datetime import timezone as dt_timezone
from app.models.task import Task
from app.models.product import Product
# 每台设备按主任务创建时间倒序,取第一条即「最新主任务」
result = await db.execute(
select(
Product.id,
Product.spec_model,
Task.task_name,
Task.assignee_id,
Task.created_at,
)
.join(Task, Task.product_id == Product.id)
.where(
or_(
Task.parent_task_id.is_(None),
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
)
)
.order_by(Product.id, Task.created_at.desc())
)
rows = result.all()
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
device_cur: dict[str, tuple] = {}
seen: set[str] = set()
for pid, spec, task_name, assignee, created in rows:
if pid in seen:
continue
seen.add(pid)
# 时间筛选:设备最新主任务的创建时间
if created is not None and created.tzinfo is None:
created = created.replace(tzinfo=dt_timezone.utc)
if since and created is not None and created < since:
continue
if until and created is not None and created > until:
continue
if dimension == "task_name":
# 设备当前工序 = 最新主任务的工序名(不区分状态,不强制完成态归在库)
key = task_name or ""
else:
key = assignee or "未分配"
device_cur[pid] = (spec or "未知型号", key, assignee or "")
# 聚合:规格 × 当前工序 → 设备数;同时收集负责人
agg: dict[tuple, int] = {}
assignee_map: dict[tuple, set] = {}
for spec, key, assignee in device_cur.values():
k = (spec, key)
agg[k] = agg.get(k, 0) + 1
if assignee:
assignee_map.setdefault(k, set()).add(assignee)
# 负责人 ID → 中文名
raw_ids: set[str] = set()
for s in assignee_map.values():
raw_ids |= s
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[WipMatrixRow] = []
for (spec, key), cnt in agg.items():
dim_display = name_map.get(key, key) if dimension == "assignee" else key
assignees = [name_map.get(a, a) for a in assignee_map.get((spec, key), set())] or []
items.append(WipMatrixRow(
spec_model=spec,
dimension_key=dim_display,
count=cnt,
assignees=assignees,
))
items.sort(key=lambda x: (x.spec_model, x.dimension_key))
return items
# ============================================================
# 人员操作明细(点击数字下钻 — 接收/转交/上传备注)
# ============================================================
async def get_user_operation_detail(
db: AsyncSession,
user_id: str,
action_type: str,
since: datetime | None = None,
until: datetime | None = None,
) -> list[OperationDetail]:
"""查询某人在指定时段内的某类操作明细。
action_type:
- receive: 接收task_logs.action_type='receive'
- transfer: 转交task_logs.action_type='complete'
- record: 上传备注(该人名下任务的手动备注,排除系统自动生成的)
"""
from app.models.task_log import TaskLog
from app.models.task import Task, TaskRecord
from app.models.product import Product
from app.core.time_utils import BEIJING_TZ
def _to_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()
items: list[OperationDetail] = []
if action_type in ("receive", "transfer"):
act = "receive" if action_type == "receive" else "complete"
stmt = (
select(TaskLog.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskLog.remark)
.join(Task, TaskLog.task_id == Task.id)
.join(Product, Task.product_id == Product.id)
.where(TaskLog.operator_id == user_id, TaskLog.action_type == act)
.order_by(TaskLog.created_at.desc())
)
if since:
stmt = stmt.where(TaskLog.created_at >= since)
if until:
stmt = stmt.where(TaskLog.created_at <= until)
rows = (await db.execute(stmt)).all()
for r in rows:
items.append(OperationDetail(
task_name=r[1] or "",
product_sn=r[2] or "",
material_name=r[3] or "",
remark=r[4] or "",
time=_to_iso(r[0]),
))
elif action_type == "record":
stmt = (
select(TaskRecord.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskRecord.remark)
.join(Task, TaskRecord.task_id == Task.id)
.join(Product, Task.product_id == Product.id)
.where(
Task.assignee_id == user_id,
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
)
.order_by(TaskRecord.created_at.desc())
)
if since:
stmt = stmt.where(TaskRecord.created_at >= since)
if until:
stmt = stmt.where(TaskRecord.created_at <= until)
rows = (await db.execute(stmt)).all()
for r in rows:
items.append(OperationDetail(
task_name=r[1] or "",
product_sn=r[2] or "",
material_name=r[3] or "",
remark=r[4] or "",
time=_to_iso(r[0]),
))
return items
# ============================================================
# 人员负载(按人聚合在制品设备 — 独立「人员看板」)
# ============================================================
@ -473,7 +797,12 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
"""上帝视角 — 按负责人聚合当前在制品设备WIP/PENDING 任务product 去重,含滞留时长)。"""
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product
from app.core.time_utils import get_beijing_time, BEIJING_TZ
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
stmt = (
select(
@ -493,14 +822,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
rows = result.all()
def _to_bj(dt):
if not dt:
return None
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
return to_beijing(dt)
now = get_beijing_time()
@ -539,7 +861,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
devices: list[PersonDevice] = []
for product_id, e in prods.items():
dt = e["earliest_dt"]
hours = round((now - dt).total_seconds() / 3600, 1) if dt else 0.0
hours = working_duration_hours(dt, now, holidays) if dt else 0.0
received_str = dt.strftime("%m-%d %H:%M") if dt else None
devices.append(PersonDevice(
product_id=product_id,
@ -577,11 +899,16 @@ async def get_people_history(
"""上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED"""
from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED
from app.models.product import Product
from app.core.time_utils import get_beijing_time, BEIJING_TZ
from app.core.time_utils import get_beijing_time, BEIJING_TZ, to_beijing, working_duration_hours
from app.models.holiday import Holiday
from sqlalchemy import func
now = get_beijing_time()
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
# ── 关联 TaskRecord最新有效备注 + 记录总数 ──
from app.models.task import TaskRecord
from sqlalchemy import and_, or_
@ -705,7 +1032,7 @@ async def get_people_history(
else:
end_dt = now
completed_str = None
hours = round((end_dt - received_dt).total_seconds() / 3600, 1) if received_dt else 0.0
hours = working_duration_hours(received_dt, end_dt, holidays) if received_dt else 0.0
records.append(PersonHistoryRecord(
task_id=str(row[0]),
task_name=row[1] or "",

View File

@ -541,11 +541,14 @@ 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)最早接手时间 → 小时
# 🔧 当前人滞留时长:每个产品活跃任务(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
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
from app.models.holiday import Holiday
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
start_stmt = (
select(
Task.product_id,
@ -563,11 +566,29 @@ async def get_all_products(
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)
start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间修复多算8小时
active_duration_map[row[0]] = working_duration_hours(start_bj, now, holidays)
# 🔧 生产总天数(自然天 + 工作日):自创建至今
import math
from app.core.time_utils import to_beijing as _tb, working_duration_hours as _wdh
from app.models.holiday import Holiday as _Holiday
hres2 = await db.execute(select(_Holiday.day))
holidays2 = {r[0] for r in hres2}
now2 = get_beijing_time()
def _prod_days(created_at):
created = _tb(created_at)
if not created:
return 1, 1
natural = max(1, math.ceil((now2 - created).total_seconds() / 86400))
work_hours = _wdh(created, now2, holidays2)
workdays = max(1, math.ceil(work_hours / 24))
return natural, workdays
production_days_map: dict = {}
for _p in products:
production_days_map[_p.id] = _prod_days(_p.created_at)
return [
ProductResponse(
@ -604,6 +625,8 @@ async def get_all_products(
if latest_record_map.get(p.id, (None, None, False, None))[3] else None
),
active_duration_hours=active_duration_map.get(p.id),
production_days=production_days_map.get(p.id, (1, 1))[0],
production_days_workdays=production_days_map.get(p.id, (1, 1))[1],
)
for p in products
]

View File

@ -24,6 +24,7 @@ const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
export default function App() {
return (
@ -55,6 +56,7 @@ export default function App() {
<Route path="/admin/people" element={<AdminPeoplePage />} />
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
<Route path="/admin/analytics" element={<AnalyticsDashboard />} />
<Route path="/admin/matrix" element={<MatrixBoard />} />
</Route>
</Routes>
</Suspense>

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3 } from "lucide-react";
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2 } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
const MENU = [
@ -33,6 +33,12 @@ const MENU = [
icon: BarChart3,
description: "人员效能 / 设备流转 ECharts 可视化",
},
{
title: "WIP 分布矩阵",
path: "/admin/matrix",
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
];
export default function AdminLayout() {

View File

@ -0,0 +1,193 @@
/**
* WIP 分布矩阵 — 在制品透视表(规格型号 × 人员/工序,单元格=设备数量)
* Y 轴 = 规格型号X 轴 = 人员 或 工序,单元格 = 该交叉点的在制品设备数
*/
import { useEffect, useMemo, useState } from "react";
import { Radio, Table, DatePicker } from "antd";
import { Loader2 } from "lucide-react";
import dayjs, { type Dayjs } from "dayjs";
import { fetchWipMatrix, type WipMatrixRow } from "../services/dashboardApi";
const { RangePicker } = DatePicker;
// 每块最多显示的工序列数,超过则分块垂直排列,避免横向超长
const CHUNK_MATRIX = 6;
// ⏰ 时间筛选(与全局概览一致)
type DateRangeKey = "today" | "7d" | "30d" | "custom";
function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
if (key === "custom" && customRange) {
return {
since: customRange[0].startOf("day").toISOString(),
until: customRange[1].endOf("day").toISOString(),
};
}
const since = dayjs().startOf("day");
if (key === "7d") return { since: since.subtract(7, "day").toISOString() };
if (key === "30d") return { since: since.subtract(30, "day").toISOString() };
return { since: since.toISOString() };
}
export default function MatrixBoard() {
// 固定按工序分布(已取消按人员分布)
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
const [data, setData] = useState<WipMatrixRow[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
const { since, until } = rangeToParams(dateKey, customRange);
fetchWipMatrix("task_name", since, until)
.then(setData)
.catch(() => setData([]))
.finally(() => setLoading(false));
}, [dateKey, customRange]);
// 🔧 核心:扁平数组 → 动态交叉表规格型号为行、dimension_key 为列)
// 列数超过 CHUNK_MATRIX 时分块垂直排列,避免一行十几个人员横向超长
const matrixChunks = useMemo(() => {
const keys = Array.from(new Set(data.map((d) => d.dimension_key)));
if (keys.length === 0) return [];
const chunks: { columns: any[]; dataSource: any[]; keys: string[] }[] = [];
for (let i = 0; i < keys.length; i += CHUNK_MATRIX) {
const chunkKeys = keys.slice(i, i + CHUNK_MATRIX);
const keySet = new Set(chunkKeys);
const rowsMap = new Map<string, any>();
for (const item of data) {
if (!keySet.has(item.dimension_key)) continue;
if (!rowsMap.has(item.spec_model)) {
rowsMap.set(item.spec_model, { spec_model: item.spec_model, _assignees: {}, row_total: 0 });
}
const row = rowsMap.get(item.spec_model);
row[item.dimension_key] = (row[item.dimension_key] || 0) + item.count;
row.row_total += item.count;
// 记录每个交叉点的主负责人(中文名,去重)
row._assignees[item.dimension_key] = item.assignees || [];
}
const columns: any[] = [
{
title: "规格型号",
dataIndex: "spec_model",
fixed: "left",
width: 170,
sorter: (a: any, b: any) => String(a.spec_model).localeCompare(String(b.spec_model), "zh"),
sortDirections: ["ascend", "descend"],
render: (v: string) => <span className="font-semibold text-gray-700">{v}</span>,
},
...chunkKeys.map((key) => ({
title: key,
dataIndex: key,
align: "center" as const,
width: 120,
render: (val: number, record: any) => {
const assignees = record._assignees?.[key] || [];
return (
<div className="text-center">
<div className="text-sm font-bold text-gray-800">{val || 0}</div>
{assignees.length > 0 && (
<div className="max-w-[100px] truncate text-[10px] text-gray-400" title={assignees.join("、")}>
{assignees.join("、")}
</div>
)}
</div>
);
},
})),
{ title: "合计", dataIndex: "row_total", fixed: "right" as const, align: "center" as const, width: 90, render: (v: number) => <span className="font-bold text-blue-600">{v}</span> },
];
chunks.push({ columns, dataSource: Array.from(rowsMap.values()), keys: chunkKeys });
}
return chunks;
}, [data]);
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 WIP </h2>
<p className="mt-1 text-sm text-gray-500"> × · /</p>
</div>
{/* ⏰ 时间筛选(与全局概览一致) */}
<div className="flex flex-wrap items-center gap-2">
<Radio.Group
value={dateKey}
onChange={e => { setDateKey(e.target.value); setCustomRange(null); }}
size="small"
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="today"></Radio.Button>
<Radio.Button value="7d">7</Radio.Button>
<Radio.Button value="30d">30</Radio.Button>
<Radio.Button value="custom"></Radio.Button>
</Radio.Group>
{dateKey === "custom" && (
<RangePicker
size="small"
value={customRange as any}
onChange={dates => setCustomRange(dates as [Dayjs, Dayjs] | null)}
style={{ width: 240 }}
placeholder={["开始", "结束"]}
/>
)}
</div>
</div>
{loading && data.length === 0 ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
) : matrixChunks.length === 0 ? (
<div className="rounded-xl bg-white py-16 text-center text-sm text-gray-400 shadow-sm"></div>
) : (
/* 🔧 瀑布流:人员/工序列超 CHUNK_MATRIX 时,按列分块垂直向下排列 */
<div className="flex flex-col gap-8">
{matrixChunks.map((chunk, idx) => (
<div key={idx} className="relative">
{idx > 0 && <div className="absolute -top-4 left-0 w-full border-t border-dashed border-gray-200" />}
<div className="rounded-xl bg-white p-4 shadow-sm">
<Table
columns={chunk.columns}
dataSource={chunk.dataSource}
loading={loading}
rowKey="spec_model"
bordered
size="small"
pagination={false}
scroll={{ x: "max-content" }}
summary={(pageData: readonly any[]) => {
const totals: Record<string, number> = {};
let rowTotal = 0;
for (const row of pageData) {
rowTotal += row.row_total || 0;
for (const k of chunk.keys) totals[k] = (totals[k] || 0) + (row[k] || 0);
}
return (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}><span className="font-bold text-gray-800"></span></Table.Summary.Cell>
{chunk.keys.map(k => (
<Table.Summary.Cell key={k} index={0} align="center">
<span className="font-semibold text-gray-700">{totals[k] || 0}</span>
</Table.Summary.Cell>
))}
<Table.Summary.Cell index={0} align="center">
<span className="font-bold text-blue-600">{rowTotal}</span>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,14 +1,15 @@
import { useEffect, useState, useCallback } from "react";
import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle, CheckCircle2,
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
Clock, MessageCircle, CheckCircle2, Users,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs";
import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask,
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail,
} from "../../services/dashboardApi";
const { RangePicker } = DatePicker;
@ -269,6 +270,22 @@ export default function AdminDashboard() {
const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]);
const [rejectedLoading, setRejectedLoading] = useState(false);
// 人员操作统计(抽屉)
const [opsDrawerOpen, setOpsDrawerOpen] = useState(false);
const [opStats, setOpStats] = useState<UserOperation[]>([]);
const [opLoading, setOpLoading] = useState(false);
const [opFilterUsers, setOpFilterUsers] = useState<Set<string>>(new Set()); // 多选人员
const [opFilterTouched, setOpFilterTouched] = useState(false); // 用户是否主动操作过筛选
const [opSort, setOpSort] = useState<{ key: string; order: "asc" | "desc" } | null>(null);
// 抽屉内独立时间筛选(不依赖顶部)
const [opDateKey, setOpDateKey] = useState<DateRangeKey>("today");
const [opCustomRange, setOpCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
// 操作明细下钻
const [opDetailOpen, setOpDetailOpen] = useState(false);
const [opDetailList, setOpDetailList] = useState<OperationDetail[]>([]);
const [opDetailLoading, setOpDetailLoading] = useState(false);
const [opDetailTitle, setOpDetailTitle] = useState("");
const navigate = useNavigate();
// ── 加载主数据 ──
@ -323,6 +340,77 @@ export default function AdminDashboard() {
.finally(() => setRejectedLoading(false));
};
// 人员操作统计 — 用抽屉内独立时间筛选
const loadUserOperations = useCallback(() => {
setOpLoading(true);
const { since, until } = rangeToParams(opDateKey, opCustomRange);
fetchUserOperations(since, until)
.then(setOpStats)
.catch(() => setOpStats([]))
.finally(() => setOpLoading(false));
}, [opDateKey, opCustomRange]);
// 抽屉打开或内部时间变化时加载
useEffect(() => {
if (opsDrawerOpen) loadUserOperations();
}, [opsDrawerOpen, loadUserOperations]);
const openOpsDrawer = () => {
setOpsDrawerOpen(true);
setOpDateKey(dateKey); // 默认同步顶部当前时间
setOpCustomRange(customRange);
setOpFilterUsers(new Set());
setOpFilterTouched(false);
setOpSort(null);
};
// 首次加载完成后默认全选(显示全部);用户操作过筛选后不再自动覆盖
useEffect(() => {
if (!opFilterTouched && opStats.length > 0) {
setOpFilterUsers(new Set(opStats.map(u => u.user_id)));
}
}, [opStats, opFilterTouched]);
// 人员多选切换
const toggleOpFilterUser = (id: string) => {
setOpFilterTouched(true);
setOpFilterUsers(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
// 全选 / 全不选
const selectAllUsers = () => {
setOpFilterTouched(true);
setOpFilterUsers(new Set(opStats.map(u => u.user_id)));
};
const clearAllUsers = () => {
setOpFilterTouched(true);
setOpFilterUsers(new Set());
};
// 表头升降序:无 → 升序 → 降序 → 无
const toggleOpSort = (key: string) => {
setOpSort(prev => {
if (!prev || prev.key !== key) return { key, order: "asc" };
if (prev.order === "asc") return { key, order: "desc" };
return null;
});
};
// 点击数字下钻查看明细(跟随抽屉内时间)
const openOpDetail = (user: UserOperation, actionType: string, label: string) => {
setOpDetailOpen(true);
setOpDetailTitle(`${user.user_name} · ${label}`);
setOpDetailLoading(true);
const { since, until } = rangeToParams(opDateKey, opCustomRange);
fetchOperationDetail(user.user_id, actionType, since, until)
.then(setOpDetailList)
.catch(() => setOpDetailList([]))
.finally(() => setOpDetailLoading(false));
};
const onMsgSearch = (value: string) => {
setMsgKeyword(value);
loadMessages(value);
@ -524,6 +612,11 @@ export default function AdminDashboard() {
className="flex w-full items-center justify-between rounded-lg bg-purple-50 px-4 py-3 text-left text-sm font-medium text-purple-700 hover:bg-purple-100 transition-colors">
<span>💬 </span><ArrowRight className="h-4 w-4" />
</button>
{/* 👥 人员操作统计入口 */}
<button onClick={openOpsDrawer}
className="flex w-full items-center justify-between rounded-lg bg-indigo-50 px-4 py-3 text-left text-sm font-medium text-indigo-700 hover:bg-indigo-100 transition-colors">
<span className="flex items-center gap-1.5"><Users className="h-4 w-4" /></span><ArrowRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
@ -647,6 +740,163 @@ export default function AdminDashboard() {
</div>
)}
</Drawer>
{/* ═══ 人员操作统计抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">👥 <span className="font-normal text-gray-400">{opStats.length} </span></span>}
open={opsDrawerOpen}
onClose={() => setOpsDrawerOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
{/* 抽屉内时间筛选 */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Radio.Group
value={opDateKey}
onChange={e => { setOpDateKey(e.target.value); setOpCustomRange(null); }}
size="small"
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="today"></Radio.Button>
<Radio.Button value="7d">7</Radio.Button>
<Radio.Button value="30d">30</Radio.Button>
<Radio.Button value="custom"></Radio.Button>
</Radio.Group>
{opDateKey === "custom" && (
<RangePicker
size="small"
value={opCustomRange as any}
onChange={dates => setOpCustomRange(dates as [Dayjs, Dayjs] | null)}
style={{ width: 240 }}
placeholder={["开始", "结束"]}
/>
)}
</div>
{/* 人员选择 — 人名宫格多选 */}
<div className="mb-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs text-gray-400"></span>
<div className="flex gap-1">
<button onClick={selectAllUsers}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${opFilterUsers.size > 0 && opFilterUsers.size === opStats.length ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
</button>
<button onClick={clearAllUsers}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${opFilterUsers.size === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
</button>
</div>
</div>
<div className="grid gap-1.5" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))" }}>
{opStats.map(u => {
const active = opFilterUsers.size > 0 && opFilterUsers.has(u.user_id);
return (
<button key={u.user_id} onClick={() => toggleOpFilterUser(u.user_id)}
className={`rounded-lg px-2 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"}`}>
{u.user_name}
</button>
);
})}
</div>
</div>
{opLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : opStats.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400"></div>
) : opFilterUsers.size === 0 ? (
<div className="py-16 text-center text-sm text-gray-400"></div>
) : (
(() => {
// 过滤 + 排序后的数据
let list = opStats.filter(u => opFilterUsers.has(u.user_id));
if (opSort) {
const key = opSort.key;
const getVal = (u: UserOperation) => key === "receive" ? u.receive_count : key === "transfer" ? u.transfer_count : key === "record" ? u.record_count : u.total;
list = [...list].sort((a, b) => opSort!.order === "asc" ? getVal(a) - getVal(b) : getVal(b) - getVal(a));
}
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-100 text-xs text-gray-400">
<th className="py-2 pr-3 font-medium">#</th>
<th className="py-2 pr-3 font-medium"></th>
{(["receive", "transfer", "record", "total"] as const).map(k => {
const label = k === "receive" ? "接收" : k === "transfer" ? "转交" : k === "record" ? "上传备注" : "总次数";
const sorted = opSort?.key === k;
return (
<th key={k} className="cursor-pointer py-2 pr-3 text-right font-medium hover:text-blue-600" onClick={() => toggleOpSort(k)}>
<span className="inline-flex items-center gap-0.5">
{label}
{sorted ? (opSort!.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>
</th>
);
})}
</tr>
</thead>
<tbody>
{list.map((u, idx) => (
<tr key={u.user_id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/50">
<td className="py-2 pr-3 text-xs text-gray-400">{idx + 1}</td>
<td className="py-2 pr-3 font-medium text-gray-800">{u.user_name}</td>
<td className="py-2 pr-3 text-right">
<button onClick={() => openOpDetail(u, "receive", "接收")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.receive_count}</button>
</td>
<td className="py-2 pr-3 text-right">
<button onClick={() => openOpDetail(u, "transfer", "转交")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.transfer_count}</button>
</td>
<td className="py-2 pr-3 text-right">
<button onClick={() => openOpDetail(u, "record", "上传备注")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.record_count}</button>
</td>
<td className="py-2 text-right font-bold text-blue-600">{u.total}</td>
</tr>
))}
</tbody>
</table>
</div>
);
})()
)}
</Drawer>
{/* ═══ 操作明细抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">📋 {opDetailTitle} <span className="font-normal text-gray-400">{opDetailList.length} </span></span>}
open={opDetailOpen}
onClose={() => setOpDetailOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
{opDetailLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : opDetailList.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400"></div>
) : (
<div>
{opDetailList.map((d, i) => (
<div key={i} className="mb-2 rounded-lg border border-gray-100 bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">{d.task_name}</span>
<span className="shrink-0 text-xs text-gray-400">{d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}</span>
</div>
{d.remark && <p className="mt-1 text-xs leading-relaxed text-gray-600">{d.remark}</p>}
<div className="mt-1.5 text-[11px] text-gray-400">
<span>{d.material_name || "未知设备"}</span>
{d.product_sn && <span className="ml-2 font-mono">: {d.product_sn}</span>}
</div>
</div>
))}
</div>
)}
</Drawer>
</div>
);
}

View File

@ -250,7 +250,8 @@ export default function AdminProductsPage() {
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={CalendarDays} label="生产总天数" value={`${p.production_days ?? formatProductionDays(p.created_at)}`} />
<InfoRow icon={CalendarDays} label="生产总天数(工作日)" value={p.production_days_workdays != null ? `${p.production_days_workdays}` : "—"} />
<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">

View File

@ -2,8 +2,8 @@
* 个人能力图谱X 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image } from "antd";
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle } from "lucide-react";
import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image, Radio } from "antd";
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";
@ -59,6 +59,8 @@ function alignName(name: string) {
return name.length === 2 ? name[0] + "" + name[1] : name;
}
const CHUNK_SIZE = 6; // 人员视图瀑布流每块设备数
export default function AnalyticsDashboard() {
const [searchParams, setSearchParams] = useSearchParams();
@ -88,6 +90,8 @@ export default function AnalyticsDashboard() {
const [capabilityLoading, setCapabilityLoading] = useState(false);
const [flow, setFlow] = useState<FlowResponse | null>(null);
const [flowLoading, setFlowLoading] = useState(false);
// 📅 设备生产总天数口径:自然天 / 工作日
const [totalDaysMode, setTotalDaysMode] = useState<"natural" | "workdays">("natural");
const [error, setError] = useState<string | null>(null);
// ─── 弹窗下钻设备备注all=true 表示流转图点击,展示全部) ──
@ -118,15 +122,17 @@ export default function AnalyticsDashboard() {
q.since = range[0].startOf("day").toISOString();
q.until = range[1].endOf("day").toISOString();
}
q.mode = totalDaysMode; // 自然天 / 工作日,控制人员视图耗时口径
return q;
}, [assigneeIds, specModels, range]);
}, [assigneeIds, specModels, range, totalDaysMode]);
const buildFlowQuery = useCallback((): FlowQuery => {
const q: FlowQuery = {};
if (productSns.length) q.product_sns = productSns;
if (specModels.length) q.spec_models = specModels;
q.mode = totalDaysMode; // 自然天 / 工作日,控制轨迹流转图时间口径
return q;
}, [productSns, specModels]);
}, [productSns, specModels, totalDaysMode]);
// ─── 能力图谱(需选择人员才发起) ──
const loadCapability = useCallback(async () => {
@ -194,12 +200,13 @@ export default function AnalyticsDashboard() {
}, [recordQuery, assigneeIds]);
// 能力图谱:点柱子 → 按当前人员筛选过滤备注
const handleZrClick = useCallback((chart: any, e: any) => {
const handleZrClick = useCallback((chart: any, e: any, chunkStart = 0) => {
const coord = chart.convertFromPixel({ seriesIndex: 0 }, [e.offsetX, e.offsetY]);
if (!coord || coord.length < 1) return;
const dataIndex = Math.round(coord[0]);
if (dataIndex < 0 || dataIndex >= categoriesRef.current.length) return;
const sn = categoriesRef.current[dataIndex];
const globalIdx = chunkStart + dataIndex; // 瀑布流块内索引 → 全局设备索引
if (globalIdx < 0 || globalIdx >= categoriesRef.current.length) return;
const sn = categoriesRef.current[globalIdx];
if (sn) setRecordQuery({ sn, all: false });
}, []);
@ -220,15 +227,32 @@ export default function AnalyticsDashboard() {
setProductSns([]);
};
// ─── 柱状图人员视图X=设备身份证,系列=人员,居中紧凑,消除幽灵占位 ──
const capabilityOption = useMemo<EChartsCoreOption>(() => {
const categories = capability?.categories ?? [];
const devices = capability?.devices ?? [];
// 每台设备上真正产生耗时的人员 seriesIndex 数组
const activePerDevice = devices.map((_, devIdx) =>
(capability?.series ?? [])
// ─── 人员视图:设备按 CHUNK_SIZE 切块(瀑布流,向下追加渲染 ──
const capabilityChunks = useMemo(() => {
if (!capability || !capability.devices?.length) return [];
const chunks = [];
const total = capability.devices.length;
for (let i = 0; i < total; i += CHUNK_SIZE) {
chunks.push({
start: i, // 该块在全部设备中的起始索引(点击下钻用)
devices: capability.devices.slice(i, i + CHUNK_SIZE),
categories: capability.categories.slice(i, i + CHUNK_SIZE),
series: capability.series.map(s => ({
name: s.name,
data: s.data.slice(i, i + CHUNK_SIZE),
})),
});
}
return chunks;
}, [capability]);
// ─── 单个块(6台设备)的 ECharts Option 工厂函数(无横向滚动,向下追加) ──
const getCapabilityOption = (chunkDevices: any[], chunkCategories: string[], chunkSeries: any[]): EChartsCoreOption => {
// 该块内每台设备上真正产生耗时的人员 seriesIndex
const activePerDevice = chunkDevices.map((_, devIdx) =>
chunkSeries
.map((s, sIdx) => (s.data[devIdx] && s.data[devIdx].value != null ? sIdx : -1))
.filter((idx) => idx !== -1),
.filter(idx => idx !== -1),
);
return {
tooltip: {
@ -238,8 +262,8 @@ export default function AnalyticsDashboard() {
formatter: (p: any) => {
const items = Array.isArray(p) ? p : [p];
const idx = items[0]?.dataIndex ?? 0;
const sn = categories[idx] ?? "—";
const dev = devices[idx];
const sn = chunkCategories[idx] ?? "—";
const dev = chunkDevices[idx];
const ext = dev?.external_serial;
const rows = items
.filter((it: any) => (it.data?.status ?? "—") !== "—")
@ -263,16 +287,14 @@ export default function AnalyticsDashboard() {
},
},
legend: { top: 0, type: "scroll" },
grid: { left: 48, right: 24, top: 60, bottom: 96 },
grid: { left: 48, right: 24, top: 80, bottom: 48 }, // top 加大,避免图例遮挡柱子
xAxis: {
type: "category",
data: devices.map((d) =>
[d.material_name, d.spec_model, d.external_serial, d.product_sn].filter(Boolean).join("\n"),
),
axisLabel: { fontSize: 10, interval: 0, lineHeight: 13 },
data: chunkDevices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
axisLabel: { fontSize: 11, interval: 0, color: "#666", lineHeight: 14 },
},
yAxis: { type: "value", name: "耗时(小时)" },
series: (capability?.series ?? []).map((s, sIdx) => ({
series: chunkSeries.map((s, sIdx) => ({
name: s.name,
type: "custom",
encode: { x: 0, y: 1 },
@ -291,36 +313,28 @@ export default function AnalyticsDashboard() {
const localIndex = activeSeries.indexOf(sIdx);
if (localIndex === -1) return;
const barWidth = 24; // 黄金粗细,绝不妥协
const gap = 6; // 紧凑的柱间距
const barWidth = 24;
const gap = 6;
const totalWidth = activeSeries.length * barWidth + (activeSeries.length - 1) * gap;
// 核心:彻底消除幽灵占位,让存活的柱子绝对居中对齐
const centerX = api.coord([devIdx, 0])[0];
const x = centerX - totalWidth / 2 + localIndex * (barWidth + gap);
const valY = api.coord([devIdx, val])[1];
const y0 = api.coord([devIdx, 0])[1];
const height = Math.max(y0 - valY, 3); // 至少 3px0 值/极小值也有柱子
const height = Math.max(y0 - valY, 3);
const y = y0 - height;
return {
type: "rect",
shape: { x, y, width: barWidth, height, r: [3, 3, 0, 0] },
style: api.style(),
};
},
data: s.data.map((d, i) => {
data: s.data.map((d: any, i: number) => {
if (d.value == null) return null;
return {
...d,
value: [i, d.value], // custom 必须的 [x, y] 坐标格式
actualValue: d.value, // 供 tooltip 读取的真实值
};
return { ...d, value: [i, d.value], actualValue: d.value };
}),
})),
};
}, [capability]);
};
// ─── 有数据的日期集合(用于日历蓝点) ──
const activityDates = useMemo(() => {
@ -673,10 +687,57 @@ export default function AnalyticsDashboard() {
</div>
)}
{/* 📅 设备生产总天数(自然天 / 工作日切换) */}
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<CalendarDays className="h-4 w-4 text-emerald-500" /> 📅
</h3>
<Radio.Group
value={totalDaysMode}
onChange={e => setTotalDaysMode(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="natural"></Radio.Button>
<Radio.Button value="workdays"></Radio.Button>
</Radio.Group>
</div>
{flowLoading && !flow ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
</div>
) : !flow || flow.devices.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-400"></div>
) : (
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-gray-800">{flow.devices.length}<span className="text-sm font-normal text-gray-400"> </span></p>
</div>
<div>
<p className="text-xs text-gray-400">{totalDaysMode === "natural" ? "平均生产总天数(自然天)" : "平均生产总天数(工作日)"}</p>
<p className="text-2xl font-bold text-emerald-600">
{Math.round(flow.devices.reduce((s, d) => s + (totalDaysMode === "natural" ? d.total_days : d.total_workdays), 0) / flow.devices.length)}
<span className="text-sm font-normal text-gray-400"> </span>
</p>
</div>
<div>
<p className="text-xs text-gray-400">{totalDaysMode === "natural" ? "自然天" : "工作日"}</p>
<p className="text-2xl font-bold text-blue-600">
{Math.max(...flow.devices.map(d => totalDaysMode === "natural" ? d.total_days : d.total_workdays))}
<span className="text-sm font-normal text-gray-400"> </span>
</p>
</div>
</div>
)}
</div>
{/* 主体:两个可视化区块 */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
onChange={(k) => setActiveTab(k as "capability" | "flow")}
items={[
{
key: "capability",
@ -688,7 +749,19 @@ export default function AnalyticsDashboard() {
),
children: (
<div className="rounded-xl bg-white p-4 shadow-sm">
<h3 className="mb-2 text-sm font-semibold text-gray-700"> · </h3>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700"> · </h3>
<Radio.Group
value={totalDaysMode}
onChange={e => setTotalDaysMode(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="natural"></Radio.Button>
<Radio.Button value="workdays"></Radio.Button>
</Radio.Group>
</div>
{capabilityLoading && !capability ? (
<div className="flex h-[420px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
@ -700,14 +773,18 @@ export default function AnalyticsDashboard() {
/>
</div>
) : (
<div className="w-full overflow-x-auto pb-2 custom-scrollbar">
<div style={{ minWidth: (capability?.devices?.length ?? 0) * 140 }}>
<BaseEChart
option={capabilityOption}
height={420}
onZrClick={handleZrClick}
/>
</div>
/* 🔧 瀑布流:设备按 CHUNK_SIZE 切块向下追加渲染,告别横向滚动 */
<div className="flex flex-col gap-12 pt-4">
{capabilityChunks.map((chunk, idx) => (
<div key={idx} className="relative">
{idx > 0 && <div className="absolute -top-6 left-0 w-full border-t border-dashed border-gray-200" />}
<BaseEChart
option={getCapabilityOption(chunk.devices, chunk.categories, chunk.series)}
height={380}
onZrClick={(chart: any, e: any) => handleZrClick(chart, e, chunk.start)}
/>
</div>
))}
</div>
)}
</div>
@ -723,7 +800,19 @@ export default function AnalyticsDashboard() {
),
children: (
<div className="rounded-xl bg-white p-4 shadow-sm">
<h3 className="mb-2 text-sm font-semibold text-gray-700"> · </h3>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700"> · </h3>
<Radio.Group
value={totalDaysMode}
onChange={e => setTotalDaysMode(e.target.value)}
size="small"
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="natural"></Radio.Button>
<Radio.Button value="workdays"></Radio.Button>
</Radio.Group>
</div>
{flowLoading && !flow ? (
<div className="flex h-[380px] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />

View File

@ -41,6 +41,8 @@ export interface FlowDevice {
spec_model: string;
lead_time: number; // 设备生命周期总时长(小时)
started_at: string; // 设备最早介入时间T0MM-DD HH:mm
total_days: number; // 设备生产总天数(自然天)
total_workdays: number; // 设备生产总天数(工作日)
}
// 时间区间deviceIndex, startOffset, endOffset, taskName, duration, isMain
@ -96,11 +98,13 @@ export interface CapabilityQuery {
spec_models?: string[];
since?: string;
until?: string;
mode?: "natural" | "workdays";
}
export interface FlowQuery {
product_sns?: string[];
spec_models?: string[];
mode?: "natural" | "workdays";
}
// ============================================================
@ -113,6 +117,7 @@ export async function fetchCapabilityProfile(query: CapabilityQuery): Promise<Ca
if (query.spec_models?.length) params.spec_models = query.spec_models.join(",");
if (query.since) params.since = query.since;
if (query.until) params.until = query.until;
if (query.mode) params.mode = query.mode;
const { data } = await api.get<CapabilityResponse>("/analytics/capability", { params });
return data;
}
@ -121,6 +126,7 @@ export async function fetchFlowData(query: FlowQuery): Promise<FlowResponse> {
const params: Record<string, string> = {};
if (query.product_sns?.length) params.product_sns = query.product_sns.join(",");
if (query.spec_models?.length) params.spec_models = query.spec_models.join(",");
if (query.mode) params.mode = query.mode;
const { data } = await api.get<FlowResponse>("/analytics/flow", { params });
return data;
}

View File

@ -54,6 +54,30 @@ export interface RejectedTask {
rejected_at: string | null;
}
export interface UserOperation {
user_id: string;
user_name: string;
receive_count: number;
transfer_count: number;
record_count: number;
total: number;
}
export interface OperationDetail {
task_name: string;
product_sn: string;
material_name: string;
remark: string | null;
time: string;
}
export interface WipMatrixRow {
spec_model: string;
dimension_key: string;
count: number;
assignees: string[];
}
export interface PersonDevice {
product_id: string;
serial_number: string;
@ -142,6 +166,34 @@ export async function fetchRejectedTasks(since?: string, until?: string): Promis
return data;
}
export async function fetchUserOperations(since?: string, until?: string): Promise<UserOperation[]> {
const params: Record<string, string> = {};
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<UserOperation[]>("/dashboard/user-operations", { params });
return data;
}
export async function fetchOperationDetail(
userId: string, actionType: string, since?: string, until?: string,
): Promise<OperationDetail[]> {
const params: Record<string, string> = { user_id: userId, action_type: actionType };
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<OperationDetail[]>("/dashboard/user-operations/detail", { params });
return data;
}
export async function fetchWipMatrix(
dimension: "assignee" | "task_name", since?: string, until?: string,
): Promise<WipMatrixRow[]> {
const params: Record<string, string> = { dimension };
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<WipMatrixRow[]>("/dashboard/wip-matrix", { params });
return data;
}
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
return data;

View File

@ -24,6 +24,8 @@ export interface ProductResponse {
latest_record_assignee_id: string | null;
latest_record_assignee_name: string | null;
active_duration_hours: number | null;
production_days: number;
production_days_workdays: number;
}
/** MOM 物料选项 */

View File

@ -128,13 +128,8 @@ export default {
uni.previewImage({ urls: fullUrls, current: index });
},
confirmDelete(rec) {
uni.showModal({
title: "删除记录",
content: "确定删除这条记录吗?",
success: (res) => {
if (res.confirm) this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
},
});
// 🚀 交给父页面统一做「双重确认倒计时」,避免原生 showModal 取消行为异常
this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
},
statusLabel(s) {
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };

View File

@ -133,7 +133,7 @@
<text class="popup-title">品质驳回</text>
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
<text class="popup-hint"> 驳回后将自动创建返工任务</text>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :disabled="actionLoading || !rejectReason.trim()" @tap="doReject">确认驳回</button></view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ confirmLabel('reject', '确认驳回') }}</button></view>
</template>
<template v-if="actionPopup.type === 'transfer'">
<text class="popup-title">完工转交</text>
@ -196,6 +196,19 @@
</view>
</view>
</view>
<!-- 🛡 双重确认弹窗删除/结束分支/驳回 5 秒倒计时防误触 -->
<view v-if="confirmDlg.visible" class="overlay" @tap="confirmDlgCancel">
<view class="popup" @tap.stop style="max-width:360px;border-radius:16px;">
<text class="popup-title">{{ confirmDlg.title }}</text>
<text class="popup-hint" style="display:block;margin-bottom:4px;">{{ confirmDlg.content }}</text>
<text v-if="confirmDlg.countdown" class="cd-tip"> 5 秒确认等待中请核对信息倒计时结束后确认按钮才可点击</text>
<view class="popup-btns">
<button class="btn-cancel" @tap="confirmDlgCancel">取消</button>
<button class="btn-primary" :class="{ 'btn-counting': confirming === 'dlg' && confirmCount > 0 }" :disabled="confirming === 'dlg' && confirmCount > 0" @tap="confirmDlgConfirm">{{ confirmDlg.countdown ? confirmLabel('dlg', '确认') : '确认' }}</button>
</view>
</view>
</view>
</view>
</template>
@ -224,6 +237,11 @@ export default {
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
spawnForm: { assignee_id: "", remark: "" },
// 🛡️ 双重确认倒计时:避免误触
confirming: "", // 当前倒计时中的操作 key'' = 无)
confirmCount: 5, // 剩余秒数
confirmTimer: null, // 定时器句柄
confirmDlg: { visible: false, title: "", content: "", action: null }, // 确认框类操作弹窗
// 💬 留言板
messages: [],
// 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题
@ -236,7 +254,7 @@ export default {
},
computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; return this.currentUser.username === this.recordPopup.task.assignee_id; },
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
@ -271,6 +289,8 @@ export default {
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
onShow() { if (this.product?.id) { this.fetchMessages(); } },
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
onUnload() { this.clearConfirm(); },
methods: {
formatUserName, formatUserAvatar,
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
@ -317,7 +337,7 @@ export default {
closeRecordPopup() { this.recordPopup = { visible: false, task: null }; },
async handleChooseImage() { const maxSlots = 9 - (this.recordForm.images.length + this.recordForm.pendingCount); if (maxSlots <= 0) return; const chooseRes = await new Promise((resolve, reject) => { uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject }); }).catch(() => null); if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return; let compressSkipCount = 0; const compressedPaths = []; for (const p of chooseRes.tempFilePaths) { try { const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); }); compressedPaths.push(compressed.tempFilePath); } catch { compressSkipCount++; } } if (!compressedPaths.length) return; this.isUploading = true; this.recordForm.pendingCount += compressedPaths.length; for (const path of compressedPaths) { const url = await this.uploadFile(path); if (url) this.recordForm.images.push(url); this.recordForm.pendingCount--; } this.isUploading = false; },
uploadFile(filePath) { return new Promise((resolve) => { uni.uploadFile({ url: getBaseUrl() + "/upload/", filePath, name: "file", success(res) { try { const data = JSON.parse(res.data); resolve(data.url || null); } catch { resolve(null); } }, fail: () => resolve(null) }); }); },
removeRecordImage(i) { this.recordForm.images.splice(i, 1); },
removeRecordImage(i) { this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", countdown: true, action: () => { this.recordForm.images.splice(i, 1); } }); },
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, current: i }); },
async doSaveRecord() { if (this.isUploading) return; this.recordSaving = true; try { const payload = { remark: this.recordForm.remark.trim(), images: this.recordForm.images }; if (this.recordForm.recordId) await put(`/records/${this.recordForm.recordId}`, payload); else await patch(`/tasks/${this.recordPopup.task.id}/records`, payload); uni.showToast({ title: "已保存", icon: "success" }); this.closeRecordPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.recordSaving = false; } },
@ -332,12 +352,72 @@ export default {
this.spawnForm = { assignee_id: "", remark: "" };
},
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
async doDeleteRecord(record) { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
confirmEndBranch(task) { uni.showModal({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, success: (res) => { if (res.confirm) this.doEndBranch(task); } }); },
confirmRecall(task) { uni.showModal({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, success: (res) => { if (res.confirm) this.doRecall(task); } }); },
async doDeleteRecord(record) { this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", countdown: true, action: async () => { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { uni.showToast({ title: (e && e.data && e.data.detail) || "删除失败", icon: "none" }); } } }); },
confirmEndBranch(task) { this.openConfirmDlg({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, countdown: true, action: () => this.doEndBranch(task) }); },
confirmRecall(task) { this.openConfirmDlg({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, countdown: true, action: () => this.doRecall(task) }); },
async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
// ═══ 双重确认倒计时(防误触) ═══
// 首次点击进入 5 秒倒计时:期间确认按钮虚化禁用(点不了),只有「取消」可用;
// 5 秒结束后确认按钮解锁,点击才真正执行。
confirmBtn(key, doAction) {
if (this.confirming === key) {
if (this.confirmCount <= 0) {
// 倒计时结束,点击执行
this.clearConfirm();
doAction();
}
// 倒计时中:按钮已禁用,忽略点击
return;
}
// 首次点击,开始倒计时
this.clearConfirm();
this.confirming = key;
this.confirmCount = 5;
this.confirmTimer = setInterval(() => {
this.confirmCount -= 1;
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
}, 1000);
},
clearConfirm() {
if (this.confirmTimer) clearInterval(this.confirmTimer);
this.confirmTimer = null;
this.confirming = "";
this.confirmCount = 5;
},
confirmLabel(key, baseText) {
if (this.confirming === key && this.confirmCount > 0) return `${baseText} (${this.confirmCount}s)`;
return baseText;
},
// 确认框类操作(结束分支/删除记录/删除图片/撤回转交)
// countdown=true 时确认按钮需要 5 秒等待期间禁用false 时立即确认
openConfirmDlg({ title, content, action, countdown = false }) {
this.clearConfirm();
this.confirmDlg = { visible: true, title, content, action, countdown };
if (countdown) {
this.confirming = "dlg";
this.confirmCount = 5;
this.confirmTimer = setInterval(() => {
this.confirmCount -= 1;
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
}, 1000);
}
},
confirmDlgConfirm() {
if (this.confirming === "dlg" && this.confirmCount > 0) return; // 倒计时中:忽略
if (this.confirming === "dlg") this.clearConfirm();
const action = this.confirmDlg.action;
this.confirmDlg.visible = false;
this.confirmDlg.action = null;
if (action) action();
},
confirmDlgCancel() {
this.clearConfirm();
this.confirmDlg.visible = false;
this.confirmDlg.action = null;
},
async doReceive() { this.actionLoading = true; try { const remark = this.receiveRemark.trim() || undefined; const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark, task_name: this.receiveTaskName }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
async doReject() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim() }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
// 转交 — 互斥选择
@ -435,6 +515,8 @@ export default {
.btn-primary[disabled] { opacity: 0.5; }
.btn-danger { flex: 1; height: 42px; border: none; border-radius: 10px; background: #dc2626; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
.btn-danger[disabled] { opacity: 0.5; }
.btn-counting { background: #f59e0b !important; }
.cd-tip { display: block; text-align: center; font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; padding: 6px 10px; margin: 8px 0 0; line-height: 1.4; }
.branch-item { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
.branch-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.branch-label { font-size: 13px; font-weight: 700; color: #374151; }

View File

@ -48,6 +48,39 @@
<text class="empty-text">暂无历史记录</text>
</view>
</template>
<!-- 🛡 双重确认删除5 秒倒计时防误触 -->
<view v-if="confirmDlg.visible" class="dlg-overlay" @tap="confirmDlgCancel">
<view class="dlg-box" @tap.stop>
<text class="dlg-title">{{ confirmDlg.title }}</text>
<text class="dlg-content">{{ confirmDlg.content }}</text>
<text class="dlg-tip"> 5 秒确认等待中请核对信息倒计时结束后确认按钮才可点击</text>
<view class="dlg-btns">
<button class="dlg-btn dlg-cancel" @tap="confirmDlgCancel">取消</button>
<button class="dlg-btn dlg-danger" :disabled="confirming === 'dlg' && confirmCount > 0" @tap="confirmDlgConfirm">{{ confirmLabel('删除') }}</button>
</view>
</view>
</view>
<!-- 编辑记录弹窗 -->
<view v-if="editVisible" class="edit-overlay" @tap="closeEditPopup">
<view class="edit-popup" @tap.stop>
<text class="edit-title"> 编辑记录</text>
<textarea v-model="editForm.remark" class="edit-textarea" placeholder="填写备注说明" :maxlength="2000" />
<view class="edit-imgs">
<view v-for="(img, i) in editForm.images" :key="i" class="edit-img-cell">
<image :src="imageUrl(img)" mode="aspectFill" class="edit-img" @tap="previewEditImage(i)" />
<text class="edit-img-del" @tap.stop="removeEditImage(i)"></text>
</view>
<view v-for="n in editForm.pendingCount" :key="'p'+n" class="edit-img-cell edit-img-loading"><text class="edit-img-loading-text"></text></view>
</view>
<button v-if="editForm.images.length + editForm.pendingCount < 9" class="edit-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : '📷 拍照/选图' }}</button>
<view class="edit-btns">
<button class="edit-btn edit-cancel" @tap="closeEditPopup">取消</button>
<button class="edit-btn edit-save" :disabled="editSaving || isUploading" @tap="doSaveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
</view>
</view>
</view>
</view>
</template>
@ -62,6 +95,16 @@ export default {
task: null,
records: [],
currentUser: null,
// 🛡️ 双重确认5 秒倒计时防误触)
confirmDlg: { visible: false, title: "", content: "", action: null },
confirming: "",
confirmCount: 5,
confirmTimer: null,
// ✏️ 编辑记录弹窗
editVisible: false,
editForm: { recordId: null, remark: "", images: [], pendingCount: 0 },
editSaving: false,
isUploading: false,
};
},
computed: {
@ -86,6 +129,8 @@ export default {
this.loading = false;
}
},
// 🚀 页面卸载:清理确认倒计时定时器
onUnload() { this.clearConfirm(); },
methods: {
loadCurrentUser() {
try {
@ -137,29 +182,117 @@ export default {
return map[s] || s;
},
// ✏️ 编辑记录:直接在当前页弹窗编辑备注与图片
openEditRecord(rec) {
// 复用 detail.vue 的编辑流程 — 通过全局事件或直接跳回
// 简单方案:在当前页弹窗编辑
uni.showToast({ title: "编辑功能请返回详情页操作", icon: "none", duration: 2000 });
this.editForm = { recordId: rec.id, remark: rec.remark || "", images: rec.images || [], pendingCount: 0 };
this.editVisible = true;
this.isUploading = false;
},
closeEditPopup() {
this.editVisible = false;
this.editForm = { recordId: null, remark: "", images: [], pendingCount: 0 };
},
previewEditImage(i) {
uni.previewImage({ urls: this.editForm.images.map((u) => this.imageUrl(u)), current: i });
},
removeEditImage(i) {
this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", action: () => { this.editForm.images.splice(i, 1); } });
},
async handleChooseImage() {
const maxSlots = 9 - (this.editForm.images.length + this.editForm.pendingCount);
if (maxSlots <= 0) return;
const chooseRes = await new Promise((resolve, reject) => {
uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject });
}).catch(() => null);
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
const compressedPaths = [];
for (const p of chooseRes.tempFilePaths) {
try {
const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); });
compressedPaths.push(compressed.tempFilePath);
} catch {}
}
if (!compressedPaths.length) return;
this.isUploading = true;
this.editForm.pendingCount += compressedPaths.length;
for (const path of compressedPaths) {
const url = await this.uploadFile(path);
if (url) this.editForm.images.push(url);
this.editForm.pendingCount--;
}
this.isUploading = false;
},
uploadFile(filePath) {
return new Promise((resolve) => {
uni.uploadFile({
url: getBaseUrl() + "/upload/", filePath, name: "file",
success(res) { try { const data = JSON.parse(res.data); resolve(data.url || null); } catch { resolve(null); } },
fail: () => resolve(null),
});
});
},
async doSaveEdit() {
if (this.isUploading) return;
this.editSaving = true;
try {
const payload = { remark: this.editForm.remark.trim(), images: this.editForm.images };
await put(`/records/${this.editForm.recordId}`, payload);
uni.showToast({ title: "已保存", icon: "success" });
const idx = this.records.findIndex((r) => r.id === this.editForm.recordId);
if (idx >= 0) this.records[idx] = { ...this.records[idx], remark: payload.remark, images: payload.images };
this.closeEditPopup();
} catch (e) {
uni.showToast({ title: (e && e.data && e.data.detail) || "保存失败", icon: "none" });
} finally {
this.editSaving = false;
}
},
confirmDelete(rec) {
uni.showModal({
title: "删除记录",
content: "确定删除这条记录吗?",
confirmColor: "#dc2626",
success: async (res) => {
if (res.confirm) {
try {
await request({ url: `/records/${rec.id}`, method: "DELETE" });
uni.showToast({ title: "已删除", icon: "success" });
this.records = this.records.filter((r) => r.id !== rec.id);
} catch {
uni.showToast({ title: "删除失败", icon: "none" });
}
}
},
});
this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", action: () => this.deleteRecord(rec) });
},
// ═══ 双重确认倒计时(防误触) ═══
openConfirmDlg({ title, content, action }) {
this.clearConfirm();
this.confirmDlg = { visible: true, title, content, action };
this.confirming = "dlg";
this.confirmCount = 5;
this.confirmTimer = setInterval(() => {
this.confirmCount -= 1;
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
}, 1000);
},
clearConfirm() {
if (this.confirmTimer) clearInterval(this.confirmTimer);
this.confirmTimer = null;
this.confirming = "";
this.confirmCount = 5;
},
confirmLabel(baseText) {
if (this.confirming === "dlg" && this.confirmCount > 0) return `${baseText} (${this.confirmCount}s)`;
return baseText;
},
confirmDlgConfirm() {
if (this.confirming === "dlg" && this.confirmCount > 0) return; // 倒计时中:忽略
if (this.confirming === "dlg") this.clearConfirm();
const action = this.confirmDlg.action;
this.confirmDlg.visible = false;
this.confirmDlg.action = null;
if (action) action();
},
confirmDlgCancel() {
this.clearConfirm();
this.confirmDlg.visible = false;
this.confirmDlg.action = null;
},
async deleteRecord(rec) {
try {
await request({ url: `/records/${rec.id}`, method: "DELETE" });
uni.showToast({ title: "已删除", icon: "success" });
this.records = this.records.filter((r) => r.id !== rec.id);
} catch {
uni.showToast({ title: "删除失败", icon: "none" });
}
},
},
};
@ -194,4 +327,37 @@ export default {
.empty-timeline { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
.empty-icon { font-size: 48px; margin-bottom: 8px; }
.empty-text { font-size: 14px; color: #9ca3af; }
/* 双重确认弹窗 */
.dlg-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; }
.dlg-box { width: 80%; max-width: 360px; background: #fff; border-radius: 16px; padding: 28px 20px 20px; box-sizing: border-box; }
.dlg-title { display: block; text-align: center; font-size: 17px; font-weight: 700; color: #1f2937; }
.dlg-content { display: block; text-align: center; font-size: 14px; color: #6b7280; margin-top: 10px; line-height: 1.5; }
.dlg-tip { display: block; text-align: center; font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; padding: 6px 10px; margin: 12px 0 0; line-height: 1.4; }
.dlg-btns { display: flex; gap: 12px; margin-top: 20px; }
.dlg-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 15px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
.dlg-btn::after { border: none; }
.dlg-cancel { background: #f3f4f6; color: #6b7280; }
.dlg-danger { background: #dc2626; color: #fff; }
.dlg-danger[disabled] { opacity: 0.5; }
/* 编辑记录弹窗 */
.edit-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
.edit-popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; box-sizing: border-box; max-height: 80vh; overflow-y: auto; }
.edit-title { display: block; text-align: center; font-size: 16px; font-weight: 700; color: #1f2937; margin-bottom: 12px; }
.edit-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; box-sizing: border-box; }
.edit-imgs { display: flex; flex-wrap: wrap; margin-top: 10px; }
.edit-img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
.edit-img { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
.edit-img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
.edit-img-loading-text { font-size: 36rpx; }
.edit-img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
.edit-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin-top: 10px; }
.edit-upload[disabled] { opacity: 0.5; }
.edit-btns { display: flex; gap: 10px; margin-top: 16px; }
.edit-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 14px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
.edit-btn::after { border: none; }
.edit-cancel { background: #f3f4f6; color: #6b7280; }
.edit-save { background: #2563eb; color: #fff; }
.edit-save[disabled] { opacity: 0.5; }
</style>