Compare commits

...

24 Commits

Author SHA1 Message Date
49cbf22854 fix: 修复or_局部导入遮蔽模块级导入导致UnboundLocalError + Drawer改用size
1. product_service.py: 删除函数内两处 from sqlalchemy import or_
   Python解析器看到函数内的import就把or_当局部变量, 在赋值前使用就报UnboundLocalError
   模块顶部已有 import or_, 函数内无需重复导入

2. AdminDashboard.tsx: Drawer width→size (Antd v6 deprecation)
2026-08-12 15:05:39 +08:00
9b59521746 feat: 看板在制品点击跳转任务全景并自动展开流转树
Dashboard: WipRow 点击改为 nav(/admin/tasks?sn=xxx)
AdminTasksPage: 读取 ?sn= 参数 → 自动搜索 → 产品加载后自动展开流转树
2026-08-12 15:00:37 +08:00
fd289db0fc hotfix: 修复模板字符串转义错误导致的编译失败 2026-08-12 14:54:06 +08:00
e0a5a572cc feat: 扫码页双模式 — 看板跳转显示任务全景(无摄像头), 直接访问保留扫码 2026-08-12 14:51:36 +08:00
c90b77801d feat: 扫码页支持URL参数自动查询 — /scan?sn=xxx 直接展示流转树 2026-08-12 14:50:44 +08:00
ddc34182dd fix: 在制品点击改为应用内跳转扫码详情页(流转树) 2026-08-12 14:45:09 +08:00
729de138cd feat: 在制品附加信息补全 — 设备名称|规格型号|序列号|身份证
后端: WipTask 新增 spec_model, 查询关联 Product.spec_model
前端: 第二行格式改为 物料|规格|序列号:xxx|身份证:xxx
2026-08-12 14:43:20 +08:00
f608f6bf81 fix: SLA阈值调整 — <24h绿 24-48h橙 >48h红 2026-08-12 14:35:07 +08:00
9460a8492a feat: 在制品列表信息层级重构 + SLA预警色 + 点击跳转
1. 信息层级重构:
   主信息: [状态Tag] 任务名 | 负责人: 张三  3h
   附加信息: 物料名 SN:25022 ...A1B2C3  08-12 10:00
   新增后端 material_name 字段, 前端卡片式布局

2. SLA滞留预警色:
   <12h → 绿色(正常) | 12-24h → 橙色(警告) | >24h → 红色加粗(危险)

3. 交互: 点击卡片新窗口打开对应产品扫码详情页
2026-08-12 14:33:56 +08:00
42544d7485 fix: get_wip_tasks 解包错误 — 新增 external_serial 后 rows 变3列但解包仍是2变量 2026-08-12 14:28:00 +08:00
079b65e761 feat: 在制品加业务序列号 + 追溯码改名为身份证
1. 在制品行: 显示 external_serial(业务序列号)
   优先 external_serial → 兜底 product_sn
   旧: 装配 A1B2C3D4  张三
   新: 装配 25022  张三

2. 留言板: 追溯码 → 身份证
   旧: 追溯码: 000000000000000D
   新: 身份证: 000000000000000D

3. 后端: WipTask 新增 external_serial 字段
   关联查询 Product.external_serial
2026-08-12 14:23:23 +08:00
ff7c791d49 fix: 留言板区分业务序列号与系统追溯码
语义修正:
  Product.serial_number  = 16位HEX系统追溯码(如000000000000000D)
  Product.external_serial = 业务产品序列号(如25022)

后端:
  - ProductMessageItem 新增 external_serial 字段
  - search_product_messages 关联查询 Product.external_serial
  - 关键词搜索也支持按 external_serial 匹配

前端显示:
  样品升降台V1J · 序列号: 25022          ← 醒目
  追溯码: 000000000000000D               ← 10px浅灰小字
2026-08-12 14:20:38 +08:00
c14bbeb891 fix: 3个逻辑/UI Bug修复
Bug1 — 任务总数与进度条不匹配:
  tasks_total 从全库历史总数改为 PENDING+WIP+COMPLETED(时段)+REJECTED(时段)
  确保进度条4段之和 = 显示的总数, 不再出现11≠9的缺口

Bug2 — 在制品排序混乱:
  移除DB层order_by, 改为Python统一按duration_hours降序
  无视status, 纯数值排序, 滞留最久排最前
  取limit*2条后在Python中排序再截断

Bug3 — 留言板产品显示割裂:
  [Tag:SN] 物料名 → 物料名 (SN: A1B2C3D4...)
  物料名加粗, SN等宽灰色, 阅读更顺畅
2026-08-12 14:13:26 +08:00
7906b8834a style: 留言板SN标签改用等宽字体更醒目 2026-08-12 14:05:46 +08:00
eb9028dcc6 fix: 任务进度条补全驳回段 + 在制品名称旁显示SN
1. 任务进度条: 3段→4段 (PENDING|WIP|COMPLETED|REJECTED)
   解决总数11但进度条只覆盖9的视觉缺口
   驳回以红色段显示, 拼图完整

2. 在制品行: 产品SN从第二行移到任务名旁边
   旧: 装配  张三  → 新: 装配 A1B2C3D4  张三
   一行看清: 做什么工序+哪台设备+谁在做
2026-08-12 14:04:14 +08:00
32c234fea1 fix: 在制品按滞留时间降序 + 留言抽屉UI美化
1. 在制品排序: nulls_first → nulls_last
   已接收任务按receipt_at升序(最早=滞留最久)排最前
   未接收任务(null)排最后

2. 留言抽屉UI重设计:
   - 卡片式布局: 彩色头像圆+边框阴影+hover效果
   - 头像颜色按姓名首字自动分配7色
   - Input.Search替代普通Input+Search图标
   - 灰色背景区分卡片, 时间格式改为YYYY-MM-DD HH:mm
   - 手动分页按钮替代Antd分页器
