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

View File

@ -10,6 +10,9 @@ from app.services.dashboard_service import (
get_wip_tasks, WipTask, get_wip_tasks, WipTask,
get_completed_tasks, CompletedTask, get_completed_tasks, CompletedTask,
get_rejected_tasks, RejectedTask, get_rejected_tasks, RejectedTask,
get_user_operations, UserOperation,
get_user_operation_detail, OperationDetail,
get_wip_matrix, WipMatrixRow,
get_people_workload, PersonWorkload, get_people_workload, PersonWorkload,
get_people_history, PersonHistoryRecord, get_people_history, PersonHistoryRecord,
search_product_messages, ProductMessageList, 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) 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]) @router.get("/people-workload", response_model=list[PersonWorkload])
async def people_workload( async def people_workload(
db: AsyncSession = Depends(get_db), 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.notifications import router as notifications_router
from app.api.v1.endpoints.app_version import router as app_version_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.analytics import router as analytics_router
from app.api.v1.endpoints.holidays import router as holidays_router
api_router = APIRouter() api_router = APIRouter()
@ -29,3 +30,4 @@ api_router.include_router(records_router)
api_router.include_router(notifications_router) api_router.include_router(notifications_router)
api_router.include_router(app_version_router) api_router.include_router(app_version_router)
api_router.include_router(analytics_router) api_router.include_router(analytics_router)
api_router.include_router(holidays_router)

View File

@ -1,5 +1,5 @@
"""全局北京时间 (UTC+8)""" """全局北京时间 (UTC+8) 与工作日时长计算"""
from datetime import datetime from datetime import datetime, date, time, timedelta, timezone
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
BEIJING_TZ = ZoneInfo("Asia/Shanghai") BEIJING_TZ = ZoneInfo("Asia/Shanghai")
@ -8,3 +8,48 @@ BEIJING_TZ = ZoneInfo("Asia/Shanghai")
def get_beijing_time() -> datetime: def get_beijing_time() -> datetime:
"""返回当前北京时间""" """返回当前北京时间"""
return datetime.now(BEIJING_TZ) return datetime.now(BEIJING_TZ)
def to_beijing(dt: datetime | None) -> datetime | None:
"""将任意 datetime 统一转为北京时间 aware。
- naive 时间按 UTC 处理(数据库 timestamptz 实存 UTC,SQLAlchemy 读出常为 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.notification import Notification
from app.models.app_version import AppVersion from app.models.app_version import AppVersion
from app.models.message import ProductMessage from app.models.message import ProductMessage
from app.models.holiday import Holiday
__all__ = [ __all__ = [
"Base", "Base",
"ProductionOrder", "ProductionOrder",
@ -17,4 +18,5 @@ __all__ = [
"Notification", "Notification",
"AppVersion", "AppVersion",
"ProductMessage", "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 latest_record_assignee_name: str | None = None
# 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时) # 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时)
active_duration_hours: float | None = None active_duration_hours: float | None = None
# 🔧 生产总天数(自创建至今)
production_days: int = 0 # 自然天
production_days_workdays: int = 0 # 工作日(排除周末/节假日)
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@ -54,6 +54,8 @@ class FlowDevice(BaseModel):
spec_model: str spec_model: str
lead_time: float # 设备生命周期总时长(小时)= max_end - min_start lead_time: float # 设备生命周期总时长(小时)= max_end - min_start
started_at: str # 设备最早介入时间(T0),格式 MM-DD HH:mm started_at: str # 设备最早介入时间(T0),格式 MM-DD HH:mm
total_days: int = 0 # 设备生产总天数(自然天,自最早介入至今)
total_workdays: int = 0 # 设备生产总天数(工作日,排除周末/节假日)
class FlowSeries(BaseModel): class FlowSeries(BaseModel):
@ -114,11 +116,15 @@ def _to_bj(dt: datetime | None) -> datetime | None:
return dt return dt
def _duration_hours(start: datetime | None, end: datetime) -> float: def _duration_hours(start: datetime | None, end: datetime, holidays: set = None, mode: str = "workdays") -> float:
"""计算单台耗时(小时),无开始时间返回 0。""" """计算单台耗时(小时),无开始时间返回 0。
mode=workdays: 排除周末/节假日的工作小时;mode=natural: 自然小时。"""
if not start: if not start:
return 0.0 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, spec_models: list[str] | None = None,
since: datetime | None = None, since: datetime | None = None,
until: datetime | None = None, until: datetime | None = None,
mode: str = "workdays",
) -> CapabilityResponse: ) -> CapabilityResponse:
""" """
个人能力图谱(单机颗粒度):X 轴 = 设备身份证,每个负责人一条柱状 series。 个人能力图谱(单机颗粒度):X 轴 = 设备身份证,每个负责人一条柱状 series。
@ -142,10 +149,15 @@ async def get_capability_profile(
Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED, Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED,
) )
from app.models.product import Product from app.models.product import Product
from app.models.holiday import Holiday
from app.core.time_utils import get_beijing_time from app.core.time_utils import get_beijing_time
now = get_beijing_time() now = get_beijing_time()
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
stmt = ( stmt = (
select( select(
Task.assignee_id, Task.status, Task.assignee_id, Task.status,
@ -196,7 +208,7 @@ async def get_capability_profile(
"hours": 0.0, "prio": 9, "hours": 0.0, "prio": 9,
"first_start": None, "last_completed": None, "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"]): if start and (entry["first_start"] is None or start < entry["first_start"]):
entry["first_start"] = start entry["first_start"] = start
completed_dt = _to_bj(row[4]) if row[4] else None completed_dt = _to_bj(row[4]) if row[4] else None
@ -272,6 +284,7 @@ async def get_flow_compare(
db: AsyncSession, db: AsyncSession,
product_sns: list[str] | None = None, product_sns: list[str] | None = None,
spec_models: list[str] | None = None, spec_models: list[str] | None = None,
mode: str = "natural",
) -> FlowResponse: ) -> 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) idx: round((max_end_by_device[idx] - t0_by_device[idx]).total_seconds() / 3600, 1)
for idx in t0_by_device 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 = [ devices = [
FlowDevice( FlowDevice(
product_sn=d.product_sn, external_serial=d.external_serial, product_sn=d.product_sn, external_serial=d.external_serial,
material_name=d.material_name, spec_model=d.spec_model, material_name=d.material_name, spec_model=d.spec_model,
lead_time=lead_time_by_index.get(i, 0.0), 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 "", 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) for i, d in enumerate(devices)
] ]
# ── 按人分组,转为相对 T0 的小时偏移区间 ── # ── 按人分组,转为相对 T0 的小时偏移区间 ──
by_assignee: dict[str, list[list]] = {} 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: for it in intervals:
t0 = t0_by_device[it["idx"]] t0 = t0_by_device[it["idx"]]
start_offset = round((it["start"] - t0).total_seconds() / 3600, 1) if mode == "workdays":
end_offset = round((it["end"] - t0).total_seconds() / 3600, 1) 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) duration = round(end_offset - start_offset, 1)
by_assignee.setdefault(it["assignee_id"], []).append( by_assignee.setdefault(it["assignee_id"], []).append(
[it["idx"], start_offset, end_offset, it["task_name"], duration, it["is_main"]] [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 # 驳回时间 ISO(BEIJING_TZ) rejected_at: str | None # 驳回时间 ISO(BEIJING_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 # 操作时间 ISO(BEIJING_TZ)
class WipMatrixRow(BaseModel):
spec_model: str # 规格型号(Y 轴)
dimension_key: str # 人员姓名 或 工序名称(X 轴)
count: int = 0 # 该交叉点的设备数量
assignees: list[str] = [] # 该交叉点涉及的主负责人(中文名,去重)
class PersonDevice(BaseModel): class PersonDevice(BaseModel):
product_id: str product_id: str
serial_number: str # 16位HEX身份证 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]: 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.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product 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 = ( stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) 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: for task, product_sn, ext_sn, mat_name, spec in rows:
start = task.received_at or task.created_at start = task.received_at or task.created_at
if start: if start:
if start.tzinfo is None: start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间(修复多算8小时)
start = start.replace(tzinfo=BEIJING_TZ) hours = working_duration_hours(start_bj, now, holidays)
else: recv_str = start_bj.strftime("%m-%d %H:%M")
start = start.astimezone(BEIJING_TZ)
hours = round((now - start).total_seconds() / 3600, 1)
recv_str = start.strftime("%m-%d %H:%M")
else: else:
hours = 0 hours = 0
recv_str = "" recv_str = ""
@ -465,6 +491,304 @@ async def get_rejected_tasks(
return items 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 去重,含滞留时长)。""" """上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。"""
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product 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 = ( stmt = (
select( select(
@ -493,14 +822,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
rows = result.all() rows = result.all()
def _to_bj(dt): def _to_bj(dt):
if not dt: return to_beijing(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
now = get_beijing_time() now = get_beijing_time()
@ -539,7 +861,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
devices: list[PersonDevice] = [] devices: list[PersonDevice] = []
for product_id, e in prods.items(): for product_id, e in prods.items():
dt = e["earliest_dt"] 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 received_str = dt.strftime("%m-%d %H:%M") if dt else None
devices.append(PersonDevice( devices.append(PersonDevice(
product_id=product_id, product_id=product_id,
@ -577,11 +899,16 @@ async def get_people_history(
"""上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED)。""" """上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED)。"""
from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED
from app.models.product import Product 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 from sqlalchemy import func
now = get_beijing_time() now = get_beijing_time()
# 读取节假日(排除非工作日)
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
# ── 关联 TaskRecord:最新有效备注 + 记录总数 ── # ── 关联 TaskRecord:最新有效备注 + 记录总数 ──
from app.models.task import TaskRecord from app.models.task import TaskRecord
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
@ -705,7 +1032,7 @@ async def get_people_history(
else: else:
end_dt = now end_dt = now
completed_str = None 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( records.append(PersonHistoryRecord(
task_id=str(row[0]), task_id=str(row[0]),
task_name=row[1] or "", 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") 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]) latest_record_map[row[0]] = (row[1], row[2], has_img, row[4])
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时 # 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时(排除非工作日)
active_duration_map: dict[uuid.UUID, float] = {} active_duration_map: dict[uuid.UUID, float] = {}
if product_ids: if product_ids:
from sqlalchemy import func as sa_func 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 = ( start_stmt = (
select( select(
Task.product_id, Task.product_id,
@ -563,11 +566,29 @@ async def get_all_products(
start = row[1] start = row[1]
if start is None: if start is None:
continue continue
if start.tzinfo is None: start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间(修复多算8小时)
start = start.replace(tzinfo=BEIJING_TZ) active_duration_map[row[0]] = working_duration_hours(start_bj, now, holidays)
else:
start = start.astimezone(BEIJING_TZ) # 🔧 生产总天数(自然天 + 工作日):自创建至今
active_duration_map[row[0]] = round((now - start).total_seconds() / 3600, 1) 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 [ return [
ProductResponse( ProductResponse(
@ -604,6 +625,8 @@ async def get_all_products(
if latest_record_map.get(p.id, (None, None, False, None))[3] else None if latest_record_map.get(p.id, (None, None, False, None))[3] else None
), ),
active_duration_hours=active_duration_map.get(p.id), 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 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 AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage")); const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard")); const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
export default function App() { export default function App() {
return ( return (
@ -55,6 +56,7 @@ export default function App() {
<Route path="/admin/people" element={<AdminPeoplePage />} /> <Route path="/admin/people" element={<AdminPeoplePage />} />
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} /> <Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
<Route path="/admin/analytics" element={<AnalyticsDashboard />} /> <Route path="/admin/analytics" element={<AnalyticsDashboard />} />
<Route path="/admin/matrix" element={<MatrixBoard />} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>

View File

@ -1,5 +1,5 @@
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom"; 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"; import { useAuth } from "../../contexts/AuthContext";
const MENU = [ const MENU = [
@ -33,6 +33,12 @@ const MENU = [
icon: BarChart3, icon: BarChart3,
description: "人员效能 / 设备流转 ECharts 可视化", description: "人员效能 / 设备流转 ECharts 可视化",
}, },
{
title: "WIP 分布矩阵",
path: "/admin/matrix",
icon: Table2,
description: "规格型号 × 人员/工序 在制品透视表",
},
]; ];
export default function AdminLayout() { 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 { useEffect, useState, useCallback } from "react";
import { import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle, 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"; } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Radio, DatePicker, Drawer, Input } from "antd"; import { Radio, DatePicker, Drawer, Input } from "antd";
import dayjs, { type Dayjs } from "dayjs"; import dayjs, { type Dayjs } from "dayjs";
import { import {
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail,
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail,
} from "../../services/dashboardApi"; } from "../../services/dashboardApi";
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@ -269,6 +270,22 @@ export default function AdminDashboard() {
const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]); const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]);
const [rejectedLoading, setRejectedLoading] = useState(false); 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(); const navigate = useNavigate();
// ── 加载主数据 ── // ── 加载主数据 ──
@ -323,6 +340,77 @@ export default function AdminDashboard() {
.finally(() => setRejectedLoading(false)); .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) => { const onMsgSearch = (value: string) => {
setMsgKeyword(value); setMsgKeyword(value);
loadMessages(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"> 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" /> <span>💬 协同留言</span><ArrowRight className="h-4 w-4" />
</button> </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> </div>
</div> </div>
@ -647,6 +740,163 @@ export default function AdminDashboard() {
</div> </div>
)} )}
</Drawer> </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> </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>; return <span className={`rounded-md px-1.5 py-0.5 font-bold ${cls}`}>{formatDuration(h)}</span>;
})()} })()}
</div> </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")} /> <InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
</div> </div>
<div className="border-t border-gray-50 px-4 py-3"> <div className="border-t border-gray-50 px-4 py-3">

View File

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

View File

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

View File

@ -54,6 +54,30 @@ export interface RejectedTask {
rejected_at: string | null; 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 { export interface PersonDevice {
product_id: string; product_id: string;
serial_number: string; serial_number: string;
@ -142,6 +166,34 @@ export async function fetchRejectedTasks(since?: string, until?: string): Promis
return data; 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[]> { export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload"); const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
return data; return data;

View File

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

View File

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

View File

@ -133,7 +133,7 @@
<text class="popup-title">品质驳回</text> <text class="popup-title">品质驳回</text>
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" /> <textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text> <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>
<template v-if="actionPopup.type === 'transfer'"> <template v-if="actionPopup.type === 'transfer'">
<text class="popup-title">完工转交</text> <text class="popup-title">完工转交</text>
@ -196,6 +196,19 @@
</view> </view>
</view> </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> </view>
</template> </template>
@ -224,6 +237,11 @@ export default {
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "", actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
transferForm: { selectedUserId: "", isWarehouse: false, note: "" }, transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
spawnForm: { assignee_id: "", remark: "" }, spawnForm: { assignee_id: "", remark: "" },
// 🛡️ 双重确认倒计时:避免误触
confirming: "", // 当前倒计时中的操作 key('' = 无)
confirmCount: 5, // 剩余秒数
confirmTimer: null, // 定时器句柄
confirmDlg: { visible: false, title: "", content: "", action: null }, // 确认框类操作弹窗
// 💬 留言板 // 💬 留言板
messages: [], messages: [],
// 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题 // 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题
@ -236,7 +254,7 @@ export default {
}, },
computed: { computed: {
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); }, 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 : ""; }, 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 : ""; }, 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 })); }, 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); }, 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 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
onShow() { if (this.product?.id) { this.fetchMessages(); } }, onShow() { if (this.product?.id) { this.fetchMessages(); } },
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
onUnload() { this.clearConfirm(); },
methods: { methods: {
formatUserName, formatUserAvatar, formatUserName, formatUserAvatar,
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; }, 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 }; }, 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; }, 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) }); }); }, 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 }); }, 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; } }, 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: "" }; this.spawnForm = { assignee_id: "", remark: "" };
}, },
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); }, 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 {} }, 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) { uni.showModal({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, success: (res) => { if (res.confirm) this.doEndBranch(task); } }); }, confirmEndBranch(task) { this.openConfirmDlg({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, countdown: true, action: () => this.doEndBranch(task) }); },
confirmRecall(task) { uni.showModal({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, success: (res) => { if (res.confirm) this.doRecall(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 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 {} }, 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 }; }, 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 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; } }, 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-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 { 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-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-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-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.branch-label { font-size: 13px; font-weight: 700; color: #374151; } .branch-label { font-size: 13px; font-weight: 700; color: #374151; }

View File

@ -48,6 +48,39 @@
<text class="empty-text">暂无历史记录</text> <text class="empty-text">暂无历史记录</text>
</view> </view>
</template> </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> </view>
</template> </template>
@ -62,6 +95,16 @@ export default {
task: null, task: null,
records: [], records: [],
currentUser: null, 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: { computed: {
@ -86,6 +129,8 @@ export default {
this.loading = false; this.loading = false;
} }
}, },
// 🚀 页面卸载:清理确认倒计时定时器
onUnload() { this.clearConfirm(); },
methods: { methods: {
loadCurrentUser() { loadCurrentUser() {
try { try {
@ -137,29 +182,117 @@ export default {
return map[s] || s; return map[s] || s;
}, },
// ✏️ 编辑记录:直接在当前页弹窗编辑备注与图片
openEditRecord(rec) { openEditRecord(rec) {
// 复用 detail.vue 的编辑流程 — 通过全局事件或直接跳回 this.editForm = { recordId: rec.id, remark: rec.remark || "", images: rec.images || [], pendingCount: 0 };
// 简单方案:在当前页弹窗编辑 this.editVisible = true;
uni.showToast({ title: "编辑功能请返回详情页操作", icon: "none", duration: 2000 }); 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) { confirmDelete(rec) {
uni.showModal({ this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", action: () => this.deleteRecord(rec) });
title: "删除记录", },
content: "确定删除这条记录吗?", // ═══ 双重确认倒计时(防误触) ═══
confirmColor: "#dc2626", openConfirmDlg({ title, content, action }) {
success: async (res) => { this.clearConfirm();
if (res.confirm) { this.confirmDlg = { visible: true, title, content, action };
try { this.confirming = "dlg";
await request({ url: `/records/${rec.id}`, method: "DELETE" }); this.confirmCount = 5;
uni.showToast({ title: "已删除", icon: "success" }); this.confirmTimer = setInterval(() => {
this.records = this.records.filter((r) => r.id !== rec.id); this.confirmCount -= 1;
} catch { if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
uni.showToast({ title: "删除失败", icon: "none" }); }, 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-timeline { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
.empty-icon { font-size: 48px; margin-bottom: 8px; } .empty-icon { font-size: 48px; margin-bottom: 8px; }
.empty-text { font-size: 14px; color: #9ca3af; } .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> </style>