Compare commits
35 Commits
d9a70793f7
...
c19dbc2e7a
| Author | SHA1 | Date | |
|---|---|---|---|
| c19dbc2e7a | |||
| c3e03bb177 | |||
| b57ae19b44 | |||
| 0dcdb5dad7 | |||
| 8c3c0c4d90 | |||
| 9d08c50ef6 | |||
| 41dd257260 | |||
| fdd7465afb | |||
| 7547a886cc | |||
| d9ec15b72f | |||
| e62855df43 | |||
| 1d4023a27c | |||
| 907473b18c | |||
| 4447a1f52d | |||
| 7f1858387c | |||
| b09187ac6e | |||
| 451e24c34c | |||
| bad0941d67 | |||
| ad4b55dece | |||
| 1a5716a1ab | |||
| 788d7a7f78 | |||
| d0fc8cdecf | |||
| 7205de369a | |||
| 554608b948 | |||
| 5722fe159a | |||
| 477a187fc1 | |||
| 41c19242cf | |||
| 9471c1b7c5 | |||
| 72fbb8b501 | |||
| b85188e625 | |||
| dd14185ab4 | |||
| d5315cb9a1 | |||
| 8537bce30c | |||
| d8e08497f6 | |||
| 5f8eeda818 |
32
backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py
Normal file
32
backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py
Normal 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')
|
||||
@ -21,6 +21,7 @@ async def capability_profile(
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(可选)"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
mode: str = Query("workdays", description="耗时口径: workdays(工作小时,默认) / natural(自然小时)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时)。"""
|
||||
@ -30,7 +31,7 @@ async def capability_profile(
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
return await get_capability_profile(
|
||||
db, assignee_ids=ids, spec_models=specs,
|
||||
since=since_dt, until=until_dt,
|
||||
since=since_dt, until=until_dt, mode=mode,
|
||||
)
|
||||
|
||||
|
||||
@ -38,12 +39,13 @@ async def capability_profile(
|
||||
async def flow_compare(
|
||||
product_sns: str | None = Query(None, description="身份证,逗号分隔"),
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(无 product_sns 时按型号取最近设备)"),
|
||||
mode: str = Query("natural", description="时间口径: natural(自然小时) / workdays(工作小时,排除周末节假日)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈)。"""
|
||||
sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
return await get_flow_compare(db, product_sns=sns, spec_models=specs)
|
||||
return await get_flow_compare(db, product_sns=sns, spec_models=specs, mode=mode)
|
||||
|
||||
|
||||
@router.get("/options", response_model=AnalyticsOptions)
|
||||
|
||||
@ -10,6 +10,9 @@ from app.services.dashboard_service import (
|
||||
get_wip_tasks, WipTask,
|
||||
get_completed_tasks, CompletedTask,
|
||||
get_rejected_tasks, RejectedTask,
|
||||
get_user_operations, UserOperation,
|
||||
get_user_operation_detail, OperationDetail,
|
||||
get_wip_matrix, WipMatrixRow,
|
||||
get_people_workload, PersonWorkload,
|
||||
get_people_history, PersonHistoryRecord,
|
||||
search_product_messages, ProductMessageList,
|
||||
@ -70,6 +73,47 @@ async def rejected_tasks(
|
||||
return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit)
|
||||
|
||||
|
||||
@router.get("/user-operations", response_model=list[UserOperation])
|
||||
async def user_operations(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员操作统计 — 按人聚合 接收/转交/上传备注 次数,按时段过滤"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_user_operations(db, since=since_dt, until=until_dt)
|
||||
|
||||
|
||||
@router.get("/user-operations/detail", response_model=list[OperationDetail])
|
||||
async def user_operations_detail(
|
||||
user_id: str = Query(..., description="人员ID(username)"),
|
||||
action_type: str = Query(..., description="操作类型: receive/transfer/record"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员操作明细下钻 — 某人在指定时段的接收/转交/上传备注明细"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_user_operation_detail(
|
||||
db, user_id, action_type, since=since_dt, until=until_dt,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wip-matrix", response_model=list[WipMatrixRow])
|
||||
async def wip_matrix(
|
||||
dimension: str = Query("assignee", description="聚合维度: assignee(人员) / task_name(工序)"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含在库/完成)"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_wip_matrix(db, dimension=dimension, since=since_dt, until=until_dt)
|
||||
|
||||
|
||||
@router.get("/people-workload", response_model=list[PersonWorkload])
|
||||
async def people_workload(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
60
backend/app/api/v1/endpoints/holidays.py
Normal file
60
backend/app/api/v1/endpoints/holidays.py
Normal 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()
|
||||
@ -13,6 +13,7 @@ from app.api.v1.endpoints.records import router as records_router
|
||||
from app.api.v1.endpoints.notifications import router as notifications_router
|
||||
from app.api.v1.endpoints.app_version import router as app_version_router
|
||||
from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
from app.api.v1.endpoints.holidays import router as holidays_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -29,3 +30,4 @@ api_router.include_router(records_router)
|
||||
api_router.include_router(notifications_router)
|
||||
api_router.include_router(app_version_router)
|
||||
api_router.include_router(analytics_router)
|
||||
api_router.include_router(holidays_router)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"""全局北京时间 (UTC+8)"""
|
||||
from datetime import datetime
|
||||
"""全局北京时间 (UTC+8) 与工作日时长计算"""
|
||||
from datetime import datetime, date, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
@ -8,3 +8,48 @@ BEIJING_TZ = ZoneInfo("Asia/Shanghai")
|
||||
def get_beijing_time() -> datetime:
|
||||
"""返回当前北京时间"""
|
||||
return datetime.now(BEIJING_TZ)
|
||||
|
||||
|
||||
def to_beijing(dt: datetime | None) -> datetime | None:
|
||||
"""将任意 datetime 统一转为北京时间 aware。
|
||||
|
||||
- naive 时间按 UTC 处理(数据库 timestamptz 实存 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)
|
||||
|
||||
@ -7,6 +7,7 @@ from app.models.task_log import TaskLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.app_version import AppVersion
|
||||
from app.models.message import ProductMessage
|
||||
from app.models.holiday import Holiday
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ProductionOrder",
|
||||
@ -17,4 +18,5 @@ __all__ = [
|
||||
"Notification",
|
||||
"AppVersion",
|
||||
"ProductMessage",
|
||||
"Holiday",
|
||||
]
|
||||
|
||||
21
backend/app/models/holiday.py
Normal file
21
backend/app/models/holiday.py
Normal 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}>"
|
||||
@ -63,6 +63,9 @@ class ProductResponse(BaseModel):
|
||||
latest_record_assignee_name: str | None = None
|
||||
# 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时)
|
||||
active_duration_hours: float | None = None
|
||||
# 🔧 生产总天数(自创建至今)
|
||||
production_days: int = 0 # 自然天
|
||||
production_days_workdays: int = 0 # 工作日(排除周末/节假日)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@ -54,6 +54,8 @@ class FlowDevice(BaseModel):
|
||||
spec_model: str
|
||||
lead_time: float # 设备生命周期总时长(小时)= max_end - min_start
|
||||
started_at: str # 设备最早介入时间(T0),格式 MM-DD HH:mm
|
||||
total_days: int = 0 # 设备生产总天数(自然天,自最早介入至今)
|
||||
total_workdays: int = 0 # 设备生产总天数(工作日,排除周末/节假日)
|
||||
|
||||
|
||||
class FlowSeries(BaseModel):
|
||||
@ -114,11 +116,15 @@ def _to_bj(dt: datetime | None) -> datetime | None:
|
||||
return dt
|
||||
|
||||
|
||||
def _duration_hours(start: datetime | None, end: datetime) -> float:
|
||||
"""计算单台耗时(小时),无开始时间返回 0。"""
|
||||
def _duration_hours(start: datetime | None, end: datetime, holidays: set = None, mode: str = "workdays") -> float:
|
||||
"""计算单台耗时(小时),无开始时间返回 0。
|
||||
mode=workdays: 排除周末/节假日的工作小时;mode=natural: 自然小时。"""
|
||||
if not start:
|
||||
return 0.0
|
||||
return (end - start).total_seconds() / 3600
|
||||
if mode == "natural":
|
||||
return (end - start).total_seconds() / 3600
|
||||
from app.core.time_utils import working_duration_hours
|
||||
return working_duration_hours(start, end, holidays)
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -131,6 +137,7 @@ async def get_capability_profile(
|
||||
spec_models: list[str] | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
mode: str = "workdays",
|
||||
) -> CapabilityResponse:
|
||||
"""
|
||||
个人能力图谱(单机颗粒度):X 轴 = 设备身份证,每个负责人一条柱状 series。
|
||||
@ -142,10 +149,15 @@ async def get_capability_profile(
|
||||
Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED,
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.holiday import Holiday
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
# 读取节假日(排除非工作日)
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Task.assignee_id, Task.status,
|
||||
@ -196,7 +208,7 @@ async def get_capability_profile(
|
||||
"hours": 0.0, "prio": 9,
|
||||
"first_start": None, "last_completed": None,
|
||||
})
|
||||
entry["hours"] += _duration_hours(start, end)
|
||||
entry["hours"] += _duration_hours(start, end, holidays, mode)
|
||||
if start and (entry["first_start"] is None or start < entry["first_start"]):
|
||||
entry["first_start"] = start
|
||||
completed_dt = _to_bj(row[4]) if row[4] else None
|
||||
@ -272,6 +284,7 @@ async def get_flow_compare(
|
||||
db: AsyncSession,
|
||||
product_sns: list[str] | None = None,
|
||||
spec_models: list[str] | None = None,
|
||||
mode: str = "natural",
|
||||
) -> FlowResponse:
|
||||
"""
|
||||
查询每台设备上各操作人的任务时间区间,拼装为「生命周期时间轴」区间图:
|
||||
@ -388,22 +401,50 @@ async def get_flow_compare(
|
||||
idx: round((max_end_by_device[idx] - t0_by_device[idx]).total_seconds() / 3600, 1)
|
||||
for idx in t0_by_device
|
||||
}
|
||||
# 🔧 设备生产总天数(自然天 + 工作日,自最早介入至今)
|
||||
import math
|
||||
from app.core.time_utils import to_beijing as _tb, working_duration_hours as _wdh
|
||||
from app.models.holiday import Holiday as _Holiday
|
||||
hres = await db.execute(select(_Holiday.day))
|
||||
_holidays = {r[0] for r in hres}
|
||||
|
||||
def _total_days(t0):
|
||||
t0_bj = _tb(t0)
|
||||
if not t0_bj:
|
||||
return 1, 1
|
||||
natural = max(1, math.ceil((now - t0_bj).total_seconds() / 86400))
|
||||
workdays = max(1, math.ceil(_wdh(t0_bj, now, _holidays) / 24))
|
||||
return natural, workdays
|
||||
|
||||
days_map = {i: _total_days(t0_by_device[i]) for i in t0_by_device}
|
||||
devices = [
|
||||
FlowDevice(
|
||||
product_sn=d.product_sn, external_serial=d.external_serial,
|
||||
material_name=d.material_name, spec_model=d.spec_model,
|
||||
lead_time=lead_time_by_index.get(i, 0.0),
|
||||
started_at=t0_by_device[i].strftime("%m-%d %H:%M") if i in t0_by_device else "",
|
||||
total_days=days_map.get(i, (1, 1))[0],
|
||||
total_workdays=days_map.get(i, (1, 1))[1],
|
||||
)
|
||||
for i, d in enumerate(devices)
|
||||
]
|
||||
|
||||
# ── 按人分组,转为相对 T0 的小时偏移区间 ──
|
||||
by_assignee: dict[str, list[list]] = {}
|
||||
# 工作日模式:把时间偏移换算为「排除周末/节假日的工作小时」,压缩休息日
|
||||
if mode == "workdays":
|
||||
from app.core.time_utils import working_duration_hours as _wdh
|
||||
from app.models.holiday import Holiday as _Holiday
|
||||
hres = await db.execute(select(_Holiday.day))
|
||||
_holidays = {r[0] for r in hres}
|
||||
for it in intervals:
|
||||
t0 = t0_by_device[it["idx"]]
|
||||
start_offset = round((it["start"] - t0).total_seconds() / 3600, 1)
|
||||
end_offset = round((it["end"] - t0).total_seconds() / 3600, 1)
|
||||
if mode == "workdays":
|
||||
start_offset = _wdh(t0, it["start"], _holidays)
|
||||
end_offset = _wdh(t0, it["end"], _holidays)
|
||||
else:
|
||||
start_offset = round((it["start"] - t0).total_seconds() / 3600, 1)
|
||||
end_offset = round((it["end"] - t0).total_seconds() / 3600, 1)
|
||||
duration = round(end_offset - start_offset, 1)
|
||||
by_assignee.setdefault(it["assignee_id"], []).append(
|
||||
[it["idx"], start_offset, end_offset, it["task_name"], duration, it["is_main"]]
|
||||
|
||||
@ -63,6 +63,30 @@ class RejectedTask(BaseModel):
|
||||
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):
|
||||
product_id: str
|
||||
serial_number: str # 16位HEX身份证
|
||||
@ -192,7 +216,12 @@ async def get_dashboard_stats(
|
||||
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import get_beijing_time, BEIJING_TZ
|
||||
from app.models.holiday import Holiday
|
||||
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
|
||||
|
||||
# 读取节假日(排除非工作日)
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
stmt = (
|
||||
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
||||
@ -214,12 +243,9 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
for task, product_sn, ext_sn, mat_name, spec in rows:
|
||||
start = task.received_at or task.created_at
|
||||
if start:
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=BEIJING_TZ)
|
||||
else:
|
||||
start = start.astimezone(BEIJING_TZ)
|
||||
hours = round((now - start).total_seconds() / 3600, 1)
|
||||
recv_str = start.strftime("%m-%d %H:%M")
|
||||
start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间(修复多算8小时)
|
||||
hours = working_duration_hours(start_bj, now, holidays)
|
||||
recv_str = start_bj.strftime("%m-%d %H:%M")
|
||||
else:
|
||||
hours = 0
|
||||
recv_str = ""
|
||||
@ -465,6 +491,304 @@ async def get_rejected_tasks(
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 人员操作统计(接收/转交/上传备注 — 按人聚合,时间可筛选)
|
||||
# ============================================================
|
||||
|
||||
async def get_user_operations(
|
||||
db: AsyncSession,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> list[UserOperation]:
|
||||
"""上帝视角 — 统计**全部人员**的操作次数(按时段过滤)。
|
||||
|
||||
返回所有 IRIS 部门人员(该时段无操作的计 0),并追加有操作记录但不在
|
||||
人员清单中的账号(如历史/已离职)。
|
||||
|
||||
操作口径(均不修改数据库):
|
||||
- 接收: task_logs.action_type='receive'(按操作人)
|
||||
- 转交: task_logs.action_type='complete'(按操作人)
|
||||
- 上传备注: task_records 按**任务负责人**归因(排除系统自动生成的
|
||||
以 '[' 开头的备注,如 "[接收] 操作员已确认接收"),历史数据可回溯
|
||||
"""
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from sqlalchemy import text, or_
|
||||
|
||||
# ── 1. 接收 / 转交(task_logs 按 operator_id 聚合)──
|
||||
rcv = func.count().filter(TaskLog.action_type == "receive")
|
||||
cpl = func.count().filter(TaskLog.action_type == "complete")
|
||||
stmt = (
|
||||
select(TaskLog.operator_id, rcv.label("receive"), cpl.label("complete"))
|
||||
.where(
|
||||
TaskLog.action_type.in_(["receive", "complete"]),
|
||||
TaskLog.operator_id.isnot(None),
|
||||
)
|
||||
.group_by(TaskLog.operator_id)
|
||||
)
|
||||
if since:
|
||||
stmt = stmt.where(TaskLog.created_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(TaskLog.created_at <= until)
|
||||
op_rows = (await db.execute(stmt)).all()
|
||||
op_map = {r[0]: (r[1] or 0, r[2] or 0) for r in op_rows}
|
||||
|
||||
# ── 2. 上传备注(task_records 按任务 assignee 归因,排除系统自动备注)──
|
||||
rcd_stmt = (
|
||||
select(Task.assignee_id, func.count(TaskRecord.id))
|
||||
.join(Task, TaskRecord.task_id == Task.id)
|
||||
.where(
|
||||
Task.assignee_id.isnot(None),
|
||||
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
|
||||
)
|
||||
.group_by(Task.assignee_id)
|
||||
)
|
||||
if since:
|
||||
rcd_stmt = rcd_stmt.where(TaskRecord.created_at >= since)
|
||||
if until:
|
||||
rcd_stmt = rcd_stmt.where(TaskRecord.created_at <= until)
|
||||
rcd_rows = (await db.execute(rcd_stmt)).all()
|
||||
record_map = {r[0]: r[1] or 0 for r in rcd_rows}
|
||||
|
||||
# ── 3. 获取全部 MOM 用户清单(IRIS 部门)──
|
||||
users: list[dict] = []
|
||||
try:
|
||||
dbm = MomSessionLocal()
|
||||
try:
|
||||
rows = dbm.execute(text("""
|
||||
SELECT username, SPLIT_PART(username, '/', 1) AS full_name
|
||||
FROM sys_user WHERE department = 'IRIS'
|
||||
""")).fetchall()
|
||||
except Exception:
|
||||
rows = dbm.execute(text("""
|
||||
SELECT username, SPLIT_PART(username, '/', 1) AS full_name
|
||||
FROM sys_user
|
||||
""")).fetchall()
|
||||
finally:
|
||||
dbm.close()
|
||||
for row in rows:
|
||||
short = row.username.split("/")[-1] if "/" in row.username else row.username
|
||||
users.append({"username": short, "full_name": row.full_name or short})
|
||||
except Exception:
|
||||
users = []
|
||||
|
||||
# ── 4. 合并:所有人员 + 有操作但不在清单的账号 ──
|
||||
merged: dict[str, dict] = {}
|
||||
for u in users:
|
||||
merged[u["username"]] = {"name": u["full_name"], "rcv": 0, "cpl": 0, "rcd": 0}
|
||||
for uid in set(op_map.keys()) | set(record_map.keys()):
|
||||
if uid not in merged:
|
||||
merged[uid] = {"name": uid, "rcv": 0, "cpl": 0, "rcd": 0}
|
||||
r, c = op_map.get(uid, (0, 0))
|
||||
merged[uid]["rcv"] = r
|
||||
merged[uid]["cpl"] = c
|
||||
merged[uid]["rcd"] = record_map.get(uid, 0)
|
||||
|
||||
# ── 5. 组装 + 排序(总次数降序,无操作的排在最后)──
|
||||
items: list[UserOperation] = []
|
||||
for uid, v in merged.items():
|
||||
items.append(UserOperation(
|
||||
user_id=uid,
|
||||
user_name=v["name"],
|
||||
receive_count=v["rcv"],
|
||||
transfer_count=v["cpl"],
|
||||
record_count=v["rcd"],
|
||||
total=v["rcv"] + v["cpl"] + v["rcd"],
|
||||
))
|
||||
items.sort(key=lambda x: x.total, reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 在制品分布透视表(WIP Matrix:规格型号 × 人员/工序)
|
||||
# ============================================================
|
||||
|
||||
async def get_wip_matrix(
|
||||
db: AsyncSession,
|
||||
dimension: str = "assignee",
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> list[WipMatrixRow]:
|
||||
"""生产分布透视表:Y=规格型号,X=人员 或 工序,单元格=设备数量。
|
||||
|
||||
核心口径:**每台设备只统计一次**,按它「当前所处工序」归属——
|
||||
1. 取该设备最新的一条主分支任务(parent_task_id IS NULL 或 TRANSFER/RECOVERY)
|
||||
2. 设备当前工序 = 该最新主任务的工序名(task_name),不区分状态
|
||||
- 「待确认」(PENDING) = 别人转给我但未接收
|
||||
- 「在库」(COMPLETED) = 真正入库/生产完成
|
||||
- 其他已完成工序(如「测试」完成)按原工序显示,不强制归「在库」
|
||||
|
||||
这样一台设备在「上一步已完成 + 下一步待确认」时只算一次(待确认),不会重复计数。
|
||||
since/until 按设备最新主任务的创建时间过滤。
|
||||
|
||||
dimension:
|
||||
- assignee: 按当前任务负责人聚合(dimension_key 为中文姓名)
|
||||
- task_name: 按当前工序聚合(dimension_key 为工序名,附主负责人)
|
||||
"""
|
||||
from datetime import timezone as dt_timezone
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
|
||||
# 每台设备按主任务创建时间倒序,取第一条即「最新主任务」
|
||||
result = await db.execute(
|
||||
select(
|
||||
Product.id,
|
||||
Product.spec_model,
|
||||
Task.task_name,
|
||||
Task.assignee_id,
|
||||
Task.created_at,
|
||||
)
|
||||
.join(Task, Task.product_id == Product.id)
|
||||
.where(
|
||||
or_(
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
|
||||
)
|
||||
)
|
||||
.order_by(Product.id, Task.created_at.desc())
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
|
||||
device_cur: dict[str, tuple] = {}
|
||||
seen: set[str] = set()
|
||||
for pid, spec, task_name, assignee, created in rows:
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
|
||||
# 时间筛选:设备最新主任务的创建时间
|
||||
if created is not None and created.tzinfo is None:
|
||||
created = created.replace(tzinfo=dt_timezone.utc)
|
||||
if since and created is not None and created < since:
|
||||
continue
|
||||
if until and created is not None and created > until:
|
||||
continue
|
||||
|
||||
if dimension == "task_name":
|
||||
# 设备当前工序 = 最新主任务的工序名(不区分状态,不强制完成态归在库)
|
||||
key = task_name or "—"
|
||||
else:
|
||||
key = assignee or "未分配"
|
||||
device_cur[pid] = (spec or "未知型号", key, assignee or "")
|
||||
|
||||
# 聚合:规格 × 当前工序 → 设备数;同时收集负责人
|
||||
agg: dict[tuple, int] = {}
|
||||
assignee_map: dict[tuple, set] = {}
|
||||
for spec, key, assignee in device_cur.values():
|
||||
k = (spec, key)
|
||||
agg[k] = agg.get(k, 0) + 1
|
||||
if assignee:
|
||||
assignee_map.setdefault(k, set()).add(assignee)
|
||||
|
||||
# 负责人 ID → 中文名
|
||||
raw_ids: set[str] = set()
|
||||
for s in assignee_map.values():
|
||||
raw_ids |= s
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(list(raw_ids))
|
||||
|
||||
items: list[WipMatrixRow] = []
|
||||
for (spec, key), cnt in agg.items():
|
||||
dim_display = name_map.get(key, key) if dimension == "assignee" else key
|
||||
assignees = [name_map.get(a, a) for a in assignee_map.get((spec, key), set())] or []
|
||||
items.append(WipMatrixRow(
|
||||
spec_model=spec,
|
||||
dimension_key=dim_display,
|
||||
count=cnt,
|
||||
assignees=assignees,
|
||||
))
|
||||
items.sort(key=lambda x: (x.spec_model, x.dimension_key))
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 人员操作明细(点击数字下钻 — 接收/转交/上传备注)
|
||||
# ============================================================
|
||||
|
||||
async def get_user_operation_detail(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
action_type: str,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> list[OperationDetail]:
|
||||
"""查询某人在指定时段内的某类操作明细。
|
||||
|
||||
action_type:
|
||||
- receive: 接收(task_logs.action_type='receive')
|
||||
- transfer: 转交(task_logs.action_type='complete')
|
||||
- record: 上传备注(该人名下任务的手动备注,排除系统自动生成的)
|
||||
"""
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
|
||||
def _to_iso(dt) -> str:
|
||||
if not dt:
|
||||
return ""
|
||||
if dt.tzinfo is None:
|
||||
from datetime import timezone as dt_timezone
|
||||
dt = dt.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt.isoformat()
|
||||
|
||||
items: list[OperationDetail] = []
|
||||
|
||||
if action_type in ("receive", "transfer"):
|
||||
act = "receive" if action_type == "receive" else "complete"
|
||||
stmt = (
|
||||
select(TaskLog.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskLog.remark)
|
||||
.join(Task, TaskLog.task_id == Task.id)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(TaskLog.operator_id == user_id, TaskLog.action_type == act)
|
||||
.order_by(TaskLog.created_at.desc())
|
||||
)
|
||||
if since:
|
||||
stmt = stmt.where(TaskLog.created_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(TaskLog.created_at <= until)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
for r in rows:
|
||||
items.append(OperationDetail(
|
||||
task_name=r[1] or "",
|
||||
product_sn=r[2] or "",
|
||||
material_name=r[3] or "",
|
||||
remark=r[4] or "",
|
||||
time=_to_iso(r[0]),
|
||||
))
|
||||
elif action_type == "record":
|
||||
stmt = (
|
||||
select(TaskRecord.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskRecord.remark)
|
||||
.join(Task, TaskRecord.task_id == Task.id)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Task.assignee_id == user_id,
|
||||
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
|
||||
)
|
||||
.order_by(TaskRecord.created_at.desc())
|
||||
)
|
||||
if since:
|
||||
stmt = stmt.where(TaskRecord.created_at >= since)
|
||||
if until:
|
||||
stmt = stmt.where(TaskRecord.created_at <= until)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
for r in rows:
|
||||
items.append(OperationDetail(
|
||||
task_name=r[1] or "",
|
||||
product_sn=r[2] or "",
|
||||
material_name=r[3] or "",
|
||||
remark=r[4] or "",
|
||||
time=_to_iso(r[0]),
|
||||
))
|
||||
return items
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 人员负载(按人聚合在制品设备 — 独立「人员看板」)
|
||||
# ============================================================
|
||||
@ -473,7 +797,12 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
|
||||
"""上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。"""
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import get_beijing_time, BEIJING_TZ
|
||||
from app.models.holiday import Holiday
|
||||
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
|
||||
|
||||
# 读取节假日(排除非工作日)
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
@ -493,14 +822,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
|
||||
rows = result.all()
|
||||
|
||||
def _to_bj(dt):
|
||||
if not dt:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
from datetime import timezone as dt_timezone
|
||||
dt = dt.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt
|
||||
return to_beijing(dt)
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
@ -539,7 +861,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
|
||||
devices: list[PersonDevice] = []
|
||||
for product_id, e in prods.items():
|
||||
dt = e["earliest_dt"]
|
||||
hours = round((now - dt).total_seconds() / 3600, 1) if dt else 0.0
|
||||
hours = working_duration_hours(dt, now, holidays) if dt else 0.0
|
||||
received_str = dt.strftime("%m-%d %H:%M") if dt else None
|
||||
devices.append(PersonDevice(
|
||||
product_id=product_id,
|
||||
@ -577,11 +899,16 @@ async def get_people_history(
|
||||
"""上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED)。"""
|
||||
from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import get_beijing_time, BEIJING_TZ
|
||||
from app.core.time_utils import get_beijing_time, BEIJING_TZ, to_beijing, working_duration_hours
|
||||
from app.models.holiday import Holiday
|
||||
from sqlalchemy import func
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
# 读取节假日(排除非工作日)
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
# ── 关联 TaskRecord:最新有效备注 + 记录总数 ──
|
||||
from app.models.task import TaskRecord
|
||||
from sqlalchemy import and_, or_
|
||||
@ -705,7 +1032,7 @@ async def get_people_history(
|
||||
else:
|
||||
end_dt = now
|
||||
completed_str = None
|
||||
hours = round((end_dt - received_dt).total_seconds() / 3600, 1) if received_dt else 0.0
|
||||
hours = working_duration_hours(received_dt, end_dt, holidays) if received_dt else 0.0
|
||||
records.append(PersonHistoryRecord(
|
||||
task_id=str(row[0]),
|
||||
task_name=row[1] or "",
|
||||
|
||||
@ -541,11 +541,14 @@ async def get_all_products(
|
||||
has_img = bool(row[3] and row[3] != "[]" and row[3] != "null")
|
||||
latest_record_map[row[0]] = (row[1], row[2], has_img, row[4])
|
||||
|
||||
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时
|
||||
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时(排除非工作日)
|
||||
active_duration_map: dict[uuid.UUID, float] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import func as sa_func
|
||||
from app.core.time_utils import get_beijing_time, BEIJING_TZ
|
||||
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
|
||||
from app.models.holiday import Holiday
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
start_stmt = (
|
||||
select(
|
||||
Task.product_id,
|
||||
@ -563,11 +566,29 @@ async def get_all_products(
|
||||
start = row[1]
|
||||
if start is None:
|
||||
continue
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=BEIJING_TZ)
|
||||
else:
|
||||
start = start.astimezone(BEIJING_TZ)
|
||||
active_duration_map[row[0]] = round((now - start).total_seconds() / 3600, 1)
|
||||
start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间(修复多算8小时)
|
||||
active_duration_map[row[0]] = working_duration_hours(start_bj, now, holidays)
|
||||
|
||||
# 🔧 生产总天数(自然天 + 工作日):自创建至今
|
||||
import math
|
||||
from app.core.time_utils import to_beijing as _tb, working_duration_hours as _wdh
|
||||
from app.models.holiday import Holiday as _Holiday
|
||||
hres2 = await db.execute(select(_Holiday.day))
|
||||
holidays2 = {r[0] for r in hres2}
|
||||
now2 = get_beijing_time()
|
||||
|
||||
def _prod_days(created_at):
|
||||
created = _tb(created_at)
|
||||
if not created:
|
||||
return 1, 1
|
||||
natural = max(1, math.ceil((now2 - created).total_seconds() / 86400))
|
||||
work_hours = _wdh(created, now2, holidays2)
|
||||
workdays = max(1, math.ceil(work_hours / 24))
|
||||
return natural, workdays
|
||||
|
||||
production_days_map: dict = {}
|
||||
for _p in products:
|
||||
production_days_map[_p.id] = _prod_days(_p.created_at)
|
||||
|
||||
return [
|
||||
ProductResponse(
|
||||
@ -604,6 +625,8 @@ async def get_all_products(
|
||||
if latest_record_map.get(p.id, (None, None, False, None))[3] else None
|
||||
),
|
||||
active_duration_hours=active_duration_map.get(p.id),
|
||||
production_days=production_days_map.get(p.id, (1, 1))[0],
|
||||
production_days_workdays=production_days_map.get(p.id, (1, 1))[1],
|
||||
)
|
||||
for p in products
|
||||
]
|
||||
|
||||
@ -24,6 +24,7 @@ const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage"));
|
||||
const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage"));
|
||||
const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage"));
|
||||
const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard"));
|
||||
const MatrixBoard = lazy(() => import("./pages/MatrixBoard"));
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -55,6 +56,7 @@ export default function App() {
|
||||
<Route path="/admin/people" element={<AdminPeoplePage />} />
|
||||
<Route path="/admin/print-config" element={<AdminPrintConfigPage />} />
|
||||
<Route path="/admin/analytics" element={<AnalyticsDashboard />} />
|
||||
<Route path="/admin/matrix" element={<MatrixBoard />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate, Navigate } from "react-router-dom";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3 } from "lucide-react";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2 } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
const MENU = [
|
||||
@ -33,6 +33,12 @@ const MENU = [
|
||||
icon: BarChart3,
|
||||
description: "人员效能 / 设备流转 ECharts 可视化",
|
||||
},
|
||||
{
|
||||
title: "WIP 分布矩阵",
|
||||
path: "/admin/matrix",
|
||||
icon: Table2,
|
||||
description: "规格型号 × 人员/工序 在制品透视表",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
193
frontend/src/pages/MatrixBoard.tsx
Normal file
193
frontend/src/pages/MatrixBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,15 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
|
||||
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle, CheckCircle2,
|
||||
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
|
||||
Clock, MessageCircle, CheckCircle2, Users,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Radio, DatePicker, Drawer, Input } from "antd";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import {
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask,
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail,
|
||||
} from "../../services/dashboardApi";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
@ -269,6 +270,22 @@ export default function AdminDashboard() {
|
||||
const [rejectedTasks, setRejectedTasks] = useState<RejectedTask[]>([]);
|
||||
const [rejectedLoading, setRejectedLoading] = useState(false);
|
||||
|
||||
// 人员操作统计(抽屉)
|
||||
const [opsDrawerOpen, setOpsDrawerOpen] = useState(false);
|
||||
const [opStats, setOpStats] = useState<UserOperation[]>([]);
|
||||
const [opLoading, setOpLoading] = useState(false);
|
||||
const [opFilterUsers, setOpFilterUsers] = useState<Set<string>>(new Set()); // 多选人员
|
||||
const [opFilterTouched, setOpFilterTouched] = useState(false); // 用户是否主动操作过筛选
|
||||
const [opSort, setOpSort] = useState<{ key: string; order: "asc" | "desc" } | null>(null);
|
||||
// 抽屉内独立时间筛选(不依赖顶部)
|
||||
const [opDateKey, setOpDateKey] = useState<DateRangeKey>("today");
|
||||
const [opCustomRange, setOpCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
// 操作明细下钻
|
||||
const [opDetailOpen, setOpDetailOpen] = useState(false);
|
||||
const [opDetailList, setOpDetailList] = useState<OperationDetail[]>([]);
|
||||
const [opDetailLoading, setOpDetailLoading] = useState(false);
|
||||
const [opDetailTitle, setOpDetailTitle] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── 加载主数据 ──
|
||||
@ -323,6 +340,77 @@ export default function AdminDashboard() {
|
||||
.finally(() => setRejectedLoading(false));
|
||||
};
|
||||
|
||||
// 人员操作统计 — 用抽屉内独立时间筛选
|
||||
const loadUserOperations = useCallback(() => {
|
||||
setOpLoading(true);
|
||||
const { since, until } = rangeToParams(opDateKey, opCustomRange);
|
||||
fetchUserOperations(since, until)
|
||||
.then(setOpStats)
|
||||
.catch(() => setOpStats([]))
|
||||
.finally(() => setOpLoading(false));
|
||||
}, [opDateKey, opCustomRange]);
|
||||
|
||||
// 抽屉打开或内部时间变化时加载
|
||||
useEffect(() => {
|
||||
if (opsDrawerOpen) loadUserOperations();
|
||||
}, [opsDrawerOpen, loadUserOperations]);
|
||||
|
||||
const openOpsDrawer = () => {
|
||||
setOpsDrawerOpen(true);
|
||||
setOpDateKey(dateKey); // 默认同步顶部当前时间
|
||||
setOpCustomRange(customRange);
|
||||
setOpFilterUsers(new Set());
|
||||
setOpFilterTouched(false);
|
||||
setOpSort(null);
|
||||
};
|
||||
|
||||
// 首次加载完成后默认全选(显示全部);用户操作过筛选后不再自动覆盖
|
||||
useEffect(() => {
|
||||
if (!opFilterTouched && opStats.length > 0) {
|
||||
setOpFilterUsers(new Set(opStats.map(u => u.user_id)));
|
||||
}
|
||||
}, [opStats, opFilterTouched]);
|
||||
|
||||
// 人员多选切换
|
||||
const toggleOpFilterUser = (id: string) => {
|
||||
setOpFilterTouched(true);
|
||||
setOpFilterUsers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
// 全选 / 全不选
|
||||
const selectAllUsers = () => {
|
||||
setOpFilterTouched(true);
|
||||
setOpFilterUsers(new Set(opStats.map(u => u.user_id)));
|
||||
};
|
||||
const clearAllUsers = () => {
|
||||
setOpFilterTouched(true);
|
||||
setOpFilterUsers(new Set());
|
||||
};
|
||||
|
||||
// 表头升降序:无 → 升序 → 降序 → 无
|
||||
const toggleOpSort = (key: string) => {
|
||||
setOpSort(prev => {
|
||||
if (!prev || prev.key !== key) return { key, order: "asc" };
|
||||
if (prev.order === "asc") return { key, order: "desc" };
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
// 点击数字下钻查看明细(跟随抽屉内时间)
|
||||
const openOpDetail = (user: UserOperation, actionType: string, label: string) => {
|
||||
setOpDetailOpen(true);
|
||||
setOpDetailTitle(`${user.user_name} · ${label}`);
|
||||
setOpDetailLoading(true);
|
||||
const { since, until } = rangeToParams(opDateKey, opCustomRange);
|
||||
fetchOperationDetail(user.user_id, actionType, since, until)
|
||||
.then(setOpDetailList)
|
||||
.catch(() => setOpDetailList([]))
|
||||
.finally(() => setOpDetailLoading(false));
|
||||
};
|
||||
|
||||
const onMsgSearch = (value: string) => {
|
||||
setMsgKeyword(value);
|
||||
loadMessages(value);
|
||||
@ -524,6 +612,11 @@ export default function AdminDashboard() {
|
||||
className="flex w-full items-center justify-between rounded-lg bg-purple-50 px-4 py-3 text-left text-sm font-medium text-purple-700 hover:bg-purple-100 transition-colors">
|
||||
<span>💬 协同留言</span><ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
{/* 👥 人员操作统计入口 */}
|
||||
<button onClick={openOpsDrawer}
|
||||
className="flex w-full items-center justify-between rounded-lg bg-indigo-50 px-4 py-3 text-left text-sm font-medium text-indigo-700 hover:bg-indigo-100 transition-colors">
|
||||
<span className="flex items-center gap-1.5"><Users className="h-4 w-4" />人员操作统计</span><ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -647,6 +740,163 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* ═══ 人员操作统计抽屉 ═══ */}
|
||||
<Drawer
|
||||
title={<span className="text-base font-bold">👥 人员操作统计 <span className="font-normal text-gray-400">{opStats.length} 人</span></span>}
|
||||
open={opsDrawerOpen}
|
||||
onClose={() => setOpsDrawerOpen(false)}
|
||||
size="large"
|
||||
styles={{ body: { padding: 16, background: "#f8fafc" } }}
|
||||
>
|
||||
{/* 抽屉内时间筛选 */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Radio.Group
|
||||
value={opDateKey}
|
||||
onChange={e => { setOpDateKey(e.target.value); setOpCustomRange(null); }}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="today">今天</Radio.Button>
|
||||
<Radio.Button value="7d">近7天</Radio.Button>
|
||||
<Radio.Button value="30d">近30天</Radio.Button>
|
||||
<Radio.Button value="custom">自定义</Radio.Button>
|
||||
</Radio.Group>
|
||||
{opDateKey === "custom" && (
|
||||
<RangePicker
|
||||
size="small"
|
||||
value={opCustomRange as any}
|
||||
onChange={dates => setOpCustomRange(dates as [Dayjs, Dayjs] | null)}
|
||||
style={{ width: 240 }}
|
||||
placeholder={["开始", "结束"]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 人员选择 — 人名宫格多选 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">人员筛选(可多选)</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={selectAllUsers}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${opFilterUsers.size > 0 && opFilterUsers.size === opStats.length ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
|
||||
全选
|
||||
</button>
|
||||
<button onClick={clearAllUsers}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${opFilterUsers.size === 0 ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
|
||||
全不选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(72px, 1fr))" }}>
|
||||
{opStats.map(u => {
|
||||
const active = opFilterUsers.size > 0 && opFilterUsers.has(u.user_id);
|
||||
return (
|
||||
<button key={u.user_id} onClick={() => toggleOpFilterUser(u.user_id)}
|
||||
className={`rounded-lg px-2 py-1.5 text-xs font-medium transition-colors ${active ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>
|
||||
{u.user_name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{opLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : opStats.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400">该时段暂无操作记录</div>
|
||||
) : opFilterUsers.size === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400">未选择任何人员,请点击「全选」或选择人员</div>
|
||||
) : (
|
||||
(() => {
|
||||
// 过滤 + 排序后的数据
|
||||
let list = opStats.filter(u => opFilterUsers.has(u.user_id));
|
||||
if (opSort) {
|
||||
const key = opSort.key;
|
||||
const getVal = (u: UserOperation) => key === "receive" ? u.receive_count : key === "transfer" ? u.transfer_count : key === "record" ? u.record_count : u.total;
|
||||
list = [...list].sort((a, b) => opSort!.order === "asc" ? getVal(a) - getVal(b) : getVal(b) - getVal(a));
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-xs text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">#</th>
|
||||
<th className="py-2 pr-3 font-medium">人员</th>
|
||||
{(["receive", "transfer", "record", "total"] as const).map(k => {
|
||||
const label = k === "receive" ? "接收" : k === "transfer" ? "转交" : k === "record" ? "上传备注" : "总次数";
|
||||
const sorted = opSort?.key === k;
|
||||
return (
|
||||
<th key={k} className="cursor-pointer py-2 pr-3 text-right font-medium hover:text-blue-600" onClick={() => toggleOpSort(k)}>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{label}
|
||||
{sorted ? (opSort!.order === "asc" ? <ArrowUp className="h-3 w-3 text-blue-600" /> : <ArrowDown className="h-3 w-3 text-blue-600" />) : <ArrowUpDown className="h-3 w-3 text-gray-300" />}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((u, idx) => (
|
||||
<tr key={u.user_id} className="border-b border-gray-50 last:border-0 hover:bg-gray-50/50">
|
||||
<td className="py-2 pr-3 text-xs text-gray-400">{idx + 1}</td>
|
||||
<td className="py-2 pr-3 font-medium text-gray-800">{u.user_name}</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<button onClick={() => openOpDetail(u, "receive", "接收")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.receive_count}</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<button onClick={() => openOpDetail(u, "transfer", "转交")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.transfer_count}</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<button onClick={() => openOpDetail(u, "record", "上传备注")} className="cursor-pointer text-blue-600 hover:text-blue-800 hover:underline">{u.record_count}</button>
|
||||
</td>
|
||||
<td className="py-2 text-right font-bold text-blue-600">{u.total}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* ═══ 操作明细抽屉 ═══ */}
|
||||
<Drawer
|
||||
title={<span className="text-base font-bold">📋 {opDetailTitle} <span className="font-normal text-gray-400">{opDetailList.length} 条</span></span>}
|
||||
open={opDetailOpen}
|
||||
onClose={() => setOpDetailOpen(false)}
|
||||
size="large"
|
||||
styles={{ body: { padding: 16, background: "#f8fafc" } }}
|
||||
>
|
||||
{opDetailLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : opDetailList.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400">该时段暂无相关记录</div>
|
||||
) : (
|
||||
<div>
|
||||
{opDetailList.map((d, i) => (
|
||||
<div key={i} className="mb-2 rounded-lg border border-gray-100 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-800">{d.task_name}</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">{d.time ? dayjs(d.time).format("YYYY-MM-DD HH:mm") : ""}</span>
|
||||
</div>
|
||||
{d.remark && <p className="mt-1 text-xs leading-relaxed text-gray-600">{d.remark}</p>}
|
||||
<div className="mt-1.5 text-[11px] text-gray-400">
|
||||
<span>{d.material_name || "未知设备"}</span>
|
||||
{d.product_sn && <span className="ml-2 font-mono">身份证: {d.product_sn}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -250,7 +250,8 @@ export default function AdminProductsPage() {
|
||||
return <span className={`rounded-md px-1.5 py-0.5 font-bold ${cls}`}>{formatDuration(h)}</span>;
|
||||
})()}
|
||||
</div>
|
||||
<InfoRow icon={CalendarDays} label="生产总天数" value={formatProductionDays(p.created_at)} />
|
||||
<InfoRow icon={CalendarDays} label="生产总天数" value={`${p.production_days ?? formatProductionDays(p.created_at)} 天`} />
|
||||
<InfoRow icon={CalendarDays} label="生产总天数(工作日)" value={p.production_days_workdays != null ? `${p.production_days_workdays} 天` : "—"} />
|
||||
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
|
||||
</div>
|
||||
<div className="border-t border-gray-50 px-4 py-3">
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
* 个人能力图谱:X 轴 = 设备身份证,单机耗时对比,支持点击柱子下钻备注弹窗(含照片)。 */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image } from "antd";
|
||||
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle } from "lucide-react";
|
||||
import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image, Radio } from "antd";
|
||||
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle, CalendarDays } from "lucide-react";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import "dayjs/locale/zh-cn";
|
||||
@ -59,6 +59,8 @@ function alignName(name: string) {
|
||||
return name.length === 2 ? name[0] + " " + name[1] : name;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 6; // 人员视图瀑布流每块设备数
|
||||
|
||||
export default function AnalyticsDashboard() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@ -88,6 +90,8 @@ export default function AnalyticsDashboard() {
|
||||
const [capabilityLoading, setCapabilityLoading] = useState(false);
|
||||
const [flow, setFlow] = useState<FlowResponse | null>(null);
|
||||
const [flowLoading, setFlowLoading] = useState(false);
|
||||
// 📅 设备生产总天数口径:自然天 / 工作日
|
||||
const [totalDaysMode, setTotalDaysMode] = useState<"natural" | "workdays">("natural");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ─── 弹窗下钻:设备备注(all=true 表示流转图点击,展示全部) ──
|
||||
@ -118,15 +122,17 @@ export default function AnalyticsDashboard() {
|
||||
q.since = range[0].startOf("day").toISOString();
|
||||
q.until = range[1].endOf("day").toISOString();
|
||||
}
|
||||
q.mode = totalDaysMode; // 自然天 / 工作日,控制人员视图耗时口径
|
||||
return q;
|
||||
}, [assigneeIds, specModels, range]);
|
||||
}, [assigneeIds, specModels, range, totalDaysMode]);
|
||||
|
||||
const buildFlowQuery = useCallback((): FlowQuery => {
|
||||
const q: FlowQuery = {};
|
||||
if (productSns.length) q.product_sns = productSns;
|
||||
if (specModels.length) q.spec_models = specModels;
|
||||
q.mode = totalDaysMode; // 自然天 / 工作日,控制轨迹流转图时间口径
|
||||
return q;
|
||||
}, [productSns, specModels]);
|
||||
}, [productSns, specModels, totalDaysMode]);
|
||||
|
||||
// ─── 能力图谱(需选择人员才发起) ──
|
||||
const loadCapability = useCallback(async () => {
|
||||
@ -194,12 +200,13 @@ export default function AnalyticsDashboard() {
|
||||
}, [recordQuery, assigneeIds]);
|
||||
|
||||
// 能力图谱:点柱子 → 按当前人员筛选过滤备注
|
||||
const handleZrClick = useCallback((chart: any, e: any) => {
|
||||
const handleZrClick = useCallback((chart: any, e: any, chunkStart = 0) => {
|
||||
const coord = chart.convertFromPixel({ seriesIndex: 0 }, [e.offsetX, e.offsetY]);
|
||||
if (!coord || coord.length < 1) return;
|
||||
const dataIndex = Math.round(coord[0]);
|
||||
if (dataIndex < 0 || dataIndex >= categoriesRef.current.length) return;
|
||||
const sn = categoriesRef.current[dataIndex];
|
||||
const globalIdx = chunkStart + dataIndex; // 瀑布流块内索引 → 全局设备索引
|
||||
if (globalIdx < 0 || globalIdx >= categoriesRef.current.length) return;
|
||||
const sn = categoriesRef.current[globalIdx];
|
||||
if (sn) setRecordQuery({ sn, all: false });
|
||||
}, []);
|
||||
|
||||
@ -220,15 +227,32 @@ export default function AnalyticsDashboard() {
|
||||
setProductSns([]);
|
||||
};
|
||||
|
||||
// ─── 柱状图:人员视图(X=设备身份证,系列=人员,居中紧凑,消除幽灵占位) ──
|
||||
const capabilityOption = useMemo<EChartsCoreOption>(() => {
|
||||
const categories = capability?.categories ?? [];
|
||||
const devices = capability?.devices ?? [];
|
||||
// 每台设备上真正产生耗时的人员 seriesIndex 数组
|
||||
const activePerDevice = devices.map((_, devIdx) =>
|
||||
(capability?.series ?? [])
|
||||
// ─── 人员视图:设备按 CHUNK_SIZE 切块(瀑布流,向下追加渲染) ──
|
||||
const capabilityChunks = useMemo(() => {
|
||||
if (!capability || !capability.devices?.length) return [];
|
||||
const chunks = [];
|
||||
const total = capability.devices.length;
|
||||
for (let i = 0; i < total; i += CHUNK_SIZE) {
|
||||
chunks.push({
|
||||
start: i, // 该块在全部设备中的起始索引(点击下钻用)
|
||||
devices: capability.devices.slice(i, i + CHUNK_SIZE),
|
||||
categories: capability.categories.slice(i, i + CHUNK_SIZE),
|
||||
series: capability.series.map(s => ({
|
||||
name: s.name,
|
||||
data: s.data.slice(i, i + CHUNK_SIZE),
|
||||
})),
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}, [capability]);
|
||||
|
||||
// ─── 单个块(6台设备)的 ECharts Option 工厂函数(无横向滚动,向下追加) ──
|
||||
const getCapabilityOption = (chunkDevices: any[], chunkCategories: string[], chunkSeries: any[]): EChartsCoreOption => {
|
||||
// 该块内每台设备上真正产生耗时的人员 seriesIndex
|
||||
const activePerDevice = chunkDevices.map((_, devIdx) =>
|
||||
chunkSeries
|
||||
.map((s, sIdx) => (s.data[devIdx] && s.data[devIdx].value != null ? sIdx : -1))
|
||||
.filter((idx) => idx !== -1),
|
||||
.filter(idx => idx !== -1),
|
||||
);
|
||||
return {
|
||||
tooltip: {
|
||||
@ -238,8 +262,8 @@ export default function AnalyticsDashboard() {
|
||||
formatter: (p: any) => {
|
||||
const items = Array.isArray(p) ? p : [p];
|
||||
const idx = items[0]?.dataIndex ?? 0;
|
||||
const sn = categories[idx] ?? "—";
|
||||
const dev = devices[idx];
|
||||
const sn = chunkCategories[idx] ?? "—";
|
||||
const dev = chunkDevices[idx];
|
||||
const ext = dev?.external_serial;
|
||||
const rows = items
|
||||
.filter((it: any) => (it.data?.status ?? "—") !== "—")
|
||||
@ -263,16 +287,14 @@ export default function AnalyticsDashboard() {
|
||||
},
|
||||
},
|
||||
legend: { top: 0, type: "scroll" },
|
||||
grid: { left: 48, right: 24, top: 60, bottom: 96 },
|
||||
grid: { left: 48, right: 24, top: 80, bottom: 48 }, // top 加大,避免图例遮挡柱子
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: devices.map((d) =>
|
||||
[d.material_name, d.spec_model, d.external_serial, d.product_sn].filter(Boolean).join("\n"),
|
||||
),
|
||||
axisLabel: { fontSize: 10, interval: 0, lineHeight: 13 },
|
||||
data: chunkDevices.map((d) => (d.external_serial ? `${d.external_serial}\n${d.product_sn}` : d.product_sn)),
|
||||
axisLabel: { fontSize: 11, interval: 0, color: "#666", lineHeight: 14 },
|
||||
},
|
||||
yAxis: { type: "value", name: "耗时(小时)" },
|
||||
series: (capability?.series ?? []).map((s, sIdx) => ({
|
||||
series: chunkSeries.map((s, sIdx) => ({
|
||||
name: s.name,
|
||||
type: "custom",
|
||||
encode: { x: 0, y: 1 },
|
||||
@ -291,36 +313,28 @@ export default function AnalyticsDashboard() {
|
||||
const localIndex = activeSeries.indexOf(sIdx);
|
||||
if (localIndex === -1) return;
|
||||
|
||||
const barWidth = 24; // 黄金粗细,绝不妥协
|
||||
const gap = 6; // 紧凑的柱间距
|
||||
const barWidth = 24;
|
||||
const gap = 6;
|
||||
const totalWidth = activeSeries.length * barWidth + (activeSeries.length - 1) * gap;
|
||||
|
||||
// 核心:彻底消除幽灵占位,让存活的柱子绝对居中对齐
|
||||
const centerX = api.coord([devIdx, 0])[0];
|
||||
const x = centerX - totalWidth / 2 + localIndex * (barWidth + gap);
|
||||
|
||||
const valY = api.coord([devIdx, val])[1];
|
||||
const y0 = api.coord([devIdx, 0])[1];
|
||||
const height = Math.max(y0 - valY, 3); // 至少 3px,0 值/极小值也有柱子
|
||||
const height = Math.max(y0 - valY, 3);
|
||||
const y = y0 - height;
|
||||
|
||||
return {
|
||||
type: "rect",
|
||||
shape: { x, y, width: barWidth, height, r: [3, 3, 0, 0] },
|
||||
style: api.style(),
|
||||
};
|
||||
},
|
||||
data: s.data.map((d, i) => {
|
||||
data: s.data.map((d: any, i: number) => {
|
||||
if (d.value == null) return null;
|
||||
return {
|
||||
...d,
|
||||
value: [i, d.value], // custom 必须的 [x, y] 坐标格式
|
||||
actualValue: d.value, // 供 tooltip 读取的真实值
|
||||
};
|
||||
return { ...d, value: [i, d.value], actualValue: d.value };
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}, [capability]);
|
||||
};
|
||||
|
||||
// ─── 有数据的日期集合(用于日历蓝点) ──
|
||||
const activityDates = useMemo(() => {
|
||||
@ -673,10 +687,57 @@ export default function AnalyticsDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 📅 设备生产总天数(自然天 / 工作日切换) */}
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<CalendarDays className="h-4 w-4 text-emerald-500" /> 📅 设备生产总天数
|
||||
</h3>
|
||||
<Radio.Group
|
||||
value={totalDaysMode}
|
||||
onChange={e => setTotalDaysMode(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="natural">自然天</Radio.Button>
|
||||
<Radio.Button value="workdays">工作日</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{flowLoading && !flow ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
) : !flow || flow.devices.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-400">暂无设备数据,请选择筛选条件后查询</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">设备数</p>
|
||||
<p className="text-2xl font-bold text-gray-800">{flow.devices.length}<span className="text-sm font-normal text-gray-400"> 台</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">{totalDaysMode === "natural" ? "平均生产总天数(自然天)" : "平均生产总天数(工作日)"}</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">
|
||||
{Math.round(flow.devices.reduce((s, d) => s + (totalDaysMode === "natural" ? d.total_days : d.total_workdays), 0) / flow.devices.length)}
|
||||
<span className="text-sm font-normal text-gray-400"> 天</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">最长生产周期({totalDaysMode === "natural" ? "自然天" : "工作日"})</p>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{Math.max(...flow.devices.map(d => totalDaysMode === "natural" ? d.total_days : d.total_workdays))}
|
||||
<span className="text-sm font-normal text-gray-400"> 天</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 主体:两个可视化区块 */}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
onChange={(k) => setActiveTab(k as "capability" | "flow")}
|
||||
items={[
|
||||
{
|
||||
key: "capability",
|
||||
@ -688,7 +749,19 @@ export default function AnalyticsDashboard() {
|
||||
),
|
||||
children: (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-700">人员视图(单台设备耗时 · 点击柱子查看备注)</h3>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">人员视图(单台设备耗时 · 点击柱子查看备注)</h3>
|
||||
<Radio.Group
|
||||
value={totalDaysMode}
|
||||
onChange={e => setTotalDaysMode(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="natural">自然天</Radio.Button>
|
||||
<Radio.Button value="workdays">工作日</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{capabilityLoading && !capability ? (
|
||||
<div className="flex h-[420px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
@ -700,14 +773,18 @@ export default function AnalyticsDashboard() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full overflow-x-auto pb-2 custom-scrollbar">
|
||||
<div style={{ minWidth: (capability?.devices?.length ?? 0) * 140 }}>
|
||||
<BaseEChart
|
||||
option={capabilityOption}
|
||||
height={420}
|
||||
onZrClick={handleZrClick}
|
||||
/>
|
||||
</div>
|
||||
/* 🔧 瀑布流:设备按 CHUNK_SIZE 切块向下追加渲染,告别横向滚动 */
|
||||
<div className="flex flex-col gap-12 pt-4">
|
||||
{capabilityChunks.map((chunk, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
{idx > 0 && <div className="absolute -top-6 left-0 w-full border-t border-dashed border-gray-200" />}
|
||||
<BaseEChart
|
||||
option={getCapabilityOption(chunk.devices, chunk.categories, chunk.series)}
|
||||
height={380}
|
||||
onZrClick={(chart: any, e: any) => handleZrClick(chart, e, chunk.start)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -723,7 +800,19 @@ export default function AnalyticsDashboard() {
|
||||
),
|
||||
children: (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<h3 className="mb-2 text-sm font-semibold text-gray-700">轨迹流转(按人区间 · 并行任务并排显示)</h3>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-700">轨迹流转(按人区间 · 并行任务并排显示)</h3>
|
||||
<Radio.Group
|
||||
value={totalDaysMode}
|
||||
onChange={e => setTotalDaysMode(e.target.value)}
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="natural">自然天</Radio.Button>
|
||||
<Radio.Button value="workdays">工作日</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{flowLoading && !flow ? (
|
||||
<div className="flex h-[380px] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
|
||||
@ -41,6 +41,8 @@ export interface FlowDevice {
|
||||
spec_model: string;
|
||||
lead_time: number; // 设备生命周期总时长(小时)
|
||||
started_at: string; // 设备最早介入时间(T0),MM-DD HH:mm
|
||||
total_days: number; // 设备生产总天数(自然天)
|
||||
total_workdays: number; // 设备生产总天数(工作日)
|
||||
}
|
||||
|
||||
// 时间区间:deviceIndex, startOffset, endOffset, taskName, duration, isMain
|
||||
@ -96,11 +98,13 @@ export interface CapabilityQuery {
|
||||
spec_models?: string[];
|
||||
since?: string;
|
||||
until?: string;
|
||||
mode?: "natural" | "workdays";
|
||||
}
|
||||
|
||||
export interface FlowQuery {
|
||||
product_sns?: string[];
|
||||
spec_models?: string[];
|
||||
mode?: "natural" | "workdays";
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -113,6 +117,7 @@ export async function fetchCapabilityProfile(query: CapabilityQuery): Promise<Ca
|
||||
if (query.spec_models?.length) params.spec_models = query.spec_models.join(",");
|
||||
if (query.since) params.since = query.since;
|
||||
if (query.until) params.until = query.until;
|
||||
if (query.mode) params.mode = query.mode;
|
||||
const { data } = await api.get<CapabilityResponse>("/analytics/capability", { params });
|
||||
return data;
|
||||
}
|
||||
@ -121,6 +126,7 @@ export async function fetchFlowData(query: FlowQuery): Promise<FlowResponse> {
|
||||
const params: Record<string, string> = {};
|
||||
if (query.product_sns?.length) params.product_sns = query.product_sns.join(",");
|
||||
if (query.spec_models?.length) params.spec_models = query.spec_models.join(",");
|
||||
if (query.mode) params.mode = query.mode;
|
||||
const { data } = await api.get<FlowResponse>("/analytics/flow", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -54,6 +54,30 @@ export interface RejectedTask {
|
||||
rejected_at: string | null;
|
||||
}
|
||||
|
||||
export interface UserOperation {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
receive_count: number;
|
||||
transfer_count: number;
|
||||
record_count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface OperationDetail {
|
||||
task_name: string;
|
||||
product_sn: string;
|
||||
material_name: string;
|
||||
remark: string | null;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface WipMatrixRow {
|
||||
spec_model: string;
|
||||
dimension_key: string;
|
||||
count: number;
|
||||
assignees: string[];
|
||||
}
|
||||
|
||||
export interface PersonDevice {
|
||||
product_id: string;
|
||||
serial_number: string;
|
||||
@ -142,6 +166,34 @@ export async function fetchRejectedTasks(since?: string, until?: string): Promis
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchUserOperations(since?: string, until?: string): Promise<UserOperation[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (since) params.since = since;
|
||||
if (until) params.until = until;
|
||||
const { data } = await api.get<UserOperation[]>("/dashboard/user-operations", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchOperationDetail(
|
||||
userId: string, actionType: string, since?: string, until?: string,
|
||||
): Promise<OperationDetail[]> {
|
||||
const params: Record<string, string> = { user_id: userId, action_type: actionType };
|
||||
if (since) params.since = since;
|
||||
if (until) params.until = until;
|
||||
const { data } = await api.get<OperationDetail[]>("/dashboard/user-operations/detail", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchWipMatrix(
|
||||
dimension: "assignee" | "task_name", since?: string, until?: string,
|
||||
): Promise<WipMatrixRow[]> {
|
||||
const params: Record<string, string> = { dimension };
|
||||
if (since) params.since = since;
|
||||
if (until) params.until = until;
|
||||
const { data } = await api.get<WipMatrixRow[]>("/dashboard/wip-matrix", { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchPeopleWorkload(): Promise<PersonWorkload[]> {
|
||||
const { data } = await api.get<PersonWorkload[]>("/dashboard/people-workload");
|
||||
return data;
|
||||
|
||||
@ -24,6 +24,8 @@ export interface ProductResponse {
|
||||
latest_record_assignee_id: string | null;
|
||||
latest_record_assignee_name: string | null;
|
||||
active_duration_hours: number | null;
|
||||
production_days: number;
|
||||
production_days_workdays: number;
|
||||
}
|
||||
|
||||
/** MOM 物料选项 */
|
||||
|
||||
@ -128,13 +128,8 @@ export default {
|
||||
uni.previewImage({ urls: fullUrls, current: index });
|
||||
},
|
||||
confirmDelete(rec) {
|
||||
uni.showModal({
|
||||
title: "删除记录",
|
||||
content: "确定删除这条记录吗?",
|
||||
success: (res) => {
|
||||
if (res.confirm) this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
|
||||
},
|
||||
});
|
||||
// 🚀 交给父页面统一做「双重确认倒计时」,避免原生 showModal 取消行为异常
|
||||
this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
|
||||
},
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
|
||||
@ -133,7 +133,7 @@
|
||||
<text class="popup-title">品质驳回</text>
|
||||
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
|
||||
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :disabled="actionLoading || !rejectReason.trim()" @tap="doReject">确认驳回</button></view>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ confirmLabel('reject', '确认驳回') }}</button></view>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'transfer'">
|
||||
<text class="popup-title">完工转交</text>
|
||||
@ -196,6 +196,19 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 🛡️ 双重确认弹窗(删除/结束分支/驳回 5 秒倒计时防误触) -->
|
||||
<view v-if="confirmDlg.visible" class="overlay" @tap="confirmDlgCancel">
|
||||
<view class="popup" @tap.stop style="max-width:360px;border-radius:16px;">
|
||||
<text class="popup-title">{{ confirmDlg.title }}</text>
|
||||
<text class="popup-hint" style="display:block;margin-bottom:4px;">{{ confirmDlg.content }}</text>
|
||||
<text v-if="confirmDlg.countdown" class="cd-tip">⚠️ 5 秒确认等待中:请核对信息,倒计时结束后确认按钮才可点击</text>
|
||||
<view class="popup-btns">
|
||||
<button class="btn-cancel" @tap="confirmDlgCancel">取消</button>
|
||||
<button class="btn-primary" :class="{ 'btn-counting': confirming === 'dlg' && confirmCount > 0 }" :disabled="confirming === 'dlg' && confirmCount > 0" @tap="confirmDlgConfirm">{{ confirmDlg.countdown ? confirmLabel('dlg', '确认') : '确认' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@ -224,6 +237,11 @@ export default {
|
||||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
|
||||
spawnForm: { assignee_id: "", remark: "" },
|
||||
// 🛡️ 双重确认倒计时:避免误触
|
||||
confirming: "", // 当前倒计时中的操作 key('' = 无)
|
||||
confirmCount: 5, // 剩余秒数
|
||||
confirmTimer: null, // 定时器句柄
|
||||
confirmDlg: { visible: false, title: "", content: "", action: null }, // 确认框类操作弹窗
|
||||
// 💬 留言板
|
||||
messages: [],
|
||||
// 🚀 字典版本号:驱动子组件强制重建,解决 userNameMap 非响应式问题
|
||||
@ -236,7 +254,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
userLabels() { return this.users.map(u => `${u.full_name} (${u.username})`); },
|
||||
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; return this.currentUser.username === this.recordPopup.task.assignee_id; },
|
||||
canDeleteImage() { if (!this.recordPopup.task) return true; if (!this.currentUser) return true; const frozen = ["COMPLETED","REJECTED","ARCHIVED","CANCELED"]; if (frozen.includes(this.recordPopup.task.status)) return false; const assignee = this.recordPopup.task.assignee_id; return assignee == this.currentUserId || assignee == this.currentUsername || (this.currentUser && this.currentUser.id == assignee) || (this.currentUser && this.currentUser.username == assignee); },
|
||||
transferUserName() { const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId); return u ? u.name : ""; },
|
||||
spawnUserName() { const u = this.userOptions.find(u => u.id === this.spawnForm.assignee_id); return u ? u.name : ""; },
|
||||
userGridOptions() { return (this.userOptions || []).map(u => ({ id: u.id, name: u.name })); },
|
||||
@ -271,6 +289,8 @@ export default {
|
||||
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) { this.doQuery(sn); return; } /* 🚀 兜底: 从 taskId 反查 product */ const tid = options.taskId || ""; if (tid) this.doQueryByTask(tid); },
|
||||
// 🚀 onShow 生命周期:每次页面显示时刷新留言板(解决从聊天室退回不更新问题)
|
||||
onShow() { if (this.product?.id) { this.fetchMessages(); } },
|
||||
// 🚀 页面卸载:清理确认倒计时定时器,避免泄漏
|
||||
onUnload() { this.clearConfirm(); },
|
||||
methods: {
|
||||
formatUserName, formatUserAvatar,
|
||||
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
|
||||
@ -317,7 +337,7 @@ export default {
|
||||
closeRecordPopup() { this.recordPopup = { visible: false, task: null }; },
|
||||
async handleChooseImage() { const maxSlots = 9 - (this.recordForm.images.length + this.recordForm.pendingCount); if (maxSlots <= 0) return; const chooseRes = await new Promise((resolve, reject) => { uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject }); }).catch(() => null); if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return; let compressSkipCount = 0; const compressedPaths = []; for (const p of chooseRes.tempFilePaths) { try { const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); }); compressedPaths.push(compressed.tempFilePath); } catch { compressSkipCount++; } } if (!compressedPaths.length) return; this.isUploading = true; this.recordForm.pendingCount += compressedPaths.length; for (const path of compressedPaths) { const url = await this.uploadFile(path); if (url) this.recordForm.images.push(url); this.recordForm.pendingCount--; } this.isUploading = false; },
|
||||
uploadFile(filePath) { return new Promise((resolve) => { uni.uploadFile({ url: getBaseUrl() + "/upload/", filePath, name: "file", success(res) { try { const data = JSON.parse(res.data); resolve(data.url || null); } catch { resolve(null); } }, fail: () => resolve(null) }); }); },
|
||||
removeRecordImage(i) { this.recordForm.images.splice(i, 1); },
|
||||
removeRecordImage(i) { this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", countdown: true, action: () => { this.recordForm.images.splice(i, 1); } }); },
|
||||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, current: i }); },
|
||||
async doSaveRecord() { if (this.isUploading) return; this.recordSaving = true; try { const payload = { remark: this.recordForm.remark.trim(), images: this.recordForm.images }; if (this.recordForm.recordId) await put(`/records/${this.recordForm.recordId}`, payload); else await patch(`/tasks/${this.recordPopup.task.id}/records`, payload); uni.showToast({ title: "已保存", icon: "success" }); this.closeRecordPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.recordSaving = false; } },
|
||||
|
||||
@ -332,12 +352,72 @@ export default {
|
||||
this.spawnForm = { assignee_id: "", remark: "" };
|
||||
},
|
||||
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
|
||||
async doDeleteRecord(record) { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||||
confirmEndBranch(task) { uni.showModal({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, success: (res) => { if (res.confirm) this.doEndBranch(task); } }); },
|
||||
confirmRecall(task) { uni.showModal({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, success: (res) => { if (res.confirm) this.doRecall(task); } }); },
|
||||
async doDeleteRecord(record) { this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", countdown: true, action: async () => { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch (e) { uni.showToast({ title: (e && e.data && e.data.detail) || "删除失败", icon: "none" }); } } }); },
|
||||
confirmEndBranch(task) { this.openConfirmDlg({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, countdown: true, action: () => this.doEndBranch(task) }); },
|
||||
confirmRecall(task) { this.openConfirmDlg({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, countdown: true, action: () => this.doRecall(task) }); },
|
||||
async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||||
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||||
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
|
||||
|
||||
// ═══ 双重确认倒计时(防误触) ═══
|
||||
// 首次点击进入 5 秒倒计时:期间确认按钮虚化禁用(点不了),只有「取消」可用;
|
||||
// 5 秒结束后确认按钮解锁,点击才真正执行。
|
||||
confirmBtn(key, doAction) {
|
||||
if (this.confirming === key) {
|
||||
if (this.confirmCount <= 0) {
|
||||
// 倒计时结束,点击执行
|
||||
this.clearConfirm();
|
||||
doAction();
|
||||
}
|
||||
// 倒计时中:按钮已禁用,忽略点击
|
||||
return;
|
||||
}
|
||||
// 首次点击,开始倒计时
|
||||
this.clearConfirm();
|
||||
this.confirming = key;
|
||||
this.confirmCount = 5;
|
||||
this.confirmTimer = setInterval(() => {
|
||||
this.confirmCount -= 1;
|
||||
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
|
||||
}, 1000);
|
||||
},
|
||||
clearConfirm() {
|
||||
if (this.confirmTimer) clearInterval(this.confirmTimer);
|
||||
this.confirmTimer = null;
|
||||
this.confirming = "";
|
||||
this.confirmCount = 5;
|
||||
},
|
||||
confirmLabel(key, baseText) {
|
||||
if (this.confirming === key && this.confirmCount > 0) return `${baseText} (${this.confirmCount}s)`;
|
||||
return baseText;
|
||||
},
|
||||
// 确认框类操作(结束分支/删除记录/删除图片/撤回转交)
|
||||
// countdown=true 时确认按钮需要 5 秒等待(期间禁用);false 时立即确认
|
||||
openConfirmDlg({ title, content, action, countdown = false }) {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg = { visible: true, title, content, action, countdown };
|
||||
if (countdown) {
|
||||
this.confirming = "dlg";
|
||||
this.confirmCount = 5;
|
||||
this.confirmTimer = setInterval(() => {
|
||||
this.confirmCount -= 1;
|
||||
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
confirmDlgConfirm() {
|
||||
if (this.confirming === "dlg" && this.confirmCount > 0) return; // 倒计时中:忽略
|
||||
if (this.confirming === "dlg") this.clearConfirm();
|
||||
const action = this.confirmDlg.action;
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
if (action) action();
|
||||
},
|
||||
confirmDlgCancel() {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
},
|
||||
async doReceive() { this.actionLoading = true; try { const remark = this.receiveRemark.trim() || undefined; const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark, task_name: this.receiveTaskName }); uni.showToast({ title: "已接收", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
async doReject() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/reject?operator_id=${encodeURIComponent(opId)}`, { reason: this.rejectReason.trim() }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
// 转交 — 互斥选择
|
||||
@ -435,6 +515,8 @@ export default {
|
||||
.btn-primary[disabled] { opacity: 0.5; }
|
||||
.btn-danger { flex: 1; height: 42px; border: none; border-radius: 10px; background: #dc2626; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
|
||||
.btn-danger[disabled] { opacity: 0.5; }
|
||||
.btn-counting { background: #f59e0b !important; }
|
||||
.cd-tip { display: block; text-align: center; font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; padding: 6px 10px; margin: 8px 0 0; line-height: 1.4; }
|
||||
.branch-item { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
|
||||
.branch-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||
.branch-label { font-size: 13px; font-weight: 700; color: #374151; }
|
||||
|
||||
@ -48,6 +48,39 @@
|
||||
<text class="empty-text">暂无历史记录</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 🛡️ 双重确认删除(5 秒倒计时防误触) -->
|
||||
<view v-if="confirmDlg.visible" class="dlg-overlay" @tap="confirmDlgCancel">
|
||||
<view class="dlg-box" @tap.stop>
|
||||
<text class="dlg-title">{{ confirmDlg.title }}</text>
|
||||
<text class="dlg-content">{{ confirmDlg.content }}</text>
|
||||
<text class="dlg-tip">⚠️ 5 秒确认等待中:请核对信息,倒计时结束后确认按钮才可点击</text>
|
||||
<view class="dlg-btns">
|
||||
<button class="dlg-btn dlg-cancel" @tap="confirmDlgCancel">取消</button>
|
||||
<button class="dlg-btn dlg-danger" :disabled="confirming === 'dlg' && confirmCount > 0" @tap="confirmDlgConfirm">{{ confirmLabel('删除') }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ✏️ 编辑记录弹窗 -->
|
||||
<view v-if="editVisible" class="edit-overlay" @tap="closeEditPopup">
|
||||
<view class="edit-popup" @tap.stop>
|
||||
<text class="edit-title">✏️ 编辑记录</text>
|
||||
<textarea v-model="editForm.remark" class="edit-textarea" placeholder="填写备注说明" :maxlength="2000" />
|
||||
<view class="edit-imgs">
|
||||
<view v-for="(img, i) in editForm.images" :key="i" class="edit-img-cell">
|
||||
<image :src="imageUrl(img)" mode="aspectFill" class="edit-img" @tap="previewEditImage(i)" />
|
||||
<text class="edit-img-del" @tap.stop="removeEditImage(i)">✕</text>
|
||||
</view>
|
||||
<view v-for="n in editForm.pendingCount" :key="'p'+n" class="edit-img-cell edit-img-loading"><text class="edit-img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="editForm.images.length + editForm.pendingCount < 9" class="edit-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : '📷 拍照/选图' }}</button>
|
||||
<view class="edit-btns">
|
||||
<button class="edit-btn edit-cancel" @tap="closeEditPopup">取消</button>
|
||||
<button class="edit-btn edit-save" :disabled="editSaving || isUploading" @tap="doSaveEdit">{{ editSaving ? '保存中...' : '保存' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@ -62,6 +95,16 @@ export default {
|
||||
task: null,
|
||||
records: [],
|
||||
currentUser: null,
|
||||
// 🛡️ 双重确认(5 秒倒计时防误触)
|
||||
confirmDlg: { visible: false, title: "", content: "", action: null },
|
||||
confirming: "",
|
||||
confirmCount: 5,
|
||||
confirmTimer: null,
|
||||
// ✏️ 编辑记录弹窗
|
||||
editVisible: false,
|
||||
editForm: { recordId: null, remark: "", images: [], pendingCount: 0 },
|
||||
editSaving: false,
|
||||
isUploading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -86,6 +129,8 @@ export default {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
// 🚀 页面卸载:清理确认倒计时定时器
|
||||
onUnload() { this.clearConfirm(); },
|
||||
methods: {
|
||||
loadCurrentUser() {
|
||||
try {
|
||||
@ -137,29 +182,117 @@ export default {
|
||||
return map[s] || s;
|
||||
},
|
||||
|
||||
// ✏️ 编辑记录:直接在当前页弹窗编辑备注与图片
|
||||
openEditRecord(rec) {
|
||||
// 复用 detail.vue 的编辑流程 — 通过全局事件或直接跳回
|
||||
// 简单方案:在当前页弹窗编辑
|
||||
uni.showToast({ title: "编辑功能请返回详情页操作", icon: "none", duration: 2000 });
|
||||
this.editForm = { recordId: rec.id, remark: rec.remark || "", images: rec.images || [], pendingCount: 0 };
|
||||
this.editVisible = true;
|
||||
this.isUploading = false;
|
||||
},
|
||||
closeEditPopup() {
|
||||
this.editVisible = false;
|
||||
this.editForm = { recordId: null, remark: "", images: [], pendingCount: 0 };
|
||||
},
|
||||
previewEditImage(i) {
|
||||
uni.previewImage({ urls: this.editForm.images.map((u) => this.imageUrl(u)), current: i });
|
||||
},
|
||||
removeEditImage(i) {
|
||||
this.openConfirmDlg({ title: "删除图片", content: "确定删除这张图片吗?", action: () => { this.editForm.images.splice(i, 1); } });
|
||||
},
|
||||
async handleChooseImage() {
|
||||
const maxSlots = 9 - (this.editForm.images.length + this.editForm.pendingCount);
|
||||
if (maxSlots <= 0) return;
|
||||
const chooseRes = await new Promise((resolve, reject) => {
|
||||
uni.chooseImage({ count: maxSlots, sizeType: ["compressed"], sourceType: ["camera", "album"], success: resolve, fail: reject });
|
||||
}).catch(() => null);
|
||||
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
|
||||
const compressedPaths = [];
|
||||
for (const p of chooseRes.tempFilePaths) {
|
||||
try {
|
||||
const compressed = await new Promise((resolve, reject) => { uni.compressImage({ src: p, quality: 60, success: resolve, fail: reject }); });
|
||||
compressedPaths.push(compressed.tempFilePath);
|
||||
} catch {}
|
||||
}
|
||||
if (!compressedPaths.length) return;
|
||||
this.isUploading = true;
|
||||
this.editForm.pendingCount += compressedPaths.length;
|
||||
for (const path of compressedPaths) {
|
||||
const url = await this.uploadFile(path);
|
||||
if (url) this.editForm.images.push(url);
|
||||
this.editForm.pendingCount--;
|
||||
}
|
||||
this.isUploading = false;
|
||||
},
|
||||
uploadFile(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
uni.uploadFile({
|
||||
url: getBaseUrl() + "/upload/", filePath, name: "file",
|
||||
success(res) { try { const data = JSON.parse(res.data); resolve(data.url || null); } catch { resolve(null); } },
|
||||
fail: () => resolve(null),
|
||||
});
|
||||
});
|
||||
},
|
||||
async doSaveEdit() {
|
||||
if (this.isUploading) return;
|
||||
this.editSaving = true;
|
||||
try {
|
||||
const payload = { remark: this.editForm.remark.trim(), images: this.editForm.images };
|
||||
await put(`/records/${this.editForm.recordId}`, payload);
|
||||
uni.showToast({ title: "已保存", icon: "success" });
|
||||
const idx = this.records.findIndex((r) => r.id === this.editForm.recordId);
|
||||
if (idx >= 0) this.records[idx] = { ...this.records[idx], remark: payload.remark, images: payload.images };
|
||||
this.closeEditPopup();
|
||||
} catch (e) {
|
||||
uni.showToast({ title: (e && e.data && e.data.detail) || "保存失败", icon: "none" });
|
||||
} finally {
|
||||
this.editSaving = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete(rec) {
|
||||
uni.showModal({
|
||||
title: "删除记录",
|
||||
content: "确定删除这条记录吗?",
|
||||
confirmColor: "#dc2626",
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await request({ url: `/records/${rec.id}`, method: "DELETE" });
|
||||
uni.showToast({ title: "已删除", icon: "success" });
|
||||
this.records = this.records.filter((r) => r.id !== rec.id);
|
||||
} catch {
|
||||
uni.showToast({ title: "删除失败", icon: "none" });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
this.openConfirmDlg({ title: "删除记录", content: "确定删除这条记录吗?删除后不可恢复。", action: () => this.deleteRecord(rec) });
|
||||
},
|
||||
// ═══ 双重确认倒计时(防误触) ═══
|
||||
openConfirmDlg({ title, content, action }) {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg = { visible: true, title, content, action };
|
||||
this.confirming = "dlg";
|
||||
this.confirmCount = 5;
|
||||
this.confirmTimer = setInterval(() => {
|
||||
this.confirmCount -= 1;
|
||||
if (this.confirmCount <= 0) clearInterval(this.confirmTimer);
|
||||
}, 1000);
|
||||
},
|
||||
clearConfirm() {
|
||||
if (this.confirmTimer) clearInterval(this.confirmTimer);
|
||||
this.confirmTimer = null;
|
||||
this.confirming = "";
|
||||
this.confirmCount = 5;
|
||||
},
|
||||
confirmLabel(baseText) {
|
||||
if (this.confirming === "dlg" && this.confirmCount > 0) return `${baseText} (${this.confirmCount}s)`;
|
||||
return baseText;
|
||||
},
|
||||
confirmDlgConfirm() {
|
||||
if (this.confirming === "dlg" && this.confirmCount > 0) return; // 倒计时中:忽略
|
||||
if (this.confirming === "dlg") this.clearConfirm();
|
||||
const action = this.confirmDlg.action;
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
if (action) action();
|
||||
},
|
||||
confirmDlgCancel() {
|
||||
this.clearConfirm();
|
||||
this.confirmDlg.visible = false;
|
||||
this.confirmDlg.action = null;
|
||||
},
|
||||
async deleteRecord(rec) {
|
||||
try {
|
||||
await request({ url: `/records/${rec.id}`, method: "DELETE" });
|
||||
uni.showToast({ title: "已删除", icon: "success" });
|
||||
this.records = this.records.filter((r) => r.id !== rec.id);
|
||||
} catch {
|
||||
uni.showToast({ title: "删除失败", icon: "none" });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -194,4 +327,37 @@ export default {
|
||||
.empty-timeline { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
|
||||
/* 双重确认弹窗 */
|
||||
.dlg-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; }
|
||||
.dlg-box { width: 80%; max-width: 360px; background: #fff; border-radius: 16px; padding: 28px 20px 20px; box-sizing: border-box; }
|
||||
.dlg-title { display: block; text-align: center; font-size: 17px; font-weight: 700; color: #1f2937; }
|
||||
.dlg-content { display: block; text-align: center; font-size: 14px; color: #6b7280; margin-top: 10px; line-height: 1.5; }
|
||||
.dlg-tip { display: block; text-align: center; font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fcd34d; border-radius: 8px; padding: 6px 10px; margin: 12px 0 0; line-height: 1.4; }
|
||||
.dlg-btns { display: flex; gap: 12px; margin-top: 20px; }
|
||||
.dlg-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 15px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
|
||||
.dlg-btn::after { border: none; }
|
||||
.dlg-cancel { background: #f3f4f6; color: #6b7280; }
|
||||
.dlg-danger { background: #dc2626; color: #fff; }
|
||||
.dlg-danger[disabled] { opacity: 0.5; }
|
||||
|
||||
/* 编辑记录弹窗 */
|
||||
.edit-overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||||
.edit-popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0; padding: 20px 16px 32px; box-sizing: border-box; max-height: 80vh; overflow-y: auto; }
|
||||
.edit-title { display: block; text-align: center; font-size: 16px; font-weight: 700; color: #1f2937; margin-bottom: 12px; }
|
||||
.edit-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; box-sizing: border-box; }
|
||||
.edit-imgs { display: flex; flex-wrap: wrap; margin-top: 10px; }
|
||||
.edit-img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
|
||||
.edit-img { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
|
||||
.edit-img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.edit-img-loading-text { font-size: 36rpx; }
|
||||
.edit-img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx; background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx; text-align: center; line-height: 40rpx; z-index: 2; }
|
||||
.edit-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin-top: 10px; }
|
||||
.edit-upload[disabled] { opacity: 0.5; }
|
||||
.edit-btns { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.edit-btn { flex: 1; height: 42px; line-height: 42px; border-radius: 10px; font-size: 14px; font-weight: 600; text-align: center; box-sizing: border-box; padding: 0; margin: 0; }
|
||||
.edit-btn::after { border: none; }
|
||||
.edit-cancel { background: #f3f4f6; color: #6b7280; }
|
||||
.edit-save { background: #2563eb; color: #fff; }
|
||||
.edit-save[disabled] { opacity: 0.5; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user