2026-08-12 13:59:39 +08:00
ca195192b6 fix: 协同留言时间改为北京时间
根因: ProductMessage.created_at 使用 datetime.utcnow (UTC),
      与其他所有模型使用的 get_beijing_time 不一致。
      看板留言抽屉显示的时间比实际晚8小时。

修复:
  1. message.py: DateTime → DateTime(timezone=True)
     default 从 datetime.utcnow → get_beijing_time
  2. dashboard_service.py: 兜底转换已有数据
     naive UTC → replace(tzinfo=UTC) → astimezone(Beijing)
2026-08-12 13:55:33 +08:00
3b53db03c1 feat: 看板全局时间筛选 + 协同留言抽屉
任务1 — 后端时间快照逻辑:
  get_dashboard_stats 新增 since/until 参数
  PENDING/WIP/总数 → 永远实时快照(忽略时间筛选)
  COMPLETED/REJECTED → 严格按时段过滤
  完成率基于过滤后的COMPLETED计算

任务2 — 前端时间筛选器:
  Radio.Button: 今天 | 近7天 | 近30天 | 自定义
  DatePicker.RangePicker 自定义区间
  切换时重新拉取 /dashboard/stats

任务3 — 协同留言抽屉:
  后端: GET /dashboard/messages(上帝视角全厂数据)
    JOIN Product → serial_number + material_name
    支持 keyword 搜: SN/物料名/留言人/内容
    按 created_at 倒序
  前端: Drawer + Input.Search + List
    点击"留言"数字打开抽屉
    分页展示: 留言人/内容/SN标签/物料名/时间
2026-08-12 13:50:56 +08:00
39377ae5e4 feat: 看板重构 — 在制品看板 + 留言未读数
核心理念转变:
  旧: 最近操作历史(谁做了什么)
  新: 当前在制品状态(谁的活卡了多久)

后端:
  - DashboardStats 新增 unread_messages(协同留言总数)
  - 新增 GET /dashboard/wip-tasks(在制品端点)
  - 按滞留时间升序排列,PENDING/WIP 任务倒计时
  - 自动计算 duration_hours(已滞留小时数)

前端:
  - 品质卡片: 新增留言未读数(紫色)
  - 在制品看板: 替换原最近动态
    每行显示: 状态徽标 | 任务名+负责人+身份证 | 滞留时长
  - 滞留颜色: 48h+红 | 24h+橙 | 8h+黄 | <8h灰
  - 系统通知可点击跳转
2026-08-12 13:40:08 +08:00
00fae01eed fix: 看板4项体验修复 — 时间/身份证/日期筛选/通知跳转
1. 完整16位产品身份证号显示(不再截断)
2. 日期筛选栏: 今天 | 近7天 | 近30天 | 全部
   - 后端 recent-activity 支持 since/until ISO参数
   - 默认展示今天动态,可按时间范围切换
3. 操作人兜底: TaskLog无operator_id时用任务assignee_id
4. 未读通知可点击跳转通知页面 + 快捷入口增加通知中心
2026-08-12 13:35:23 +08:00
c3f3a5e291 fix: 看板动态时间改为北京时间 + 操作人显示中文姓名
1. 时区修复:
   - database.py: PG连接池会话级设置 TimeZone=Asia/Shanghai
   - dashboard_service.py: 格式化时间显式 astimezone(BEIJING_TZ)
   - 兜底: tzinfo为None时默认当作北京时间处理

2. 操作人中文姓名:
   - 收集所有operator_id → 调用mom_cache批量翻译
   - 优先中文姓名 → 英文用户名兜底
   - operator_id为None时不再显示"系统",留空
2026-08-12 13:28:38 +08:00
658fc28b9b feat: 管理看板全面改版 — 增强可读性 + 填充空白区域
后端增强:
  - DashboardStats 新增 tasks_rejected, tasks_rework, unread_notifications
  - 新增 GET /dashboard/recent-activity 最近动态端点
  - 关联 Task + Product 表返回完整动态信息

前端改版 (AdminDashboard.tsx):
  - 4 张概览卡片: 产品流转 | 任务状态 | 品质通知 | 完成率
  - 每张卡片含进度条(百分比标注) + 中文说明
  - SVG 环形图展示任务完成率
  - 最近流转动态时间线 (8条)
  - 快捷入口: 创建产品/任务管理/打印配置/扫码干活
  - 底部说明卡片解释"产品 vs 任务"的区别
  - 响应式网格填满全屏, 无空白区域
2026-08-12 13:25:49 +08:00
8e8d23010f fix: 留言板未读数退出重进后重置 — lastMsgSeenAt持久化到storage 2026-08-12 13:16:11 +08:00
b731144a5a fix: 移动端通知已读状态无法持久化
根因:
  handleCardTap 中 try { await markNotificationRead(); item.is_read = true } catch {}
  静默吞噬了 API 错误。PUT /notifications/{id}/read 调用失败时,本地 is_read
  仍被设为 true,红点消失。下次进入页面重新拉取,服务端仍为未读状态。

修复:
  - API 失败时保留 is_read = false,不更新本地状态
  - 弹出Toast提示用户下拉刷新重试
  - 失败时 return 阻止跳转,避免用户误以为已处理
2026-08-12 12:15:36 +08:00
11 changed files with 831 additions and 79 deletions

View File

@ -1,12 +1,54 @@
"""Dashboard API"""
from fastapi import APIRouter, Depends
"""Dashboard API — 上帝视角(全厂数据,无用户过滤)"""
from datetime import datetime
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.dashboard_service import get_dashboard_stats, DashboardStats
from app.services.dashboard_service import (
get_dashboard_stats, DashboardStats,
get_wip_tasks, WipTask,
search_product_messages, ProductMessageList,
)
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
@router.get("/stats", response_model=DashboardStats)
async def dashboard_stats(db: AsyncSession = Depends(get_db)):
return await get_dashboard_stats(db)
async def dashboard_stats(
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
until: str | None = Query(None, description="截止日期 ISO"),
db: AsyncSession = Depends(get_db),
):
"""
全局统计(上帝视角)。
时间筛选仅影响 COMPLETED / REJECTED 计数;
PENDING / WIP / 总数永远返回实时快照。
"""
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
return await get_dashboard_stats(db, since=since_dt, until=until_dt)
@router.get("/wip-tasks", response_model=list[WipTask])
async def wip_tasks(
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""在制品看板 — 永远实时的 PENDING/WIP 任务"""
return await get_wip_tasks(db, limit)
@router.get("/messages", response_model=ProductMessageList)
async def dashboard_messages(
keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"),
skip: int = Query(0, ge=0),
limit: int = Query(30, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
"""
协同留言搜索(上帝视角 — 全厂所有产品留言)。
关联 Product 表返回 serial_number + material_name
按时间倒序排列。
"""
return await search_product_messages(db, keyword=keyword, skip=skip, limit=limit)

View File

@ -9,6 +9,9 @@ engine = create_async_engine(
max_overflow=10, # 超出 pool_size 时最多再创建的连接数
pool_recycle=3600, # 连接回收时间(秒),防止 MySQL 8 小时断连
pool_pre_ping=True, # 每次取出连接前先 ping 检测可用性
connect_args={
"server_settings": {"TimeZone": "Asia/Shanghai"}, # PG 会话级北京时间
},
)
AsyncSessionLocal = async_sessionmaker(

View File

@ -6,6 +6,7 @@ from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class ProductMessage(Base):
@ -28,5 +29,5 @@ class ProductMessage(Base):
Text, nullable=False, comment="留言内容",
)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, comment="留言时间",
DateTime(timezone=True), default=get_beijing_time, comment="留言时间(北京时间)",
)

View File

@ -1,9 +1,14 @@
"""Dashboard 统计服务"""
from sqlalchemy import select, func
"""Dashboard 统计服务 — 上帝视角(全厂全系统数据,不按用户过滤)"""
from datetime import datetime
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
# ============================================================
# Schemas
# ============================================================
class DashboardStats(BaseModel):
products_total: int
products_pending: int
@ -13,21 +18,95 @@ class DashboardStats(BaseModel):
tasks_pending: int
tasks_in_progress: int
tasks_completed: int
tasks_rejected: int
tasks_rework: int
unread_notifications: int = 0
unread_messages: int = 0
async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
class WipTask(BaseModel):
task_id: str
task_name: str
assignee: str
product_sn: str # 16位HEX身份证
external_serial: str | None # 业务序列号
material_name: str # 设备名称
spec_model: str # 规格型号
status: str
received_at: str
duration_hours: float
class ProductMessageItem(BaseModel):
id: str
content: str
operator_name: str # 留言人中文姓名
product_sn: str # 16位HEX系统追溯码
external_serial: str | None # 业务产品序列号(如 25022
material_name: str # 物料名称
created_at: str # ISO时间字符串
class ProductMessageList(BaseModel):
items: list[ProductMessageItem]
total: int
# ============================================================
# 看板统计(时间快照语义)
# ============================================================
async def get_dashboard_stats(
db: AsyncSession,
since: datetime | None = None,
until: datetime | None = None,
) -> DashboardStats:
"""
上帝视角 — 全厂全系统统计。
时间筛选规则:
- PENDING / WIP / 总数:永远忽略时间筛选,返回实时快照。
- COMPLETED / REJECTED / 完成率:严格按 since~until 过滤(用于时段报表)。
"""
from app.models.product import Product
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED
from app.models.task import (
Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED,
TASK_STATUS_REJECTED,
)
from app.models.notification import Notification
from app.models.message import ProductMessage
# ── 产品(实时快照,不过滤) ──
p_total = await db.scalar(select(func.count(Product.id)))
p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending"))
p_progress = await db.scalar(select(func.count(Product.id)).where(Product.status == "in_progress"))
p_done = await db.scalar(select(func.count(Product.id)).where(Product.status == "completed"))
t_total = await db.scalar(select(func.count(Task.id)))
# ── 任务实时快照PENDING/WIP/返工 — 永远不过滤) ──
t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING))
t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP))
t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED))
t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True)))
# ── 任务已完成/驳回(时间可过滤) ──
t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED)
t_rej_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED)
if since:
t_done_q = t_done_q.where(Task.completed_at >= since)
t_rej_q = t_rej_q.where(Task.completed_at >= since)
if until:
t_done_q = t_done_q.where(Task.completed_at <= until)
t_rej_q = t_rej_q.where(Task.completed_at <= until)
t_done = await db.scalar(t_done_q)
t_rejected = await db.scalar(t_rej_q)
# ── 任务总数 = 实时快照段 + 时间过滤段(确保进度条段总和=总数) ──
t_total = (t_pending or 0) + (t_progress or 0) + (t_done or 0) + (t_rejected or 0)
# ── 通知 & 留言(实时快照) ──
unread_notif = await db.scalar(
select(func.count(Notification.id)).where(Notification.is_read.is_(False))
)
unread_msg = await db.scalar(select(func.count(ProductMessage.id)))
return DashboardStats(
products_total=p_total or 0,
@ -38,4 +117,143 @@ async def get_dashboard_stats(db: AsyncSession) -> DashboardStats:
tasks_pending=t_pending or 0,
tasks_in_progress=t_progress or 0,
tasks_completed=t_done or 0,
tasks_rejected=t_rejected or 0,
tasks_rework=t_rework or 0,
unread_notifications=unread_notif or 0,
unread_messages=unread_msg or 0,
)
# ============================================================
# 在制品看板(永远实时)
# ============================================================
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
stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id)
.where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]))
.limit(limit * 2)
)
result = await db.execute(stmt)
rows = result.all()
raw_ids = list({t.assignee_id for t, *_ in rows if t.assignee_id})
name_map: dict[str, str] = {}
if raw_ids:
from app.services.mom_cache import get_display_names
name_map = get_display_names(raw_ids)
now = get_beijing_time()
wip_list: 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")
else:
hours = 0
recv_str = ""
wip_list.append(WipTask(
task_id=str(task.id),
task_name=task.task_name,
assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"),
product_sn=product_sn or "",
external_serial=ext_sn or None,
material_name=mat_name or "",
spec_model=spec or "",
status=task.status,
received_at=recv_str,
duration_hours=hours,
))
# 统一按滞留时间降序排列(无视 status纯数值排序
wip_list.sort(key=lambda t: t.duration_hours, reverse=True)
return wip_list[:limit]
# ============================================================
# 协同留言搜索(上帝视角 — 全厂)
# ============================================================
async def search_product_messages(
db: AsyncSession,
keyword: str = "",
skip: int = 0,
limit: int = 50,
) -> ProductMessageList:
"""
上帝视角 — 全厂所有产品的协同留言。
关联链: ProductMessage → Product → (material_name, serial_number)
搜索支持: SN码、物料名称、留言人
排序: created_at 倒序(最新在前)
"""
from app.models.message import ProductMessage
from app.models.product import Product
from app.core.time_utils import BEIJING_TZ
# 基础查询 — 同时取出 16位追溯码 + 业务序列号
stmt = (
select(ProductMessage, Product.serial_number, Product.material_name, Product.external_serial)
.join(Product, ProductMessage.product_id == Product.id)
)
# 关键词搜索
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
stmt = stmt.where(or_(
Product.serial_number.ilike(kw),
Product.external_serial.ilike(kw),
Product.material_name.ilike(kw),
ProductMessage.operator_id.ilike(kw),
ProductMessage.content.ilike(kw),
))
# 总数
count_stmt = select(func.count()).select_from(stmt.subquery())
total = await db.scalar(count_stmt) or 0
# 分页 + 排序
stmt = stmt.order_by(ProductMessage.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
rows = result.all()
# 收集 operator_id → 批量翻译中文姓名
raw_ids = list({row[0].operator_id for row in rows if row[0].operator_id})
name_map: dict[str, str] = {}
if raw_ids:
from app.services.mom_cache import get_display_names
name_map = get_display_names(raw_ids)
items: list[ProductMessageItem] = []
for msg, sn, mat_name, ext_sn in rows:
t = msg.created_at
if t:
if t.tzinfo is None:
# 旧数据datetime.utcnownaive UTC → 转北京时间
from datetime import timezone as dt_timezone
t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
else:
t = t.astimezone(BEIJING_TZ)
time_str = t.isoformat() if t else ""
items.append(ProductMessageItem(
id=str(msg.id),
content=msg.content,
operator_name=name_map.get(msg.operator_id, msg.operator_id),
product_sn=sn or "",
external_serial=ext_sn or None,
material_name=mat_name or "",
created_at=time_str,
))
return ProductMessageList(items=items, total=total)

View File

@ -289,7 +289,6 @@ async def update_overall_status(
# SUPER_ADMIN 直接放行
if user_role != "SUPER_ADMIN":
# 检查当前用户是否是该产品主线任务的负责人
from sqlalchemy import or_
main_task_result = await db.execute(
select(Task).where(
Task.product_id == product.id,
@ -416,7 +415,7 @@ async def get_all_products(
overall_names: dict[uuid.UUID, str] = {}
main_assignees: dict[uuid.UUID, str] = {}
if product_ids:
from sqlalchemy import and_, or_, func as sa_func, case as sa_case
from sqlalchemy import and_, func as sa_func, case as sa_case
main_where = and_(
Task.product_id.in_(product_ids),
or_(

View File

@ -1,5 +1,6 @@
/** 扫码干活页 — 编排摄像头扫码 + 手动输入 + 查询结果 */
import { useState, useCallback, Suspense, lazy } from "react";
import { useState, useCallback, useEffect, Suspense, lazy } from "react";
import { useSearchParams } from "react-router-dom";
import { QrCode, Loader2 } from "lucide-react";
import { scanProduct } from "../services/productApi";
import type { ProductScanResponse } from "../types/api";
@ -10,6 +11,7 @@ import QueryResult from "../components/scan/QueryResult";
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
export default function ScanPage() {
const [searchParams] = useSearchParams();
const [cameraActive, setCameraActive] = useState(false);
const [cameraError, setCameraError] = useState<string | null>(null);
const [serialNumber, setSerialNumber] = useState("");
@ -39,6 +41,13 @@ export default function ScanPage() {
}
}, []);
// URL 参数自动查询(支持从看板等外部跳转)
const isEmbedded = !!searchParams.get("sn");
useEffect(() => {
const sn = searchParams.get("sn");
if (sn) doQuery(sn.trim());
}, [searchParams, doQuery]);
/** 扫码回调:提取纯产品身份证 */
const handleScan = useCallback(
(decodedText: string) => {
@ -50,10 +59,34 @@ export default function ScanPage() {
return (
<div className="flex min-h-full flex-col pb-20 pt-safe">
<div className="flex items-center gap-2 px-4 pt-2">
<QrCode className="h-5 w-5 text-blue-600" />
<h2 className="text-lg font-bold text-gray-800"></h2>
</div>
{/* 外部跳转模式:干净的任务全景视图 */}
{isEmbedded ? (
<>
<div className="flex items-center gap-2 px-4 pt-2">
<QrCode className="h-5 w-5 text-blue-600" />
<h2 className="text-lg font-bold text-gray-800"> · </h2>
<span className="ml-auto text-xs text-gray-400"></span>
</div>
{product && (
<div className="mt-3 px-4">
<div className="flex items-center gap-2 rounded-lg bg-white px-4 py-2.5 shadow-sm">
<span className="text-xs text-gray-400"></span>
<span className={`flex-1 text-sm font-bold ${product.overall_status ? "text-blue-600" : "text-red-500"}`}>
{product.overall_status || "未设定"}
</span>
</div>
</div>
)}
<div className="mt-3 px-4">
<QueryResult loading={loading} error={error} product={product} />
</div>
</>
) : (
<>
<div className="flex items-center gap-2 px-4 pt-2">
<QrCode className="h-5 w-5 text-blue-600" />
<h2 className="text-lg font-bold text-gray-800"></h2>
</div>
{/* 摄像头扫码 — 点击启动时才动态加载 html5-qrcode */}
<div className="mt-3 px-4">
@ -101,6 +134,8 @@ export default function ScanPage() {
<div className="mt-3 px-4">
<QueryResult loading={loading} error={error} product={product} />
</div>
</>
)}
</div>
);
}

View File

@ -1,81 +1,462 @@
import { useEffect, useState } from "react";
import { Package, ClipboardList, Loader2, AlertCircle } from "lucide-react";
import { fetchDashboardStats, type DashboardStats } from "../../services/dashboardApi";
import { useEffect, useState, useCallback } from "react";
import {
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
RefreshCw, Loader2, AlertCircle, ArrowRight, Clock, MessageCircle,
} 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,
type DashboardStats, type WipTask, type ProductMessageItem,
} from "../../services/dashboardApi";
function StatCard({
label,
total,
pending,
progress,
done,
icon: Icon,
}: {
label: string;
total: number;
pending: number;
progress: number;
done: number;
icon: React.ComponentType<{ className?: string }>;
const { RangePicker } = DatePicker;
// ─── 时间筛选选项 ─────────────────────────────────────────
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() };
// today
return { since: since.toISOString() };
}
// ─── 进度条支持3或4段 ─────────────────────────────────
function ProgressBar({ a, b, c, total, labels, d }: {
a: number; b: number; c: number; total: number;
labels: [string, string, string];
d?: number;
}) {
if (total === 0) return <div className="py-4 text-center text-xs text-gray-400"></div>;
const pct = (n: number) => Math.round((n / total) * 100);
const segs = [
{ n: a, color: "bg-amber-400", label: labels[0] },
{ n: b, color: "bg-blue-500", label: labels[1] },
{ n: c, color: "bg-emerald-500", label: labels[2] },
...(d !== undefined ? [{ n: d, color: "bg-red-400", label: "已驳回" }] : []),
].filter(s => s.n > 0);
return (
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<Icon className="h-5 w-5 text-blue-600" />
<h3 className="font-semibold text-gray-800">{label}</h3>
<span className="ml-auto text-2xl font-bold text-gray-800">{total}</span>
<div>
<div className="flex h-3 overflow-hidden rounded-full bg-gray-100">
{segs.map((s, i) => (
<div key={i} className={`${s.color} transition-all duration-500`}
style={{ width: `${(s.n / total) * 100}%` }} />
))}
</div>
<div className="flex h-2 overflow-hidden rounded-full bg-gray-100">
{pending > 0 && (
<div className="bg-yellow-400" style={{ width: `${(pending / Math.max(total, 1)) * 100}%` }} />
)}
{progress > 0 && (
<div className="bg-blue-500" style={{ width: `${(progress / Math.max(total, 1)) * 100}%` }} />
)}
{done > 0 && (
<div className="bg-green-500" style={{ width: `${(done / Math.max(total, 1)) * 100}%` }} />
)}
</div>
<div className="mt-3 flex gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-yellow-400" /> {pending}</span>
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-blue-500" /> {progress}</span>
<span className="flex items-center gap-1"><span className="inline-block h-2 w-2 rounded-full bg-green-500" /> {done}</span>
<div className="mt-2 flex flex-wrap gap-3 text-xs text-gray-500">
{segs.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label} {s.n}{pct(s.n)}%
</span>
))}
</div>
</div>
);
}
// ─── 滞留时间 SLA 预警颜色 ────────────────────────────────
function durationColor(h: number) {
if (h >= 48) return "text-red-600 bg-red-100 font-bold";
if (h >= 24) return "text-orange-600 bg-orange-50";
return "text-emerald-600 bg-emerald-50";
}
function durationLabel(h: number) {
if (h >= 48) return `${Math.round(h / 24)}`;
if (h >= 24) return `${Math.round(h / 24)}`;
if (h >= 1) return `${h}小时`;
return `${Math.round(h * 60)}分钟`;
}
function WipRow({ t }: { t: WipTask }) {
const nav = useNavigate();
const handleClick = () => {
if (t.product_sn) {
nav(`/admin/tasks?sn=${t.product_sn}`);
}
};
return (
<div
onClick={handleClick}
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-blue-200 hover:shadow-md"
>
{/* 主信息行:状态 + 任务名 + 负责人 */}
<div className="flex items-center gap-2">
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${
t.status === "WIP" ? "bg-blue-100 text-blue-700" : "bg-amber-100 text-amber-700"
}`}>
{t.status === "WIP" ? "进行中" : "待接收"}
</span>
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
<span className="text-xs text-gray-500">|</span>
<span className="text-xs text-gray-500 shrink-0">: {t.assignee}</span>
{/* 滞留时间 */}
<span className={`ml-auto shrink-0 rounded-md px-2 py-0.5 text-xs font-bold ${durationColor(t.duration_hours)}`}>
<Clock className="mr-0.5 inline h-3 w-3" />
{durationLabel(t.duration_hours)}
</span>
</div>
{/* 附加信息行 */}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
<span className="text-gray-300">|</span>
<span>{t.spec_model || "无规格"}</span>
<span className="text-gray-300">|</span>
<span>: {t.external_serial || "未录入"}</span>
<span className="text-gray-300">|</span>
<span className="font-mono text-gray-300">: {t.product_sn}</span>
{t.received_at && <span className="ml-auto">{t.received_at}</span>}
</div>
</div>
);
}
// ─── 留言列表项(卡片式) ─────────────────────────────────
const AVATAR_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#10b981", "#ef4444", "#06b6d4"];
function MsgRow({ m }: { m: ProductMessageItem }) {
const t = m.created_at ? dayjs(m.created_at).format("YYYY-MM-DD HH:mm") : "";
const initial = (m.operator_name || "?").charAt(0);
const color = AVATAR_COLORS[initial.charCodeAt(0) % AVATAR_COLORS.length];
return (
<div className="mb-3 rounded-xl border border-gray-100 bg-white p-4 shadow-sm transition-shadow hover:shadow-md">
<div className="flex gap-3">
{/* 头像 */}
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: color }}
>
{initial}
</div>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-semibold text-gray-800">{m.operator_name}</span>
<span className="shrink-0 text-xs text-gray-400">{t}</span>
</div>
<p className="mb-2 text-sm leading-relaxed text-gray-600">{m.content}</p>
<div className="flex flex-col gap-0.5">
{m.material_name && (
<span className="text-xs">
<span className="font-semibold text-gray-700">{m.material_name}</span>
<span className="text-gray-400"> · : </span>
<span className="font-medium text-gray-600">{m.external_serial || "未录入"}</span>
</span>
)}
<span className="text-[10px] text-gray-300 font-mono">: {m.product_sn}</span>
</div>
</div>
</div>
</div>
);
}
// ─── 主组件 ───────────────────────────────────────────────
export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchDashboardStats()
.then(setStats)
.catch(() => setError("加载统计数据失败,请确认后端已启动"))
// 时间筛选
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
// 留言抽屉
const [msgDrawerOpen, setMsgDrawerOpen] = useState(false);
const [msgKeyword, setMsgKeyword] = useState("");
const [msgData, setMsgData] = useState<ProductMessageItem[]>([]);
const [msgTotal, setMsgTotal] = useState(0);
const [msgLoading, setMsgLoading] = useState(false);
const navigate = useNavigate();
// ── 加载主数据 ──
const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | null) => {
setLoading(true);
const { since, until } = rangeToParams(key, range);
Promise.all([
fetchDashboardStats(since, until),
fetchWipTasks(20),
])
.then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); })
.catch(() => setError("加载失败,请确认后端已启动"))
.finally(() => setLoading(false));
}, []);
if (loading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 animate-spin text-blue-500" /></div>;
useEffect(() => { loadData(dateKey, customRange); }, [dateKey, customRange]);
// ── 加载留言 ──
const loadMessages = useCallback(async (kw: string) => {
setMsgLoading(true);
try {
const res = await fetchDashboardMessages(kw, 0, 50);
setMsgData(res.items);
setMsgTotal(res.total);
} catch { /* ignore */ }
finally { setMsgLoading(false); }
}, []);
const openMsgDrawer = () => {
setMsgDrawerOpen(true);
setMsgKeyword("");
loadMessages("");
};
const onMsgSearch = (value: string) => {
setMsgKeyword(value);
loadMessages(value);
};
// ── 加载态 ──
if (loading && !stats) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
</div>
);
}
if (error) {
return <div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"><AlertCircle className="h-4 w-4" />{error}</div>;
if (error || !stats) {
return (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4" />{error || "数据为空"}
<button onClick={() => loadData(dateKey, customRange)} className="ml-auto text-blue-600 underline"></button>
</div>
);
}
if (!stats) return null;
return (
<div>
<div className="mb-6">
<h2 className="text-xl font-bold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">PC端与移动端共享同一后台数据</p>
<div className="space-y-6">
{/* ═══ 页头 + 时间筛选器 ═══ */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-gray-800">📊 </h2>
<p className="mt-0.5 text-sm text-gray-400">
= =
</p>
</div>
<div className="flex items-center gap-3">
{/* 时间筛选 */}
<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={["开始", "结束"]}
/>
)}
<button onClick={() => loadData(dateKey, customRange)}
className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs text-gray-500 hover:bg-gray-100">
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="grid gap-5 md:grid-cols-2">
<StatCard label="产品统计" total={stats.products_total} pending={stats.products_pending} progress={stats.products_in_progress} done={stats.products_completed} icon={Package} />
<StatCard label="任务统计" total={stats.tasks_total} pending={stats.tasks_pending} progress={stats.tasks_in_progress} done={stats.tasks_completed} icon={ClipboardList} />
{/* 提示:已完结受时间筛选 */}
{dateKey !== "today" && (
<div className="rounded-lg bg-blue-50 px-3 py-1.5 text-[11px] text-blue-600">
📐 <strong>/</strong>
</div>
)}
{/* ═══ 4 卡片 ═══ */}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{/* 产品流转 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
<h3 className="text-sm font-semibold text-gray-700">📦 </h3>
</div>
<p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> </span></p>
<p className="mb-3 text-[11px] text-gray-400"></p>
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
total={stats.products_total} labels={["待流转", "流转中", "已完成"]} />
</div>
{/* 任务状态 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-purple-600" />
<h3 className="text-sm font-semibold text-gray-700">📋 </h3>
</div>
<p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> </span></p>
<p className="mb-3 text-[11px] text-gray-400">PENDING/WIP | COMPLETED </p>
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected} />
</div>
{/* 品质 & 留言 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" />
<h3 className="text-sm font-semibold text-gray-700"> </h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-red-50 p-3 text-center">
<p className="text-xl font-bold text-red-600">{stats.tasks_rejected + stats.tasks_rework}</p>
<p className="text-[11px] text-red-500">/</p>
</div>
{/* 留言 — 可点击打开抽屉 */}
<button
onClick={openMsgDrawer}
className="rounded-lg bg-purple-50 p-3 text-center hover:bg-purple-100 transition-colors border-0 cursor-pointer"
>
<p className="text-xl font-bold text-purple-600">{stats.unread_messages}</p>
<p className="flex items-center justify-center gap-1 text-[11px] text-purple-500">
<MessageCircle className="h-3 w-3" />
</p>
</button>
</div>
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<button onClick={() => navigate("/notifications")}
className="flex items-center gap-1 text-xs text-gray-500 hover:text-blue-600">
<Bell className="h-3.5 w-3.5" />
{stats.unread_notifications > 0 ? `(${stats.unread_notifications})` : ""}
</button>
</div>
</div>
{/* 完成率 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-emerald-600" />
<h3 className="text-sm font-semibold text-gray-700"> </h3>
</div>
<div className="flex items-end gap-4">
<p className="text-3xl font-bold text-emerald-600">
{stats.tasks_total > 0 ? Math.round((stats.tasks_completed / stats.tasks_total) * 100) : 0}%
</p>
<p className="text-sm text-gray-400">{stats.tasks_completed}/{stats.tasks_total}</p>
</div>
<svg viewBox="0 0 100 100" className="mx-auto mt-4 h-20 w-20 -rotate-90">
<circle cx="50" cy="50" r="40" fill="none" stroke="#f3f4f6" strokeWidth="10" />
<circle cx="50" cy="50" r="40" fill="none" stroke="#10b981" strokeWidth="10"
strokeDasharray={`${stats.tasks_total > 0 ? (stats.tasks_completed / stats.tasks_total) * 251 : 0} 251`}
strokeLinecap="round" />
</svg>
<p className="mt-1 text-center text-[10px] text-gray-400"></p>
</div>
</div>
{/* ═══ 在制品 + 快捷入口 ═══ */}
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded-xl bg-white p-5 shadow-sm lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
<Clock className="h-4 w-4 text-orange-500" />
</h3>
<span className="text-[11px] text-gray-400"> {wipTasks.length} </span>
</div>
{wipTasks.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-400">🎉 </div>
) : (
<div>
<div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400">
<span className="w-14 shrink-0"></span>
<span className="flex-1"> · </span>
<span className="w-16 shrink-0 text-right"></span>
</div>
{wipTasks.map((t, i) => <WipRow key={i} t={t} />)}
</div>
)}
</div>
{/* 快捷入口 */}
<div className="rounded-xl bg-white p-5 shadow-sm">
<h3 className="mb-4 text-sm font-semibold text-gray-700"> </h3>
<div className="space-y-2">
<button onClick={() => navigate("/admin/products")}
className="flex w-full items-center justify-between rounded-lg bg-blue-50 px-4 py-3 text-left text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors">
<span>📦 </span><ArrowRight className="h-4 w-4" />
</button>
<button onClick={() => navigate("/admin/tasks")}
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={() => navigate("/notifications")}
className="flex w-full items-center justify-between rounded-lg bg-blue-50 px-4 py-3 text-left text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors">
<span>🔔 </span><ArrowRight className="h-4 w-4" />
</button>
<button onClick={openMsgDrawer}
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>
</div>
</div>
</div>
{/* ═══ 留言抽屉 ═══ */}
<Drawer
title={<span className="text-base font-bold">💬 <span className="font-normal text-gray-400"> · {msgTotal} </span></span>}
open={msgDrawerOpen}
onClose={() => setMsgDrawerOpen(false)}
size="large"
styles={{ body: { padding: 16, background: "#f8fafc" } }}
>
<Input.Search
placeholder="搜索 SN码 / 物料名称 / 留言人 / 内容"
value={msgKeyword}
onChange={e => setMsgKeyword(e.target.value)}
onSearch={onMsgSearch}
allowClear
onClear={() => onMsgSearch("")}
enterButton
className="mb-4"
/>
{msgLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
</div>
) : msgData.length === 0 ? (
<div className="py-16 text-center text-sm text-gray-400">
{msgKeyword ? "未找到匹配的留言" : "暂无留言记录"}
</div>
) : (
<div>
{msgData.map(m => <MsgRow key={m.id} m={m} />)}
{/* 简易分页 */}
{msgTotal > 30 && (
<div className="mt-4 flex items-center justify-center gap-2">
{Array.from({ length: Math.ceil(msgTotal / 30) }, (_, i) => (
<button
key={i}
onClick={() => {
fetchDashboardMessages(msgKeyword, i * 30, 30)
.then(res => { setMsgData(res.items); setMsgTotal(res.total); })
.catch(() => {});
}}
className="rounded-md border border-gray-200 px-3 py-1 text-xs text-gray-600 hover:bg-gray-100"
>
{i + 1}
</button>
))}
</div>
)}
</div>
)}
</Drawer>
</div>
);
}

View File

@ -1,5 +1,6 @@
/** 任务全景 Dashboard — 按订单聚合 + 关键词搜索 + 状态筛选 */
import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X,
@ -35,6 +36,7 @@ interface OrderGroup {
export default function AdminTasksPage() {
const { toast } = useToast();
const { user: currentUser } = useAuth();
const [searchParams] = useSearchParams();
// 搜索 & 筛选
const [keyword, setKeyword] = useState("");
@ -57,6 +59,9 @@ export default function AdminTasksPage() {
const [submitting, setSubmitting] = useState(false);
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
// 从看板跳转: ?sn=xxx → 自动搜索 + 自动展开流转树
const autoSn = searchParams.get("sn") || "";
// ---- 加载产品列表(始终拉全量,不做服务端状态过滤) ----
async function loadProducts(kw: string) {
setLoading(true);
@ -66,14 +71,27 @@ export default function AdminTasksPage() {
if (kw.trim()) params.keyword = kw.trim();
const { data } = await api.get<ProductResponse[]>("/products/", { params });
setProducts(data);
return data;
} catch {
setError("加载产品列表失败,请检查后端服务");
return [];
} finally {
setLoading(false);
}
}
useEffect(() => { loadProducts(keyword); }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (autoSn) {
setKeyword(autoSn);
loadProducts(autoSn).then((data) => {
// 产品加载完成后自动展开流转树
const found = data.find((p: ProductResponse) => p.serial_number === autoSn);
if (found) toggleProductTree(found.serial_number);
});
} else {
loadProducts(keyword);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function handleSearch(e?: React.FormEvent) {
e?.preventDefault();

View File

@ -9,9 +9,58 @@ export interface DashboardStats {
tasks_pending: number;
tasks_in_progress: number;
tasks_completed: number;
tasks_rejected: number;
tasks_rework: number;
unread_notifications: number;
unread_messages: number;
}
export async function fetchDashboardStats(): Promise<DashboardStats> {
const { data } = await api.get<DashboardStats>("/dashboard/stats");
export interface WipTask {
task_id: string;
task_name: string;
assignee: string;
product_sn: string;
external_serial: string | null;
material_name: string;
spec_model: string;
status: string;
received_at: string;
duration_hours: number;
}
export interface ProductMessageItem {
id: string;
content: string;
operator_name: string;
product_sn: string;
external_serial: string | null;
material_name: string;
created_at: string;
}
export interface ProductMessageList {
items: ProductMessageItem[];
total: number;
}
export async function fetchDashboardStats(since?: string, until?: string): Promise<DashboardStats> {
const params: Record<string, string> = {};
if (since) params.since = since;
if (until) params.until = until;
const { data } = await api.get<DashboardStats>("/dashboard/stats", { params });
return data;
}
export async function fetchWipTasks(limit = 20): Promise<WipTask[]> {
const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } });
return data;
}
export async function fetchDashboardMessages(
keyword = "", skip = 0, limit = 30,
): Promise<ProductMessageList> {
const { data } = await api.get<ProductMessageList>("/dashboard/messages", {
params: { keyword, skip, limit },
});
return data;
}

View File

@ -102,7 +102,13 @@ function formatTime(t) {
async function handleCardTap(item) {
if (!item.is_read) {
try { await markNotificationRead(item.id); item.is_read = true; } catch {}
try {
await markNotificationRead(item.id);
item.is_read = true;
} catch {
uni.showToast({ title: "标记已读失败,请下拉刷新重试", icon: "none", duration: 2000 });
return;
}
}
// 🚀 优先使用 product_serial_number兜底从 content 中解析
let sn = item.product_serial_number || "";

View File

@ -346,10 +346,10 @@ export default {
// 派发协助分支
async doSpawn() { this.actionLoading = true; try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${this.actionPopup.task.id}/spawn?operator_id=${encodeURIComponent(opId)}`, { task_name: "待确认", assignee_id: this.spawnForm.assignee_id, remark: this.spawnForm.remark.trim() || undefined }); uni.showToast({ title: "协助分支已派发", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
// 💬 留言板
async fetchMessages() { if (!this.product?.id) return; try { const res = await get(`/products/${this.product.id}/messages`); this.messages = res || []; this.scrollToBottom(); } catch (e) { console.error('获取留言失败', e); } },
async fetchMessages() { if (!this.product?.id) return; try { const res = await get(`/products/${this.product.id}/messages`); this.messages = res || []; const key = `msg_seen_${this.product.id}`; this.lastMsgSeenAt = uni.getStorageSync(key) || ''; this.scrollToBottom(); } catch (e) { console.error('获取留言失败', e); } },
async submitMessage() { const content = this.newMsgText.trim(); if (!content) return; this.newMsgText = ''; const tempId = 'temp_' + Date.now(); const tempMsg = { id: tempId, operator_id: this.currentUsername || this.currentUserId || '?', content, created_at: new Date().toISOString() }; this.messages.push(tempMsg); this.scrollToBottom(); try { await post(`/products/${this.product.id}/messages`, { operator_id: this.currentUsername || this.currentUserId, content }); this.fetchMessages(); } catch (e) { uni.showToast({ title: '发送失败', icon: 'none' }); this.messages = this.messages.filter(m => m.id !== tempId); } },
openMsgDrawer() { this.showMsgDrawer = true; this.$nextTick(() => { this.scrollToBottom(); }); },
closeMsgDrawer() { const last = this.messages[this.messages.length - 1]; this.lastMsgSeenAt = last ? last.created_at : new Date().toISOString(); this.showMsgDrawer = false; },
closeMsgDrawer() { const last = this.messages[this.messages.length - 1]; this.lastMsgSeenAt = last ? last.created_at : new Date().toISOString(); if (this.product?.id && last) { uni.setStorageSync(`msg_seen_${this.product.id}`, this.lastMsgSeenAt); } this.showMsgDrawer = false; },
scrollToBottom() { this.$nextTick(() => { this.bottomMsgId = 'msg-bottom'; }); },
fmtMsgTime(d) { if (!d) return ''; const dt = new Date(d); const pad = (n) => String(n).padStart(2, '0'); return `${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
// 🖨️ 打印标签:调用后端 API 发送打印指令