Compare commits
34 Commits
d757c7985b
...
1.0应用
| Author | SHA1 | Date | |
|---|---|---|---|
| d729a19b86 | |||
| 42c883fe7d | |||
| ee25b8fd23 | |||
| d666bbd4ed | |||
| e707f3673d | |||
| 1e6c006360 | |||
| 2539bede3c | |||
| dcc92b223e | |||
| 6bc6531e6a | |||
| a5fa789657 | |||
| 1e46d6edd9 | |||
| 5cd08b551a | |||
| a7d1c53b1f | |||
| c7f046a993 | |||
| 7fca315ff4 | |||
| eef4e5b72d | |||
| 44ca09ae22 | |||
| 242a6d5463 | |||
| 93e4714019 | |||
| ab75b9613f | |||
| 7b8084a43d | |||
| 6b3f14e8fd | |||
| 0d1e45e3fb | |||
| 71e660b428 | |||
| f22eae315f | |||
| 31d085881b | |||
| 3238e4819a | |||
| db3bafcc66 | |||
| 2de95799c6 | |||
| b40340ea55 | |||
| b9f9b897a1 | |||
| 3568d17865 | |||
| b088d25df9 | |||
| 7b7dbbb0d8 |
@ -0,0 +1,45 @@
|
||||
"""add_product_lifecycle_phase
|
||||
|
||||
Revision ID: i1j2k3l4m5n6
|
||||
Revises: h1h2h3h4h5h6
|
||||
Create Date: 2026-09-14
|
||||
|
||||
产品生命周期阶段(lifecycle_phase)
|
||||
--------------------------------
|
||||
用于区分「生产阶段的测试(发货测试)」与「出库后再次返厂的售后维修」。
|
||||
|
||||
存量数据一律回填 'PRODUCTION'(历史产品视为从未出库回流);
|
||||
新数据由 SQLAlchemy 端 default 提供,双保险。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "i1j2k3l4m5n6"
|
||||
down_revision: Union[str, None] = "h1h2h3h4h5h6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"products",
|
||||
sa.Column(
|
||||
"lifecycle_phase",
|
||||
sa.String(20),
|
||||
nullable=False,
|
||||
server_default="PRODUCTION",
|
||||
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
|
||||
),
|
||||
)
|
||||
# 存量数据回填:历史产品从未出库回流,统一视为生产阶段
|
||||
op.execute("UPDATE products SET lifecycle_phase = 'PRODUCTION' WHERE lifecycle_phase IS NULL")
|
||||
# 加索引:售后设备排查(按阶段筛选)会高频用到
|
||||
op.create_index(
|
||||
"ix_products_lifecycle_phase", "products", ["lifecycle_phase"], unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_products_lifecycle_phase", table_name="products")
|
||||
op.drop_column("products", "lifecycle_phase")
|
||||
@ -5,8 +5,10 @@ from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import get_db
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
from app.services.dashboard_service import (
|
||||
get_dashboard_stats, DashboardStats,
|
||||
get_my_stats, MyStats,
|
||||
get_wip_tasks, WipTask,
|
||||
get_completed_tasks, CompletedTask,
|
||||
get_rejected_tasks, RejectedTask,
|
||||
@ -22,6 +24,18 @@ from app.services.dashboard_service import (
|
||||
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
|
||||
|
||||
|
||||
def _parse_bound(value: str | None) -> datetime | None:
|
||||
"""解析 ISO 时间边界。
|
||||
|
||||
裸时间(无时区偏移)按**北京时间**解释 —— 否则会被当作服务器本地时间,
|
||||
在边界上整体偏移 8 小时,出现「选了今日却统计到昨天下午」这类错位。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
dt = datetime.fromisoformat(value)
|
||||
return dt.replace(tzinfo=BEIJING_TZ) if dt.tzinfo is None else dt
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def dashboard_stats(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
@ -39,12 +53,32 @@ async def dashboard_stats(
|
||||
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),
|
||||
@router.get("/my-stats", response_model=MyStats)
|
||||
async def my_stats(
|
||||
assignee_id: str = Query(..., description="负责人ID(移动端传当前登录用户 username)"),
|
||||
since: str | None = Query(None, description="起始时间 ISO(含时区偏移,如 2026-09-01T00:00:00+08:00);缺省=本月 1 日"),
|
||||
until: str | None = Query(None, description="截止时间 ISO(含时区偏移);缺省=此刻"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""在制品看板 — 永远实时的 PENDING/WIP 任务"""
|
||||
"""
|
||||
一线工人个人效能 — 移动端「工作统计」页,支持自选时段。
|
||||
|
||||
- 生产战绩(完成 / 被驳回 / 参与产品)按本人名下任务归因;
|
||||
- 操作统计(接收 / 转交 / 上传备注)与 PC /dashboard/user-operations 严格同口径,
|
||||
工人自查的数与主管看到的面板对得上。
|
||||
|
||||
⚠️ since/until 必须带时区偏移。移动端发的是北京时间 (+08:00),
|
||||
不带偏移的裸字符串会被当成"本地时间"导致边界偏移 8 小时。
|
||||
"""
|
||||
return await get_my_stats(db, assignee_id, since=_parse_bound(since), until=_parse_bound(until))
|
||||
|
||||
|
||||
@router.get("/wip-tasks", response_model=list[WipTask])
|
||||
async def wip_tasks(
|
||||
limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
|
||||
return await get_wip_tasks(db, limit)
|
||||
|
||||
|
||||
|
||||
52
backend/app/api/v1/endpoints/screen.py
Normal file
52
backend/app/api/v1/endpoints/screen.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""大屏 API — 面向管理层**日常运营与督导**的轻量聚合接口
|
||||
|
||||
视角:当月吞吐 / 当前卡点 / 系统活跃度。
|
||||
|
||||
与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表),
|
||||
/screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.screen_service import (
|
||||
get_monthly_metrics, MonthlyMetrics,
|
||||
get_wip_distribution, WipDistributionResponse,
|
||||
get_active_users, ActiveUsersResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/screen", tags=["大屏统计"])
|
||||
|
||||
|
||||
@router.get("/monthly-metrics", response_model=MonthlyMetrics)
|
||||
async def monthly_metrics(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
当月吞吐 — 大屏顶部四张数字卡。
|
||||
|
||||
返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。
|
||||
统计区间为北京时间当月 1 日 00:00 至此刻。
|
||||
"""
|
||||
return await get_monthly_metrics(db)
|
||||
|
||||
|
||||
@router.get("/wip-distribution", response_model=WipDistributionResponse)
|
||||
async def wip_distribution(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。
|
||||
|
||||
返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。
|
||||
"""
|
||||
return await get_wip_distribution(db)
|
||||
|
||||
|
||||
@router.get("/active-users", response_model=ActiveUsersResponse)
|
||||
async def active_users(
|
||||
top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。
|
||||
|
||||
桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。
|
||||
"""
|
||||
return await get_active_users(db, top_n=top_n)
|
||||
@ -2,6 +2,10 @@
|
||||
|
||||
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
|
||||
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
|
||||
|
||||
同一条入站通道还承担【撤回出库】的强制回滚:MOM 把误点出库的设备物理
|
||||
回滚到仓库时,Track 必须被动跟随 MOM 的权威物理状态(详见
|
||||
_mom_inbound_revoke 上方的特权通道说明)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -14,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.lifecycle import sync_product_status
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
@ -22,11 +27,101 @@ router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
|
||||
|
||||
|
||||
class MomInboundPayload(BaseModel):
|
||||
"""MOM 仓储系统确认接收入库的回调载荷"""
|
||||
"""MOM 仓储系统确认接收入库 / 撤回出库的回调载荷"""
|
||||
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
|
||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||
operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
|
||||
inbound_time: datetime | None = None # 入库确认时间
|
||||
# ↓ MOM 侧一直在发、此前被 Pydantic 静默丢弃的字段。撤回信号靠它们识别。
|
||||
event: str | None = None # 事件名,如 inbound.created / outbound.revoked
|
||||
action: str | None = None # 显式动作指令,如 revoke_outbound
|
||||
source_table: str | None = None # stock_product / stock_semi
|
||||
|
||||
|
||||
# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。
|
||||
# MOM 侧的字段命名尚未冻结,故刻意宽松:revoke_outbound / outbound.revoked /
|
||||
# rollback_outbound 都能命中,避免因对方改个词就整条链路失联。
|
||||
_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel")
|
||||
|
||||
|
||||
def _is_outbound_revoke(payload: MomInboundPayload) -> bool:
|
||||
"""payload 是否携带**显式**的撤回出库信号。
|
||||
|
||||
注意:返回 False 不代表「不是撤回」——MOM 也可能不加任何标记、直接以
|
||||
常规 inbound.created 重推。那种隐式信号由调用方用「产品此刻是否处于
|
||||
已出库」兜底判定(见 mom_inbound_webhook 里的 was_outbound)。
|
||||
"""
|
||||
for raw in (payload.action, payload.event):
|
||||
token = (raw or "").strip().lower()
|
||||
if token and any(word in token for word in _OUTBOUND_REVOKE_TOKENS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _pick_warehouse_log_task(db: AsyncSession, product: Product) -> Task | None:
|
||||
"""挑一条挂日志的任务:优先「在库」任务,其次该产品最新任务,都没有则 None。"""
|
||||
task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if task is None:
|
||||
task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
return task
|
||||
|
||||
|
||||
async def _match_inbound_product(
|
||||
db: AsyncSession, payload: MomInboundPayload, *, allow_outbound: bool,
|
||||
) -> Product | None:
|
||||
"""按 serial_number(优先)或 sku 匹配产品。
|
||||
|
||||
allow_outbound=False:只认「当前挂在虚拟仓库池」的产品(常规入库的既有语义)。
|
||||
allow_outbound=True :额外放行「已出库」产品 —— 出库回调会把 current_location_id
|
||||
置为 None,若仍用原条件,撤回信号必然失配并静默 return matched=False,
|
||||
造成 MOM 认为货已回库、Track 却永远停在「已出库」的数据脑裂。
|
||||
"""
|
||||
location_cond = Product.current_location_id == "virtual_warehouse"
|
||||
where_cond = (
|
||||
or_(
|
||||
location_cond,
|
||||
Product.overall_status == "已出库",
|
||||
Product.status == "OUTBOUND",
|
||||
)
|
||||
if allow_outbound
|
||||
else location_cond
|
||||
)
|
||||
|
||||
if payload.serial_number:
|
||||
return (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.serial_number == payload.serial_number,
|
||||
Product.external_serial == payload.serial_number,
|
||||
),
|
||||
where_cond,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if payload.sku:
|
||||
return (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(Product.spec_model == payload.sku, where_cond)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/mom-inbound")
|
||||
@ -35,92 +130,127 @@ async def mom_inbound_webhook(
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""MOM 仓储系统确认接收产品入库后回调本接口。
|
||||
"""MOM 确认接收入库 / 撤回出库后回调本接口。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 用 serial_number(优先)或 sku 查询当前位于 virtual_warehouse 的产品;
|
||||
命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
||||
- 未命中返回 200(MOM 可能入库了非 Track 生产的物料,直接忽略)。
|
||||
- 常规入库:用 serial_number(优先)或 sku 匹配「当前位于 virtual_warehouse」
|
||||
的产品,命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
||||
- 撤回出库:MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。
|
||||
- 未命中返回 200(MOM 可能操作了非 Track 生产的物料,直接忽略)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 按 serial_number / external_serial(双字段联合)或 sku 匹配"当前位于仓库"的产品 ──
|
||||
product = None
|
||||
if payload.serial_number:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.serial_number == payload.serial_number,
|
||||
Product.external_serial == payload.serial_number,
|
||||
),
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
elif payload.sku:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.spec_model == payload.sku,
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
explicit_revoke = _is_outbound_revoke(payload)
|
||||
|
||||
# ── 匹配产品 ──
|
||||
# 常规入库保持严格匹配;撤回(显式标记,或带 serial 可精确定位)才放宽到已出库产品。
|
||||
# 刻意不给 sku 兜底也无条件放宽:同型号可能有多台,放宽后可能误标到别的设备。
|
||||
product = await _match_inbound_product(
|
||||
db, payload, allow_outbound=explicit_revoke or bool(payload.serial_number),
|
||||
)
|
||||
|
||||
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
|
||||
if product is None:
|
||||
return {"ok": True, "matched": False}
|
||||
|
||||
# ── 标记"已实收"闭环 ──
|
||||
# 隐式撤回:payload 没带任何标记,但产品此刻正处于「已出库」。
|
||||
# 对一台已发货的设备来说,任何入库回调都只能意味着「货回来了」。
|
||||
was_outbound = (
|
||||
(product.overall_status or "").strip() == "已出库"
|
||||
or (product.status or "").strip().upper() == "OUTBOUND"
|
||||
)
|
||||
is_revoke = explicit_revoke or was_outbound
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# ★ 特权通道 — MOM 的物理状态同步优先级最高,强制覆写、不受任何内部守卫约束
|
||||
#
|
||||
# 与 task_service.py 的【绝对物理终态保护】(PHYSICAL_TERMINAL_OVERALL,
|
||||
# task_service.py:115-127) 方向刻意相反:那套保护约束的是「车间内部流转
|
||||
# 不许用工序名抹掉物理终态」;而本接口是物理事实的**权威来源**——MOM 说
|
||||
# 货已回到仓库,Track 必须无条件跟随。
|
||||
#
|
||||
# ⚠️ 后续维护者:不要在此处添加 _is_physical_terminal / 状态互斥 / 仅当
|
||||
# 状态为 X 才允许覆写 之类的校验。那会让设备永远卡在「已出库」,
|
||||
# 与 MOM 账面对不上——正是本次要消灭的数据脑裂。
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
changed = False
|
||||
|
||||
# 1) 宏观状态强制覆写为「已入库」(撤回时从「已出库」拉回)
|
||||
if product.overall_status != "已入库":
|
||||
product.overall_status = "已入库"
|
||||
# 双字段同步:整体状态与产品状态保持一致(前端徽标依赖 status)
|
||||
product.status = "ARCHIVED"
|
||||
changed = True
|
||||
|
||||
# 记录仓库接收日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态)
|
||||
inbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if inbound_task is None:
|
||||
inbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
# 2) 物理位置强制回滚到虚拟仓库池(出库回调曾把它置为 None)
|
||||
if product.current_location_id != "virtual_warehouse":
|
||||
product.current_location_id = "virtual_warehouse"
|
||||
changed = True
|
||||
|
||||
# 3) 双字段同步:lifecycle.py 约定凡改写 overall_status 必调一次。
|
||||
# (原实现在这里硬编码 product.status="ARCHIVED",绕过了约定,一并纠正)
|
||||
# ⚠️ 必须把 status 的变化也计入 changed:否则当 overall_status / location
|
||||
# 本来就已经正确时,这一处纠偏会因为 changed 保持 False 而永远不提交。
|
||||
prev_status = product.status
|
||||
sync_product_status(product)
|
||||
if product.status != prev_status:
|
||||
changed = True
|
||||
|
||||
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
|
||||
log_task = await _pick_warehouse_log_task(db, product)
|
||||
if log_task is not None:
|
||||
if is_revoke:
|
||||
signal = payload.action or payload.event or "inbound.created(隐式)"
|
||||
remark = (
|
||||
f"MOM 撤回出库 → 强制回滚:宏观状态已入库、"
|
||||
f"位置已回到 virtual_warehouse(信号: {signal})"
|
||||
)
|
||||
action_type = "warehouse_outbound_revoked"
|
||||
else:
|
||||
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
||||
remark = f"MOM 仓储系统确认接收入库(inbound_time: {time_str})"
|
||||
action_type = "warehouse_inbound"
|
||||
|
||||
if inbound_task is not None:
|
||||
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
||||
db.add(TaskLog(
|
||||
task_id=inbound_task.id,
|
||||
task_id=log_task.id,
|
||||
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
||||
action_type="warehouse_inbound",
|
||||
remark=f"MOM 仓储系统确认接收入库(inbound_time: {time_str})",
|
||||
action_type=action_type,
|
||||
remark=remark,
|
||||
))
|
||||
changed = True
|
||||
|
||||
# ── 动态生成"扫码入库"主线任务节点 + 操作日志(流转树最底部长出入库节点) ──
|
||||
if await _append_warehouse_task(db, product, "扫码入库", "通过 MOM 系统扫码入库完成"):
|
||||
# ── 动态生成主线任务节点 + 操作日志(流转树最底部长出节点) ──
|
||||
if is_revoke:
|
||||
# 撤回必须留痕:否则流转树末节点仍是「扫码出库」,而产品徽标已是
|
||||
# 「已入库」,这种可见的自相矛盾会让车间不敢信这套数据。
|
||||
#
|
||||
# ⚠️ 节点名里的「(重新入库)」不是装饰,是 [必须保留] 的契约:
|
||||
# product_service.py:166-174 的 _has_warehouse_task() 用**子串**判定
|
||||
# 仓库节点("在库" in task_name or "入库" in task_name)。而
|
||||
# 「撤回出库」四个字里只有"出库"、不含"入库",会让它判定为"无仓库任务",
|
||||
# 进而给 location==virtual_warehouse 的产品注入一个假的「已完成 /
|
||||
# 待仓库扫码」虚拟节点(product_service.py:237-242 的情况 A)——
|
||||
# 该设备明明已入库且在仓库里,树尾却显示待收货。
|
||||
# 补上「重新入库」后关键字命中,虚拟节点不再注入。
|
||||
appended = await _append_warehouse_task(
|
||||
db, product, "撤回出库(重新入库)", "MOM 撤回出库,设备已物理回滚至仓库",
|
||||
)
|
||||
else:
|
||||
appended = await _append_warehouse_task(
|
||||
db, product, "扫码入库", "通过 MOM 系统扫码入库完成",
|
||||
)
|
||||
if appended:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True, "matched": True, "serial_number": product.serial_number}
|
||||
return {
|
||||
"ok": True,
|
||||
"matched": True,
|
||||
"serial_number": product.serial_number,
|
||||
"revoked": is_revoke,
|
||||
}
|
||||
|
||||
|
||||
async def _append_warehouse_task(
|
||||
@ -256,24 +386,16 @@ async def mom_outbound_webhook(
|
||||
product.status = "OUTBOUND"
|
||||
changed = True
|
||||
|
||||
# 🚚 同步出清厂内位置(与 _recalc_product_location 的「货发走就离场」对齐):
|
||||
# 本回调的匹配条件之一就是 current_location_id == "virtual_warehouse",
|
||||
# 即设备此刻还挂在仓库池里。既然 MOM 已确认发货,就不该再显示为厂内仓库/工位。
|
||||
# 置空后产品列表的「当前位置」显示为「—」。
|
||||
if product.current_location_id is not None:
|
||||
product.current_location_id = None
|
||||
changed = True
|
||||
|
||||
# 记录出库日志(优先"在库"任务,其次该产品最新任务)
|
||||
outbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if outbound_task is None:
|
||||
outbound_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
outbound_task = await _pick_warehouse_log_task(db, product)
|
||||
|
||||
if outbound_task is not None:
|
||||
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"
|
||||
|
||||
@ -16,6 +16,7 @@ from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
from app.api.v1.endpoints.holidays import router as holidays_router
|
||||
from app.api.v1.endpoints.webhooks import router as webhooks_router
|
||||
from app.api.v1.endpoints.external_products import router as external_products_router
|
||||
from app.api.v1.endpoints.screen import router as screen_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -35,3 +36,4 @@ api_router.include_router(analytics_router)
|
||||
api_router.include_router(holidays_router)
|
||||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(external_products_router)
|
||||
api_router.include_router(screen_router)
|
||||
|
||||
196
backend/app/core/lifecycle.py
Normal file
196
backend/app/core/lifecycle.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""生命周期阶段(Lifecycle Phase)词汇表与选项隔离 — 单一事实来源
|
||||
|
||||
生产制造(PRODUCTION)与售后回流(AFTER_SALES)各自拥有一套合法的
|
||||
宏观状态 / 工序名。回流设备绝不允许被重新排产回「备货 / 生产」等前期环节,
|
||||
本模块集中定义两套词表并提供校验,前端各端复制同一份口径即可。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
|
||||
「测试 / 维修」。这样报表、看板、导出无需 JOIN lifecycle_phase 就能区分,
|
||||
也不会出现"同一个词两种含义"。
|
||||
2. 选定售后专属工序 = 设备进入售后生命周期,单向不可回退。
|
||||
这条规则同时覆盖两类设备:
|
||||
- 已出库后被重新派发任务(由 task_service 的 outbound 判定捕获)
|
||||
- 无任何历史记录、直接走售后流程的老设备(首次选定售后工序即判定)
|
||||
3. 「待确认」与仓库虚拟节点是建单占位符,不参与阶段校验,否则会卡死建单流程。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# ============================================================
|
||||
# 生命周期阶段
|
||||
# ============================================================
|
||||
LIFECYCLE_PRODUCTION = "PRODUCTION" # 生产制造阶段(发货前)
|
||||
LIFECYCLE_AFTER_SALES = "AFTER_SALES" # 已出库后再次回流返厂(售后)
|
||||
|
||||
PHASE_LABELS = {
|
||||
LIFECYCLE_PRODUCTION: "生产制造阶段",
|
||||
LIFECYCLE_AFTER_SALES: "售后回流阶段",
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 售后专属工序名
|
||||
# ============================================================
|
||||
STEP_SHIP_TEST = "发货测试" # 出厂 / 发货前测试
|
||||
STEP_AFTER_SALES_REPAIR = "售后维修" # 售后返修本体
|
||||
|
||||
# 售后区独立成列所用的两个工序名(前端分列 / 大屏返厂统计仍以它为准)
|
||||
AFTER_SALES_ONLY_STEPS = frozenset({STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR})
|
||||
|
||||
# 🔧 工序语义细分(2026-09-17)——「售后专属」不等于「回流返厂」:
|
||||
# · 出厂质检(发货测试):设备可能压根没回厂,只是补做发货前测试。
|
||||
# 它【不】构成回流信号,选中它不应把设备永久烙进售后生命周期。
|
||||
# · 明确修机指令(售后维修):设备确实是回厂返修 → 判定回流。
|
||||
# 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
|
||||
# AFTER_SALES 烙印(售后死锁),而「已出库」的物理终态也被抹掉。
|
||||
OUTBOUND_QC_STEPS = frozenset({STEP_SHIP_TEST})
|
||||
AFTER_SALES_REPAIR_STEPS = frozenset({STEP_AFTER_SALES_REPAIR})
|
||||
|
||||
# ============================================================
|
||||
# 各阶段合法的工序名 / 宏观状态名
|
||||
# ============================================================
|
||||
|
||||
# ── 生产制造阶段 ──
|
||||
# 🔧 2026-09-17 补入「发货测试」:出厂质检现在被允许在生产阶段执行
|
||||
# (设备的 lifecycle_phase 不再因选中它而翻成售后)。若不补,
|
||||
# _enforce_step_isolation 会把「已出库设备的发货测试」直接 400 掉。
|
||||
PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库")
|
||||
PRODUCTION_OVERALL_STEPS = (
|
||||
"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库",
|
||||
STEP_SHIP_TEST,
|
||||
)
|
||||
|
||||
# ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
|
||||
AFTER_SALES_TASK_STEPS = (STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库")
|
||||
AFTER_SALES_OVERALL_STEPS = (
|
||||
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "待仓库收货", "已入库", "已出库",
|
||||
)
|
||||
|
||||
# 仅属于生产制造阶段的工序名 — 售后设备出现即视为"跨阶段误排"。
|
||||
# 注意:「在库 / 已入库 / 已出库」是两阶段通用的,不在此列。
|
||||
PRODUCTION_ONLY_STEPS = frozenset({"备货", "生产", "测试", "维修"})
|
||||
|
||||
# 建单占位工序名 — 建单时为「待确认」,接收时才由操作员选定真实工序
|
||||
PLACEHOLDER_STEPS = frozenset({"待确认"})
|
||||
|
||||
# 仓库虚拟节点标记(转交入库时作为 assignee 传递)
|
||||
VIRTUAL_WAREHOUSE = "virtual_warehouse"
|
||||
|
||||
# 两阶段并集 — 用于"完全非法取值"的第一道粗筛
|
||||
ALL_OVERALL_STEPS = frozenset(PRODUCTION_OVERALL_STEPS) | frozenset(AFTER_SALES_OVERALL_STEPS)
|
||||
|
||||
_ALLOWED_BY_PHASE = {
|
||||
LIFECYCLE_PRODUCTION: frozenset(PRODUCTION_OVERALL_STEPS),
|
||||
LIFECYCLE_AFTER_SALES: frozenset(AFTER_SALES_OVERALL_STEPS),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 查询 / 校验
|
||||
# ============================================================
|
||||
|
||||
def phase_label(phase: str | None) -> str:
|
||||
"""阶段的中文名(用于报错文案)"""
|
||||
return PHASE_LABELS.get(phase or "", PHASE_LABELS[LIFECYCLE_PRODUCTION])
|
||||
|
||||
|
||||
def allowed_steps(phase: str | None) -> tuple[str, ...]:
|
||||
"""该阶段合法的全部工序/状态名"""
|
||||
if (phase or "") == LIFECYCLE_AFTER_SALES:
|
||||
return AFTER_SALES_OVERALL_STEPS
|
||||
return PRODUCTION_OVERALL_STEPS
|
||||
|
||||
|
||||
def is_placeholder_step(step: str | None) -> bool:
|
||||
"""建单占位符(待确认)/ 仓库虚拟节点 — 不做阶段校验
|
||||
|
||||
否则 create_task 会被「待确认」卡住,转交入库会被
|
||||
「🏭 入库 (virtual_warehouse)」卡住,整条流程直接断掉。
|
||||
"""
|
||||
if not step:
|
||||
return True
|
||||
return step in PLACEHOLDER_STEPS or VIRTUAL_WAREHOUSE in step
|
||||
|
||||
|
||||
def resolve_phase_for_step(current_phase: str | None, step: str | None) -> str:
|
||||
"""选定【明确修机指令】→ 设备进入售后生命周期(单向,不可回退)
|
||||
|
||||
⚠️ 只有「售后维修」触发,出厂质检(发货测试)【不】触发。
|
||||
「已出库的设备补做发货测试」不代表它回厂返修;若在此烙印,
|
||||
设备会被永久打成售后(该标志单向不可回退),这就是售后死锁的第二个入口。
|
||||
|
||||
这是"无历史记录的老设备"进入售后阶段的入口:首次选定「售后维修」即判定回流。
|
||||
"""
|
||||
if step and step.strip() in AFTER_SALES_REPAIR_STEPS:
|
||||
return LIFECYCLE_AFTER_SALES
|
||||
return current_phase or LIFECYCLE_PRODUCTION
|
||||
|
||||
|
||||
def is_step_allowed(phase: str | None, step: str | None) -> bool:
|
||||
"""工序名在该生命周期阶段下是否合法"""
|
||||
if is_placeholder_step(step):
|
||||
return True
|
||||
allowed = _ALLOWED_BY_PHASE.get(
|
||||
phase or LIFECYCLE_PRODUCTION, _ALLOWED_BY_PHASE[LIFECYCLE_PRODUCTION],
|
||||
)
|
||||
return step.strip() in allowed
|
||||
|
||||
|
||||
# 老数据兼容:售后设备的工序曾沿用生产阶段的「测试 / 维修」写法
|
||||
# (售后独立工序名是后加的)。统计/看板展示时折算到售后区的独立工序名,
|
||||
# 否则售后设备的历史工序会混进生产区的同名工序,导致统计失真。
|
||||
AFTER_SALES_STEP_ALIAS = {
|
||||
"测试": STEP_SHIP_TEST,
|
||||
"维修": STEP_AFTER_SALES_REPAIR,
|
||||
}
|
||||
|
||||
|
||||
def normalize_after_sales_step(phase: str | None, step: str | None) -> str | None:
|
||||
"""售后回流设备的历史工序名归一 —— 折算到售后区的独立工序名。
|
||||
|
||||
仅对 AFTER_SALES 生效;生产设备的「测试 / 维修」原样保留。
|
||||
统计口径(WIP 矩阵 / 下钻)必须与展示口径一致,故集中在模块内。
|
||||
"""
|
||||
if (phase or "") != LIFECYCLE_AFTER_SALES or not step:
|
||||
return step
|
||||
return AFTER_SALES_STEP_ALIAS.get(step.strip(), step)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# overall_status(中文宏观状态) ⇄ product.status(英文状态码)
|
||||
# ============================================================
|
||||
# 这两个字段必须始终同步。历史缺陷:receive_task / transfer_task 只改
|
||||
# overall_status 不改 status,导致设备出库回流后 status 永远停在 OUTBOUND,
|
||||
# 统计口径被污染(WIP 矩阵曾因此把正在做「发货测试」的设备吞进「已出库」列)。
|
||||
# 所有改写 overall_status 的地方一律调 sync_product_status()。
|
||||
|
||||
# 终态映射;不在表内的(备货/生产/测试/维修/发货测试/售后维修…)都是活跃工序 → WIP
|
||||
OVERALL_TO_PRODUCT_STATUS = {
|
||||
"已入库": "ARCHIVED",
|
||||
"在库": "ARCHIVED",
|
||||
"已出库": "OUTBOUND",
|
||||
"待仓库收货": "COMPLETED",
|
||||
}
|
||||
|
||||
DEFAULT_PRODUCT_STATUS = "WIP"
|
||||
|
||||
|
||||
def overall_to_product_status(overall: str | None) -> str:
|
||||
"""中文宏观状态 → product.status 状态码。
|
||||
|
||||
活跃工序(含售后「发货测试 / 售后维修」)不在映射表里,统一落到 WIP ——
|
||||
这正是消除「status 残留在 OUTBOUND」的关键。
|
||||
"""
|
||||
if not overall:
|
||||
return DEFAULT_PRODUCT_STATUS
|
||||
return OVERALL_TO_PRODUCT_STATUS.get(overall.strip(), DEFAULT_PRODUCT_STATUS)
|
||||
|
||||
|
||||
def sync_product_status(product) -> str:
|
||||
"""把 product.status 与 overall_status 对齐,返回落定的状态码。
|
||||
|
||||
刻意用 duck-typing 而非导入 ORM 模型,避免 core 层反向依赖 models。
|
||||
调用点:任何改写 product.overall_status 之后都必须调一次。
|
||||
"""
|
||||
product.status = overall_to_product_status(getattr(product, "overall_status", None))
|
||||
return product.status
|
||||
@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.core.lifecycle import LIFECYCLE_PRODUCTION
|
||||
|
||||
|
||||
class Product(Base):
|
||||
@ -64,6 +65,13 @@ class Product(Base):
|
||||
overall_status: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库",
|
||||
)
|
||||
# 生命周期阶段 — 生产制造 vs 出库后返厂售后。
|
||||
# 设备出库后再次被派发任务 = 回流返厂,此处切为 AFTER_SALES 且不再回退。
|
||||
lifecycle_phase: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=LIFECYCLE_PRODUCTION,
|
||||
server_default=LIFECYCLE_PRODUCTION, index=True,
|
||||
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
@ -53,7 +53,17 @@ class ProductResponse(BaseModel):
|
||||
current_location_name: str | None = None
|
||||
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
|
||||
overall_status: str | None = None
|
||||
# 【当前工序】—— 只反映"此刻在做什么",绝不拿历史工序冒充。
|
||||
# · 有活跃主干任务(WIP/PENDING) → 该任务工序名
|
||||
# · 否则产品处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态(表达"货在哪")
|
||||
# · 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
|
||||
# ⚠️ 必须与 overall_status 分开:ProductResponse.overall_status 会被"最新主干任务名"
|
||||
# 覆盖(见 product_service 的 overall_names),于是已 COMPLETED 的历史工序
|
||||
# (扫码出库 / 测试 / 发货测试…)会被当成"当前工序"长期展示。
|
||||
current_step: str = ""
|
||||
status: str
|
||||
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||
lifecycle_phase: str = "PRODUCTION"
|
||||
created_at: datetime
|
||||
# 🔧 最新动态 — 该产品活跃任务的最新记录
|
||||
latest_record_time: datetime | None = None
|
||||
@ -86,6 +96,8 @@ class ProductScanResponse(BaseModel):
|
||||
current_location_id: str | None = None
|
||||
overall_status: str | None = None
|
||||
status: str
|
||||
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||
lifecycle_phase: str = "PRODUCTION"
|
||||
created_at: datetime
|
||||
top_level_tasks: list[TaskSummaryResponse] = []
|
||||
task_tree: list[TaskResponse] = []
|
||||
|
||||
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -49,14 +49,43 @@ class SubtaskCreate(BaseModel):
|
||||
|
||||
|
||||
class TaskRejectRequest(BaseModel):
|
||||
"""品质驳回请求"""
|
||||
"""品质驳回请求 — 驳回原因必填;异常图片【选填】
|
||||
(编号错误、工序选错等场景无需拍照举证,故不强制传图)"""
|
||||
reason: str = Field(..., min_length=1, max_length=500, description="驳回原因(必填)")
|
||||
images: list[str] = Field(
|
||||
default_factory=list, max_length=9,
|
||||
description="异常图片 URL 列表(选填,可传空数组,最多 9 张)",
|
||||
)
|
||||
|
||||
@field_validator("reason", mode="before")
|
||||
@classmethod
|
||||
def _strip_reason(cls, v):
|
||||
"""先 strip 再交给 min_length 校验:否则纯空格(' ')能凑够长度绕过必填。
|
||||
顺带保证落库的 Task.reject_reason / TaskRecord.remark 不带首尾空白。
|
||||
非字符串原样返回,让 Pydantic 抛出正常的类型错误。"""
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
@field_validator("images")
|
||||
@classmethod
|
||||
def _validate_images(cls, v: list[str]) -> list[str]:
|
||||
"""剥离空串(前端可能提交 [''] 之类的占位),并做总长度上限校验,
|
||||
避免落库时才撞上 TaskRecord.images 的 String(4000) 上限。图片选填,允许为空。"""
|
||||
urls = [u.strip() for u in v if u and u.strip()]
|
||||
if sum(len(u) for u in urls) > 3500:
|
||||
raise ValueError("异常图片 URL 总长度超限,请减少图片数量")
|
||||
return urls
|
||||
|
||||
|
||||
class TaskTransferBranch(BaseModel):
|
||||
"""裂变分支"""
|
||||
task_name: str = Field(..., max_length=200, description="工序名称")
|
||||
assignees: list[str] = Field(..., min_length=1, description="接收人列表")
|
||||
assignees: list[str] = Field(
|
||||
default_factory=list, min_length=0,
|
||||
description=(
|
||||
"接收人列表。允许为空数组,但仅当 finish_directly=True 时合法——"
|
||||
"空分支不产生任何下游任务(见 TaskTransferRequest 的校验)。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TaskTransferRequest(BaseModel):
|
||||
@ -65,6 +94,29 @@ class TaskTransferRequest(BaseModel):
|
||||
next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称")
|
||||
next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表")
|
||||
note: str | None = Field(None, description="交接备注")
|
||||
finish_directly: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"直接完结:闭环当前任务但【不产生任何下游任务】。"
|
||||
"它只是一个任务闭环动作,【不改动】产品的 overall_status —— "
|
||||
"已出库的设备完结后依然是已出库(不入库,也不会变成「待仓库收货」)。"
|
||||
"权限:仅 SUPER_ADMIN / SUPERVISOR 可调用,其余角色 403。"
|
||||
"置 True 时忽略 next_tasks / next_assignees。"
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reject_silent_empty_branch(self):
|
||||
"""空 assignees 分支会让「转交」静默退化成「直接完结」——任务闭环了却没人接手,
|
||||
是个丢件级隐患。想直接完结必须显式传 finish_directly=true,不能靠漏填凑合。"""
|
||||
if self.finish_directly:
|
||||
return self
|
||||
if any(not b.assignees for b in (self.next_tasks or [])):
|
||||
raise ValueError(
|
||||
"分支 assignees 不能为空;若意图是「直接完结该任务」,"
|
||||
"请改为传 finish_directly=true"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TaskRecordCreate(BaseModel):
|
||||
|
||||
@ -4,6 +4,8 @@ from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.lifecycle import normalize_after_sales_step
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Schemas
|
||||
@ -24,6 +26,30 @@ class DashboardStats(BaseModel):
|
||||
unread_messages: int = 0
|
||||
|
||||
|
||||
class MyStats(BaseModel):
|
||||
"""一线工人个人效能 — /dashboard/my-stats 桥接口径。
|
||||
|
||||
与 DashboardStats 的「上帝视角」不同,这里强制过滤到本人;
|
||||
供移动端「个人中心 → 工作统计」使用,支持自选时段。
|
||||
|
||||
两组指标口径:
|
||||
「生产战绩」按 Task.assignee_id 归因(我名下的活);
|
||||
「操作统计」与 PC /dashboard/user-operations 完全一致,
|
||||
便于工人自查看到的数与管理端面板对得上。
|
||||
"""
|
||||
assignee_id: str
|
||||
# ── 生产战绩(所选时段内)──
|
||||
tasks_completed: int = 0 # 完成数
|
||||
tasks_rejected: int = 0 # 被驳回数(本人名下任务被驳回)
|
||||
products_touched: int = 0 # 参与产品数(按 product_id 去重)
|
||||
|
||||
# ── 操作统计(与 PC /dashboard/user-operations 同一口径,所选时段内)──
|
||||
receive_count: int = 0 # 接收次数
|
||||
transfer_count: int = 0 # 转交次数
|
||||
record_count: int = 0 # 上传备注次数
|
||||
op_total: int = 0 # 操作总计 = 接收 + 转交 + 上传备注
|
||||
|
||||
|
||||
class WipTask(BaseModel):
|
||||
task_id: str
|
||||
task_name: str
|
||||
@ -96,6 +122,8 @@ class WipMatrixDetailRow(BaseModel):
|
||||
material_name: str = "" # 产品名称
|
||||
spec_model: str = ""
|
||||
task_status: str = "" # 当前状态 WIP/PENDING/COMPLETED/ARCHIVED/OUTBOUND
|
||||
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||
lifecycle_phase: str = "PRODUCTION"
|
||||
assignee_id: str = ""
|
||||
assignee: str = "" # 负责人中文名
|
||||
duration_hours: float = 0.0 # 已在该工序滞留时长(小时)
|
||||
@ -154,6 +182,59 @@ class ProductMessageList(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 看板「当下性」闸门 —— 返工任务可见性
|
||||
# ============================================================
|
||||
|
||||
def _rework_visible_condition(
|
||||
month_start: datetime,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
):
|
||||
"""构造返工任务在看板上的可见条件。
|
||||
|
||||
⚠️ 卡片数字(get_dashboard_stats.tasks_rework)与明细抽屉
|
||||
(get_rejected_tasks 的 kind="rework")**必须共用这一份**,否则会出现
|
||||
「按钮显示 2、点开却是共 0 条」的自相矛盾。
|
||||
|
||||
背景(2026-09 整改):原实现里返工查询是 `is_rework=True AND status != REJECTED`
|
||||
的纯实时快照,没有任何状态/时间边界,导致上季度甚至更早的「已返工完成」记录
|
||||
无限期挂在品质异常区持续报警 —— 报警永远不消,等于没有报警。
|
||||
|
||||
规则:
|
||||
- 未完结卡点(PENDING / WIP):无论发生在何时都展示。没解决的异常必须
|
||||
一直可见,这正是看板的价值所在。
|
||||
- 已了结的(COMPLETED / ARCHIVED / CANCELED …):只保留「本月 1 日
|
||||
(北京时间)之后」的,并进一步尊重调用方所选时段。
|
||||
|
||||
用 status.notin_(开放态) 而不是枚举已了结态:将来新增状态时会自动落进
|
||||
「已了结」一侧(受时间约束),而不是凭空消失或无限期留存。
|
||||
"""
|
||||
from datetime import timezone
|
||||
from sqlalchemy import and_
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
|
||||
open_states = [TASK_STATUS_PENDING, TASK_STATUS_WIP]
|
||||
open_cond = Task.status.in_(open_states)
|
||||
|
||||
# 已了结项的时间下限 = max(本月 1 日, 调用方给的 since):
|
||||
# 既保证「绝不早于本月」,也不会越过用户选定的时段。
|
||||
# since 可能是裸时间(无时区),先补成 UTC 再比较,否则 max() 会抛 TypeError。
|
||||
floor = month_start
|
||||
if since is not None:
|
||||
floor = max(floor, since if since.tzinfo else since.replace(tzinfo=timezone.utc))
|
||||
|
||||
closed_cond = and_(
|
||||
Task.status.notin_(open_states),
|
||||
Task.completed_at.isnot(None),
|
||||
Task.completed_at >= floor,
|
||||
)
|
||||
if until is not None:
|
||||
closed_cond = and_(closed_cond, Task.completed_at <= until)
|
||||
|
||||
return or_(open_cond, closed_cond)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 看板统计(时间快照语义)
|
||||
# ============================================================
|
||||
@ -177,32 +258,78 @@ async def get_dashboard_stats(
|
||||
)
|
||||
from app.models.notification import Notification
|
||||
from app.models.message import ProductMessage
|
||||
# ── 产品(实时快照,不过滤) ──
|
||||
# ── 产品流转三段 ──
|
||||
# 状态口径(用户确认):废弃 Product.status 恒值判断,
|
||||
# COMPLETED=待仓库收货,ARCHIVED=已入库('在库' 旧命名等价),OUTBOUND=已出库。
|
||||
# 产品流转卡片第三段"已完结"= 待收货 + 已实收 + 旧在库 + 已出库,保证三段和 = 总数。
|
||||
p_total = await db.scalar(select(func.count(Product.id)))
|
||||
#
|
||||
# ⚠️「已完结」= 产出,必须响应用户在右上角选的时间范围(since / until),
|
||||
# 不能钉死在某个固定窗口 —— 否则选「今天」却看到「本月」的产出,业务语义就裂了。
|
||||
# 历史上这里先是无边界(历史全量无限累加),后又改成硬编码「本月」,两次都错。
|
||||
# Product 表没有 completed_at / updated_at(只有 created_at),故用它名下工序的
|
||||
# 完成时间来锚定「完结时刻」:只要有一条 Task.completed_at 落在所选时段内即计入。
|
||||
#
|
||||
# 「待流转 / 流转中」保持实时快照:只要还没完结,不论多久以前建的单都要统计出来 ——
|
||||
# 它们才是车间真实的积压物料,正是看板要盯的东西。
|
||||
from sqlalchemy import and_
|
||||
|
||||
# 时间下限优先取调用方所选时段的起点;未给时段时退回「本月」作为**防御性下限**,
|
||||
# 避免退化成历史全量累加(注意用独立变量,不要覆盖 since —— 下方 t_done / t_rej
|
||||
# 与返工可见性还要用它,改了会连带改变那些口径)。
|
||||
if since is not None:
|
||||
done_since = since
|
||||
else:
|
||||
from app.services.screen_service import _month_bounds
|
||||
done_since = _month_bounds()[0]
|
||||
|
||||
finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库", "已出库"])
|
||||
p_done = await db.scalar(select(func.count(Product.id)).where(finished_cond))
|
||||
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
|
||||
|
||||
# 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数
|
||||
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
|
||||
has_active_task = select(Task.id).where(
|
||||
Task.product_id == Product.id,
|
||||
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
|
||||
).exists()
|
||||
p_progress = await db.scalar(
|
||||
select(func.count(Product.id)).where(not_finished, has_active_task)
|
||||
|
||||
# 所选时段内完结 = 已完结 且 名下有工序在该时段内完成
|
||||
done_time_conds = [
|
||||
Task.product_id == Product.id,
|
||||
Task.completed_at.isnot(None),
|
||||
Task.completed_at >= done_since,
|
||||
]
|
||||
if until is not None:
|
||||
done_time_conds.append(Task.completed_at <= until)
|
||||
|
||||
done_in_range = and_(
|
||||
finished_cond,
|
||||
select(Task.id).where(*done_time_conds).exists(),
|
||||
)
|
||||
|
||||
# 待流转 = 总数 - 在制 - 完结(三段互斥,保证进度条总和=总数)
|
||||
p_pending = max((p_total or 0) - (p_progress or 0) - (p_done or 0), 0)
|
||||
p_unfinished = await db.scalar(select(func.count(Product.id)).where(not_finished)) or 0
|
||||
p_progress = await db.scalar(
|
||||
select(func.count(Product.id)).where(not_finished, has_active_task)
|
||||
) or 0
|
||||
p_done = await db.scalar(select(func.count(Product.id)).where(done_in_range)) or 0
|
||||
|
||||
# ── 任务实时快照(PENDING/WIP/返工 — 永远不过滤) ──
|
||||
# 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结
|
||||
# (三段互斥,保证进度条总和 = 展示总数)
|
||||
p_pending = max(p_unfinished - p_progress, 0)
|
||||
p_total = p_unfinished + p_done
|
||||
|
||||
# ── 任务实时快照(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_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True)))
|
||||
|
||||
# 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。
|
||||
# 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件,
|
||||
# 保证「品质异常」卡片数字与点开后的明细条数对得上。
|
||||
from app.services.screen_service import _month_bounds
|
||||
month_start_utc, _, _ = _month_bounds()
|
||||
t_rework = await db.scalar(
|
||||
select(func.count(Task.id)).where(
|
||||
Task.is_rework.is_(True),
|
||||
_rework_visible_condition(month_start_utc, since, until),
|
||||
)
|
||||
)
|
||||
|
||||
# ── 任务已完成/驳回(时间可过滤) ──
|
||||
t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED)
|
||||
@ -241,11 +368,129 @@ async def get_dashboard_stats(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 个人效能(按 assignee_id 过滤到本人 — 移动端工作统计)
|
||||
# ============================================================
|
||||
|
||||
async def get_my_stats(
|
||||
db: AsyncSession,
|
||||
assignee_id: str,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> MyStats:
|
||||
"""一线工人个人效能:生产战绩 + 操作统计(均可按自选时段)。
|
||||
|
||||
时段缺省为「本月」;由调用方(移动端)传入北京时间边界。
|
||||
|
||||
【生产战绩】按 Task.assignee_id 归因:
|
||||
- 完成 = status == COMPLETED 且 completed_at 落在区间内(与
|
||||
get_dashboard_stats 的 t_done_q 同一口径,只多加了 assignee_id 过滤)
|
||||
- 被驳回 = status == REJECTED。reject_task 只把状态改成 REJECTED 并写
|
||||
completed_at,assignee_id 仍是被驳回的那个人,直接过滤即可拿到
|
||||
「我的活被驳了几次」
|
||||
- 参与产品数按 product_id 去重 —— 同一台设备做多道工序只算一台
|
||||
|
||||
【操作统计】与 PC /dashboard/user-operations 严格同口径,便于工人自查的
|
||||
数与主管看到的面板对得上:
|
||||
- 接收 task_logs.action_type='receive' ,按 operator_id 归因
|
||||
- 转交 task_logs.action_type='complete',按 operator_id 归因
|
||||
- 上传备注 task_records 按 **Task.assignee_id** 归因,并排除以 '[' 开头的
|
||||
系统自动备注(如 "[接收] 操作员已确认接收")
|
||||
注意前两项归因于 operator_id、第三项归因于 assignee_id —— 这是 PC 端既有
|
||||
口径,此处刻意保持一致,不要"顺手统一"。
|
||||
|
||||
时间边界统一用北京时间再转 UTC:时间戳字段都是 timestamptz,拿北京时区的
|
||||
aware datetime 比较语义一致,且不受数据库会话时区影响
|
||||
(与 screen_service._month_bounds 的写法一致)。
|
||||
"""
|
||||
from datetime import timezone as dt_timezone
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.task import Task, TaskRecord, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED
|
||||
from app.models.task_log import TaskLog
|
||||
from sqlalchemy import or_
|
||||
|
||||
now_bj = get_beijing_time()
|
||||
if since is None:
|
||||
since = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if until is None:
|
||||
until = now_bj
|
||||
since_utc = since.astimezone(dt_timezone.utc)
|
||||
until_utc = until.astimezone(dt_timezone.utc)
|
||||
|
||||
# ── 1. 生产战绩 ──
|
||||
async def _count_task(status: str) -> int:
|
||||
return await db.scalar(
|
||||
select(func.count(Task.id)).where(
|
||||
Task.assignee_id == assignee_id,
|
||||
Task.status == status,
|
||||
Task.completed_at.isnot(None),
|
||||
Task.completed_at >= since_utc,
|
||||
Task.completed_at <= until_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
products_touched = await db.scalar(
|
||||
select(func.count(func.distinct(Task.product_id))).where(
|
||||
Task.assignee_id == assignee_id,
|
||||
Task.status == TASK_STATUS_COMPLETED,
|
||||
Task.completed_at.isnot(None),
|
||||
Task.completed_at >= since_utc,
|
||||
Task.completed_at <= until_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
# ── 2. 操作统计(口径同 get_user_operations,只是固定到本人)──
|
||||
async def _count_log(action_type: str) -> int:
|
||||
return await db.scalar(
|
||||
select(func.count(TaskLog.id)).where(
|
||||
TaskLog.operator_id == assignee_id,
|
||||
TaskLog.action_type == action_type,
|
||||
TaskLog.created_at >= since_utc,
|
||||
TaskLog.created_at <= until_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
# 备注归因给任务负责人(不是记录人)—— 与 PC 面板一致
|
||||
record_count = await db.scalar(
|
||||
select(func.count(TaskRecord.id))
|
||||
.join(Task, TaskRecord.task_id == Task.id)
|
||||
.where(
|
||||
Task.assignee_id == assignee_id,
|
||||
or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")),
|
||||
TaskRecord.created_at >= since_utc,
|
||||
TaskRecord.created_at <= until_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
receive_count = await _count_log("receive")
|
||||
transfer_count = await _count_log("complete")
|
||||
|
||||
return MyStats(
|
||||
assignee_id=assignee_id,
|
||||
tasks_completed=await _count_task(TASK_STATUS_COMPLETED),
|
||||
tasks_rejected=await _count_task(TASK_STATUS_REJECTED),
|
||||
products_touched=products_touched,
|
||||
receive_count=receive_count,
|
||||
transfer_count=transfer_count,
|
||||
record_count=record_count,
|
||||
op_total=receive_count + transfer_count + record_count,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 在制品看板(永远实时)
|
||||
# ============================================================
|
||||
|
||||
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]:
|
||||
"""在制品看板 — 永远实时的 PENDING/WIP 任务。
|
||||
|
||||
默认返回**全部**活跃任务,limit 只是防御性安全上限。
|
||||
|
||||
⚠️ 这里曾默认 limit=20,且先 `.limit(limit * 2)` 预取 40 条、按滞留时长排序后
|
||||
再 `[:limit]` 截断 —— 列表被静默砍到 20 条,而卡片数字来自
|
||||
tasks_pending + tasks_in_progress 的真实计数,于是看板上出现
|
||||
「卡片 23、列表却写共 20 个」的数据被吞现象。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
from app.models.product import Product
|
||||
from app.models.holiday import Holiday
|
||||
@ -255,11 +500,11 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
# 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护
|
||||
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()
|
||||
@ -296,7 +541,7 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
|
||||
))
|
||||
# 统一按滞留时间降序排列(无视 status,纯数值排序)
|
||||
wip_list.sort(key=lambda t: t.duration_hours, reverse=True)
|
||||
return wip_list[:limit]
|
||||
return wip_list[:limit] # limit 仅作上限保护,默认 500 实际不会截断
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -370,10 +615,14 @@ async def get_rejected_tasks(
|
||||
|
||||
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
|
||||
- kind="rejected":被驳回任务,按 completed_at 时间过滤
|
||||
- kind="rework":返工任务(is_rework=True 且非驳回状态),实时快照不过滤时间
|
||||
- kind="rework":返工任务(is_rework=True 且非驳回状态)。
|
||||
未完结(PENDING/WIP)永远展示;已了结的仅限本月 —— 详见
|
||||
_rework_visible_condition。该条件与 get_dashboard_stats 的 t_rework
|
||||
共用,保证卡片数字与明细条数一致。
|
||||
|
||||
返工负责人追溯逻辑与 task_service.reject_task 一致:
|
||||
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
|
||||
返工负责人(rework_assignee)不再复刻 reject_task 的推算逻辑,而是直接查
|
||||
本次驳回派生出的那条返工任务、读其**当前** assignee_id —— 这样返工任务事后
|
||||
被转交/撤回时,看板显示的仍是真实责任人。详见 _derived_rework_assignee。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_REJECTED
|
||||
from app.models.task_log import TaskLog
|
||||
@ -403,11 +652,20 @@ async def get_rejected_tasks(
|
||||
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
|
||||
rejected_rows = (await db.execute(stmt)).all()
|
||||
|
||||
# ── 2. 返工任务(is_rework=True 且当前非驳回状态,实时不过滤时间)──
|
||||
# ── 2. 返工任务(is_rework=True 且当前非驳回状态)──
|
||||
# 原实现此处无任何状态/时间边界,是本页「数据发霉」的根源:上季度甚至更早的
|
||||
# 「已返工完成」记录会无限期留在品质异常区。改由 _rework_visible_condition
|
||||
# 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。
|
||||
from app.services.screen_service import _month_bounds
|
||||
month_start_utc, _, _ = _month_bounds()
|
||||
rework_stmt = (
|
||||
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED)
|
||||
.where(
|
||||
Task.is_rework.is_(True),
|
||||
Task.status != TASK_STATUS_REJECTED,
|
||||
_rework_visible_condition(month_start_utc, since, until),
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
@ -438,41 +696,44 @@ async def get_rejected_tasks(
|
||||
if row[1]:
|
||||
reject_op[row[0]] = row[1]
|
||||
|
||||
create_op: dict = {}
|
||||
if rejected_ids:
|
||||
sub = (
|
||||
select(
|
||||
TaskLog.task_id, TaskLog.operator_id,
|
||||
func.row_number().over(
|
||||
partition_by=TaskLog.task_id,
|
||||
order_by=TaskLog.created_at.asc(),
|
||||
).label("rn"),
|
||||
)
|
||||
.where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "create")
|
||||
).subquery()
|
||||
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
|
||||
for row in r:
|
||||
if row[1]:
|
||||
create_op[row[0]] = row[1]
|
||||
|
||||
parent_assignee: dict = {}
|
||||
parent_ids = [t.parent_task_id for t, *_ in rejected_rows if t.parent_task_id and t.id not in create_op]
|
||||
if parent_ids:
|
||||
r = await db.execute(
|
||||
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
|
||||
# ── 派生返工任务的真实负责人 ──
|
||||
# 不再复刻 reject_task 的推算逻辑去「猜」当时派给了谁 —— 那只是把当时的推导
|
||||
# 重跑一遍,返工任务事后一旦被转交/撤回,看板就会继续显示当初那个旧名字。
|
||||
# 改为去库里定位真正由本次驳回派生出的那条返工任务,读它**当前**的 assignee_id。
|
||||
#
|
||||
# 定位方式:reject_task 建返工任务时只继承了 product_id 与 parent_task_id
|
||||
# (数据模型里没有「派生自哪条任务」的外键),故按「同产品 + 同父节点 +
|
||||
# is_rework」筛出候选,再用「创建时间在驳回之后的最早一条」消歧 —— 若返工任务
|
||||
# 又被驳回,会再派生一条更晚的,取最早即命中本次那条。
|
||||
# 实测两者时间戳完全相等:reject_task 里用的是同一个 now。
|
||||
rework_candidates: dict = {}
|
||||
product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id}
|
||||
if product_ids:
|
||||
cand_rows = await db.execute(
|
||||
select(Task.parent_task_id, Task.product_id, Task.created_at, Task.assignee_id)
|
||||
.where(Task.is_rework.is_(True), Task.product_id.in_(product_ids))
|
||||
.order_by(Task.created_at.asc())
|
||||
)
|
||||
for row in r:
|
||||
if row[1]:
|
||||
parent_assignee[row[0]] = row[1]
|
||||
for parent_id, prod_id, created_at, assignee_id in cand_rows.all():
|
||||
rework_candidates.setdefault((parent_id, prod_id), []).append((created_at, assignee_id))
|
||||
|
||||
def _derived_rework_assignee(task) -> str | None:
|
||||
"""取本次驳回派生出的返工任务的**当前**负责人。"""
|
||||
cands = rework_candidates.get((task.parent_task_id, task.product_id)) or []
|
||||
if not cands:
|
||||
return None # 找不到就如实返回空,不拿推算值冒充
|
||||
for created_at, assignee_id in cands: # 已按 created_at 升序
|
||||
if task.completed_at and created_at and created_at >= task.completed_at:
|
||||
return assignee_id
|
||||
return cands[0][1] # 缺 completed_at 的历史数据:退回最早一条
|
||||
|
||||
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
|
||||
derived_map = {t.id: _derived_rework_assignee(t) for t, *_ in rejected_rows}
|
||||
raw_ids: set[str] = set()
|
||||
for kind, (t, *_row) in all_rows:
|
||||
if kind == "rejected":
|
||||
raw_ids.add(reject_op.get(t.id) or "")
|
||||
raw_ids.add(create_op.get(t.id) or "")
|
||||
if t.id not in create_op and t.parent_task_id:
|
||||
raw_ids.add(parent_assignee.get(t.parent_task_id) or "")
|
||||
raw_ids.add(derived_map.get(t.id) or "")
|
||||
raw_ids.add(t.assignee_id or "")
|
||||
raw_ids.discard("")
|
||||
name_map: dict[str, str] = {}
|
||||
@ -483,12 +744,8 @@ async def get_rejected_tasks(
|
||||
items: list[RejectedTask] = []
|
||||
for kind, (task, sn, ext, mat, spec) in all_rows:
|
||||
if kind == "rejected":
|
||||
# 复刻 reject_task 追溯逻辑:create op → 父任务负责人 → 自身
|
||||
rework_id = create_op.get(task.id)
|
||||
if not rework_id and task.parent_task_id:
|
||||
rework_id = parent_assignee.get(task.parent_task_id)
|
||||
if not rework_id:
|
||||
rework_id = task.assignee_id
|
||||
# 直接取派生返工任务的真实负责人(见 _derived_rework_assignee)
|
||||
rework_id = derived_map.get(task.id)
|
||||
rejected_by_id = reject_op.get(task.id) or task.assignee_id
|
||||
items.append(RejectedTask(
|
||||
task_id=str(task.id),
|
||||
@ -661,9 +918,13 @@ async def get_wip_matrix(
|
||||
- task_name: 按当前工序聚合(dimension_key 为工序名,附主负责人)
|
||||
"""
|
||||
from datetime import timezone as dt_timezone
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
|
||||
# 外层已 join 了 tasks,内层判活跃任务必须换别名,否则语义无歧义
|
||||
_ActiveTask = aliased(Task, name="active_task")
|
||||
|
||||
# 每台设备按主任务创建时间倒序,取第一条即「最新主任务」
|
||||
result = await db.execute(
|
||||
select(
|
||||
@ -672,15 +933,27 @@ async def get_wip_matrix(
|
||||
Product.material_name,
|
||||
Task.task_name,
|
||||
Task.assignee_id,
|
||||
Task.status.label("task_status"),
|
||||
Task.created_at,
|
||||
Task.completed_at,
|
||||
Product.current_location_id,
|
||||
Product.overall_status,
|
||||
Product.status,
|
||||
Product.lifecycle_phase,
|
||||
# 🔧 该产品名下是否还有 ANY 活跃任务(含协助分支)——
|
||||
# 注意:这比下面的 is_active 更宽。is_active 只看"最新一条主干任务",
|
||||
# 而这里扫描该产品的全部任务,用于 is_terminal 兜底判定。
|
||||
# ⚠️ 必须用别名:外层 FROM 里已有一个 tasks,同名会让 SQLAlchemy
|
||||
# 自动关联掉内层 FROM 而报 "no FROM clauses"。别名后语义才无歧义。
|
||||
select(_ActiveTask.id).where(
|
||||
_ActiveTask.product_id == Product.id,
|
||||
_ActiveTask.status.in_(["WIP", "PENDING"]),
|
||||
).exists().label("has_any_active"),
|
||||
)
|
||||
.join(Task, Task.product_id == Product.id)
|
||||
.outerjoin(Task, Task.product_id == Product.id)
|
||||
.where(
|
||||
or_(
|
||||
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
|
||||
)
|
||||
@ -692,7 +965,7 @@ async def get_wip_matrix(
|
||||
# 每台设备 → (spec, 当前工序/负责人, 当前负责人ID)
|
||||
device_cur: dict[str, tuple] = {}
|
||||
seen: set[str] = set()
|
||||
for pid, spec, product_name, task_name, assignee, created, completed, loc, overall_status, product_status in rows:
|
||||
for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle, has_any_active in rows:
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
@ -702,12 +975,26 @@ async def get_wip_matrix(
|
||||
# completed_at or created_at;若后续新增 updated_at 可并入。
|
||||
anchor = completed or created
|
||||
|
||||
# ── 活跃任务判定:决定"按当前工序归列"还是"降级判完结态"的唯一依据 ──
|
||||
# 必须在所有终结态判定之前。否则「已出库」的残留状态(设备回流后
|
||||
# product.status 仍是 OUTBOUND,因为 receive_task/transfer_task 只同步
|
||||
# overall_status 不同步 status)会把正在做售后工序的设备误判为终结态。
|
||||
is_active = tstatus in ("WIP", "PENDING")
|
||||
|
||||
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
|
||||
is_terminal = (
|
||||
# 有活跃任务的一律视为在制,不做时间过滤(与"在制无条件全量"口径一致)
|
||||
is_terminal = (not is_active) and (
|
||||
loc == "virtual_warehouse"
|
||||
or overall_status in ("待仓库收货", "已入库", "在库", "已出库")
|
||||
or str(product_status).upper() in ("ARCHIVED", "OUTBOUND")
|
||||
or task_name in ("扫码入库", "扫码出库")
|
||||
# 🔧 兜底(2026-09-17):设备名下【再无任何活跃任务】= 真的没活了 → 视同终结态。
|
||||
# 历史缺陷:生产设备被【直接完结】后 overall_status 停在工序名(如「测试」),
|
||||
# 既不满足上面任何一条,又因 is_terminal=False 对时间筛选完全免疫 ——
|
||||
# 于是它永远挂在看板上冒充"在制"(报表游魂)。
|
||||
# 补上这一条后,它照常接受 since~until 过滤,不再污染在制口径。
|
||||
# 仍保留原有的终结态判定(本条件是「或」,只会让更多设备被正确归类为终结)。
|
||||
or not has_any_active
|
||||
)
|
||||
if is_terminal:
|
||||
if anchor is not None and anchor.tzinfo is None:
|
||||
@ -719,9 +1006,21 @@ async def get_wip_matrix(
|
||||
# else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致
|
||||
|
||||
if dimension == "task_name":
|
||||
# ── 终结三列判定(多维防御,不依赖单一 loc / overall)──
|
||||
# ══ ① 第一优先级:有活跃任务(WIP/PENDING) → 一律按当前工序归列 ══
|
||||
# 绝不看 overall_status / product.status 的终结态字段。这两个字段
|
||||
# 在售后回流场景下会残留旧值(status 停在 OUTBOUND),若先判终结态,
|
||||
# 正在做「发货测试」的设备会被吞进「已出库」列。
|
||||
if is_active:
|
||||
if tstatus == "PENDING":
|
||||
# 转交后待接收(工序名还是占位符「待确认」)→ 统一归「待接收」
|
||||
key = "待接收"
|
||||
else:
|
||||
key = task_name if (task_name and task_name != "—") else "待接收"
|
||||
# 售后回流设备按折算后的工序名落列:发货测试 / 售后维修
|
||||
key = normalize_after_sales_step(lifecycle, key)
|
||||
# ══ ② 无活跃任务,才降级判完结态(多维防御,不依赖单一 loc / overall)══
|
||||
# 已入库: 整体实收(已入库/在库) 或 status==ARCHIVED 或 收口节点「扫码入库」
|
||||
if overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库":
|
||||
elif overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库":
|
||||
key = "已入库"
|
||||
# 已出库: 整体已出库 或 status==OUTBOUND 或 收口节点「扫码出库」
|
||||
elif overall_status == "已出库" or str(product_status).upper() == "OUTBOUND" or task_name == "扫码出库":
|
||||
@ -730,41 +1029,69 @@ async def get_wip_matrix(
|
||||
elif overall_status == "待仓库收货" or loc == "virtual_warehouse":
|
||||
key = "已完成"
|
||||
else:
|
||||
key = task_name or "—"
|
||||
# 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」
|
||||
if tstatus is None and task_name is None:
|
||||
key = "待接收"
|
||||
else:
|
||||
# 已完工但未收口的工序(如「测试」完成),按原工序名显示
|
||||
key = task_name if (task_name and task_name != "—") else "待接收"
|
||||
# 🔧 售后回流设备的历史工序归一:老数据里售后设备的工序可能仍叫
|
||||
# 「测试 / 维修」,折算进售后区的独立列,避免混入生产区同名工序
|
||||
key = normalize_after_sales_step(lifecycle, key)
|
||||
else:
|
||||
key = assignee or "未分配"
|
||||
device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "")
|
||||
|
||||
# 聚合:规格 × 当前工序 → 设备数;同时收集负责人 与 产品名称
|
||||
from collections import Counter
|
||||
|
||||
# 聚合:规格 × 当前工序 → 设备数;负责人按人头计数,空值显式计为「未分配」
|
||||
agg: dict[tuple, int] = {}
|
||||
assignee_map: dict[tuple, set] = {}
|
||||
assignee_counter: dict[tuple, Counter] = {}
|
||||
product_name_map: dict[tuple, str] = {}
|
||||
for spec, key, assignee, product_name in device_cur.values():
|
||||
k = (spec, key)
|
||||
agg[k] = agg.get(k, 0) + 1
|
||||
product_name_map.setdefault(k, product_name)
|
||||
if assignee:
|
||||
assignee_map.setdefault(k, set()).add(assignee)
|
||||
raw = assignee if assignee and str(assignee).strip() not in ("", "—", "-", "null", "None") else ""
|
||||
assignee_counter.setdefault(k, Counter())[raw] += 1
|
||||
|
||||
# 负责人 ID → 中文名
|
||||
raw_ids: set[str] = set()
|
||||
for s in assignee_map.values():
|
||||
raw_ids |= s
|
||||
for counter in assignee_counter.values():
|
||||
raw_ids |= set(counter.keys())
|
||||
raw_ids.discard("")
|
||||
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))
|
||||
|
||||
def _disp(raw: str) -> str:
|
||||
"""负责人显示名:空值统一为 未分配"""
|
||||
return "未分配" if not raw else name_map.get(raw, raw)
|
||||
|
||||
def _fmt_label(raw: str, n: int) -> str:
|
||||
"""负责人标签:>1 台必须带数量后缀;1 台不带"""
|
||||
disp = _disp(raw)
|
||||
return f"{disp}({n})" if n > 1 else disp
|
||||
|
||||
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 []
|
||||
dim_display = _disp(key) if dimension == "assignee" else key
|
||||
counter = assignee_counter.get((spec, key), Counter())
|
||||
# 负责人名单:未分配固定排最前,其余按台数降序、名称升序;带数量后缀(>1 台)
|
||||
labels = [
|
||||
_fmt_label(raw, n)
|
||||
for raw, n in sorted(
|
||||
counter.items(),
|
||||
key=lambda kv: (0 if kv[0] == "" else 1, -kv[1], _disp(kv[0])),
|
||||
)
|
||||
]
|
||||
items.append(WipMatrixRow(
|
||||
spec_model=spec,
|
||||
product_name=product_name_map.get((spec, key), ""),
|
||||
dimension_key=dim_display,
|
||||
count=cnt,
|
||||
assignees=assignees,
|
||||
assignees=labels,
|
||||
))
|
||||
items.sort(key=lambda x: (x.spec_model, x.dimension_key))
|
||||
return items
|
||||
@ -787,11 +1114,15 @@ async def get_wip_matrix_detail(
|
||||
按其在库三态(已完成/已入库/已出库)或工序名归类到 process。
|
||||
"""
|
||||
from datetime import timezone as dt_timezone
|
||||
from sqlalchemy.orm import aliased
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
from app.models.holiday import Holiday
|
||||
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
|
||||
|
||||
# 与外层 join 的 tasks 区分开(理由见 get_wip_matrix)
|
||||
_ActiveTask = aliased(Task, name="active_task")
|
||||
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
@ -805,16 +1136,24 @@ async def get_wip_matrix_detail(
|
||||
Product.current_location_id,
|
||||
Product.overall_status,
|
||||
Product.status,
|
||||
Product.lifecycle_phase,
|
||||
Task.task_name,
|
||||
Task.assignee_id,
|
||||
Task.status.label("task_status"),
|
||||
Task.created_at,
|
||||
Task.completed_at,
|
||||
Task.received_at,
|
||||
# 🔧 与 get_wip_matrix 同口径:该产品名下是否还有 ANY 活跃任务
|
||||
# (同样必须用别名,理由见 get_wip_matrix 处注释)
|
||||
select(_ActiveTask.id).where(
|
||||
_ActiveTask.product_id == Product.id,
|
||||
_ActiveTask.status.in_(["WIP", "PENDING"]),
|
||||
).exists().label("has_any_active"),
|
||||
)
|
||||
.join(Task, Task.product_id == Product.id)
|
||||
.outerjoin(Task, Task.product_id == Product.id)
|
||||
.where(
|
||||
or_(
|
||||
Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收)
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
|
||||
)
|
||||
@ -828,7 +1167,7 @@ async def get_wip_matrix_detail(
|
||||
matched: list[WipMatrixDetailRow] = []
|
||||
raw_names: set[str] = set()
|
||||
|
||||
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, task_name, assignee, tstatus, created, completed, received in rows:
|
||||
for pid, serial, ext, mat_name, spec, loc, overall, pstatus, lifecycle, task_name, assignee, tstatus, created, completed, received, has_any_active in rows:
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
@ -839,12 +1178,21 @@ async def get_wip_matrix_detail(
|
||||
# ── 真实产出时间锚点:完工/变动时间优先,创建时间仅兜底(schema 无 updated_at)──
|
||||
anchor = completed or created
|
||||
|
||||
# ── 活跃任务判定:与 get_wip_matrix 同口径,必须先于一切终结态判定 ──
|
||||
# 否则「已出库」残留状态(回流后 pstatus 仍是 OUTBOUND)会把正在做
|
||||
# 售后工序的设备误判为终结态,既错配时间过滤又错列。
|
||||
is_active = tstatus in ("WIP", "PENDING")
|
||||
|
||||
# ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ──
|
||||
is_terminal = (
|
||||
# ⚠️ 必须与 get_wip_matrix 一字不差同步,否则会出现
|
||||
# 「矩阵格子里有数、点进来下钻却是空的」。
|
||||
is_terminal = (not is_active) and (
|
||||
loc == "virtual_warehouse"
|
||||
or overall in ("待仓库收货", "已入库", "在库", "已出库")
|
||||
or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND")
|
||||
or task_name in ("扫码入库", "扫码出库")
|
||||
# 🔧 兜底:名下再无任何活跃任务 → 视同终结态,接受时间筛选(防报表游魂)
|
||||
or not has_any_active
|
||||
)
|
||||
if is_terminal:
|
||||
if anchor is not None and anchor.tzinfo is None:
|
||||
@ -855,15 +1203,30 @@ async def get_wip_matrix_detail(
|
||||
continue
|
||||
# else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致
|
||||
|
||||
# ── 终结三列判定(与 get_wip_matrix 一字不差同步,多维防御)──
|
||||
if overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库":
|
||||
# ══ 列归类(与 get_wip_matrix 一字不差同步)══
|
||||
# ① 第一优先级:有活跃任务 → 一律按当前工序归列,不看终结态字段
|
||||
if is_active:
|
||||
if tstatus == "PENDING":
|
||||
key = "待接收"
|
||||
else:
|
||||
key = task_name if (task_name and task_name != "—") else "待接收"
|
||||
key = normalize_after_sales_step(lifecycle, key)
|
||||
# ② 无活跃任务,才降级判完结态
|
||||
elif overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库":
|
||||
key = "已入库"
|
||||
elif overall == "已出库" or str(pstatus).upper() == "OUTBOUND" or task_name == "扫码出库":
|
||||
key = "已出库"
|
||||
elif overall == "待仓库收货" or loc == "virtual_warehouse":
|
||||
key = "已完成"
|
||||
else:
|
||||
key = task_name or "—"
|
||||
# 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」
|
||||
if tstatus is None and task_name is None:
|
||||
key = "待接收"
|
||||
else:
|
||||
key = task_name if (task_name and task_name != "—") else "待接收"
|
||||
# 🔧 必须与 get_wip_matrix 同口径归一:否则矩阵格子里有数、
|
||||
# 点进来下钻却是空的(售后设备的历史「测试/维修」工序)
|
||||
key = normalize_after_sales_step(lifecycle, key)
|
||||
|
||||
if process and key != process:
|
||||
continue
|
||||
@ -884,6 +1247,7 @@ async def get_wip_matrix_detail(
|
||||
material_name=mat_name or "",
|
||||
spec_model=spec or "",
|
||||
task_status=tstatus or "",
|
||||
lifecycle_phase=lifecycle or "PRODUCTION",
|
||||
assignee_id=assignee or "",
|
||||
duration_hours=duration,
|
||||
received_at=received_str,
|
||||
|
||||
@ -6,6 +6,14 @@ from sqlalchemy import select, or_, cast, String, delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.lifecycle import (
|
||||
ALL_OVERALL_STEPS,
|
||||
allowed_steps,
|
||||
is_step_allowed,
|
||||
phase_label,
|
||||
resolve_phase_for_step,
|
||||
sync_product_status,
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.models.task import Task
|
||||
@ -279,6 +287,7 @@ async def get_product_by_serial(db: AsyncSession, serial_number: str) -> Product
|
||||
current_location_id=product.current_location_id,
|
||||
overall_status=product.overall_status,
|
||||
status=product.status,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
created_at=product.created_at,
|
||||
top_level_tasks=[
|
||||
TaskSummaryResponse.model_validate(t) for t in top_tasks
|
||||
@ -365,6 +374,7 @@ async def create_product(db: AsyncSession, data: ProductCreate, creator_username
|
||||
current_location_name=creator_display_name or None,
|
||||
overall_status=product.overall_status,
|
||||
status=product.status,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
@ -413,11 +423,14 @@ async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductU
|
||||
current_location_id=product.current_location_id,
|
||||
overall_status=product.overall_status,
|
||||
status=product.status,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
VALID_OVERALL_STATUS = {"备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库"}
|
||||
# 全部合法宏观状态(两阶段并集)— 仅作"完全非法取值"的第一道粗筛;
|
||||
# 阶段内的细分校验(售后回流设备禁止改回「备货 / 生产」)见下方 is_step_allowed
|
||||
VALID_OVERALL_STATUS = ALL_OVERALL_STEPS
|
||||
|
||||
|
||||
async def update_overall_status(
|
||||
@ -434,7 +447,7 @@ async def update_overall_status(
|
||||
if status_value not in VALID_OVERALL_STATUS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效状态: {status_value},合法值: {', '.join(sorted(VALID_OVERALL_STATUS))}",
|
||||
detail=f"无效状态: {status_value},合法值: {'、'.join(sorted(ALL_OVERALL_STEPS))}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
@ -480,15 +493,23 @@ async def update_overall_status(
|
||||
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
|
||||
)
|
||||
|
||||
# ── 选项隔离:阶段感知校验 ──
|
||||
# 售后回流设备禁止被改回「备货 / 生产」;选定售后专属工序(发货测试 / 售后维修)
|
||||
# 则设备随即进入售后生命周期(无历史记录的老设备由此进入售后阶段)。
|
||||
phase = resolve_phase_for_step(product.lifecycle_phase, status_value)
|
||||
if not is_step_allowed(phase, status_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"产品当前处于{phase_label(product.lifecycle_phase)},不允许改为「{status_value}」。"
|
||||
f"该阶段可选:{'、'.join(allowed_steps(product.lifecycle_phase))}"
|
||||
),
|
||||
)
|
||||
product.lifecycle_phase = phase
|
||||
product.overall_status = status_value
|
||||
# 同步 status 字段,保证与整体状态口径一致(修复"只改整体状态不改 status"的旧缺陷)
|
||||
_OVERALL_TO_STATUS = {
|
||||
"已入库": "ARCHIVED",
|
||||
"在库": "ARCHIVED",
|
||||
"已出库": "OUTBOUND",
|
||||
"待仓库收货": "COMPLETED",
|
||||
}
|
||||
product.status = _OVERALL_TO_STATUS.get(status_value, "WIP")
|
||||
# 同步 status 字段(映射表已上提到 app.core.lifecycle 单一来源,
|
||||
# 供 task_service 的各写入点共用,避免各处各写一份)
|
||||
sync_product_status(product)
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
|
||||
@ -652,6 +673,8 @@ async def get_all_products(
|
||||
|
||||
# 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name(宏观状态名)
|
||||
overall_names: dict[uuid.UUID, str] = {}
|
||||
# 🔧 【当前工序】专用:仅「活跃主干任务」的工序名(见下方 main_stmt 循环填充)
|
||||
active_step_map: dict[uuid.UUID, str] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import and_, func as sa_func, case as sa_case
|
||||
main_where = and_(
|
||||
@ -673,7 +696,7 @@ async def get_all_products(
|
||||
.group_by(Task.product_id)
|
||||
).subquery("mp")
|
||||
main_stmt = (
|
||||
select(Task.product_id, Task.task_name)
|
||||
select(Task.product_id, Task.task_name, Task.status)
|
||||
.join(max_prio, and_(
|
||||
Task.product_id == max_prio.c.product_id,
|
||||
prio_expr == max_prio.c.prio,
|
||||
@ -685,6 +708,11 @@ async def get_all_products(
|
||||
main_result = await db.execute(main_stmt)
|
||||
for row in main_result:
|
||||
overall_names[row[0]] = row[1]
|
||||
# 🔧 供【当前工序】专用:只收【活跃】主干任务的工序名。
|
||||
# overall_names 保持原语义不动(它连 COMPLETED 的任务也收,
|
||||
# 是"最新主干任务名",被 overall_status 复用,改动影响面太大)。
|
||||
if row[2] in ("WIP", "PENDING"):
|
||||
active_step_map[row[0]] = row[1]
|
||||
|
||||
# 🔧 当前位置:汇总所有活跃任务(WIP/PENDING,不分主线/分支)的负责人,去重保序
|
||||
active_assignees_map: dict[uuid.UUID, list[str]] = {}
|
||||
@ -804,6 +832,28 @@ async def get_all_products(
|
||||
return "COMPLETED"
|
||||
return macro_map.get(p.id) or "PENDING"
|
||||
|
||||
# 宏观终态 —— 表达的是"货在哪",不是"在做什么"。
|
||||
# 这类值即使当前没有活跃任务也必须照常展示(业务要求保留物理标识)。
|
||||
_TERMINAL_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
|
||||
|
||||
def _resolve_current_step(p: Product) -> str:
|
||||
"""【当前工序】—— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
|
||||
|
||||
· 有活跃主干任务(WIP/PENDING) → 该任务工序名
|
||||
· 否则产品处于宏观终态 → 该终态("货在哪",保留)
|
||||
· 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
|
||||
|
||||
历史缺陷:ProductResponse.overall_status 会被"最新主干任务名"覆盖
|
||||
(见上方 overall_names),于是已 COMPLETED 的「扫码出库 / 测试 / 发货测试」
|
||||
会长期冒充"当前工序",误导管理者以为活还在干。
|
||||
"""
|
||||
raw = (p.overall_status or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw in _TERMINAL_OVERALL:
|
||||
return raw
|
||||
return active_step_map.get(p.id, "")
|
||||
|
||||
return [
|
||||
ProductResponse(
|
||||
id=p.id,
|
||||
@ -828,7 +878,9 @@ async def get_all_products(
|
||||
),
|
||||
macro_status=_resolve_macro_status(p),
|
||||
overall_status=overall_names.get(p.id) or p.overall_status,
|
||||
current_step=_resolve_current_step(p),
|
||||
status=p.status,
|
||||
lifecycle_phase=p.lifecycle_phase,
|
||||
created_at=p.created_at,
|
||||
latest_record_time=latest_record_map.get(p.id, (None, None, False, None))[0],
|
||||
latest_record_content=latest_record_map.get(p.id, (None, None, False, None))[1],
|
||||
|
||||
254
backend/app/services/screen_service.py
Normal file
254
backend/app/services/screen_service.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合
|
||||
|
||||
设计约定:
|
||||
- 只读,无写操作,字段扁平,便于大屏高频轮询。
|
||||
- 时间一律按**北京时间**判定;DB 列为 timestamptz(实存 UTC),
|
||||
比较前统一把边界换算成 UTC,避免月初/凌晨的边界漂移。
|
||||
- 口径与 dashboard_service.get_dashboard_stats 保持一致:
|
||||
未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。
|
||||
|
||||
视角说明:
|
||||
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
|
||||
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.lifecycle import (
|
||||
AFTER_SALES_ONLY_STEPS,
|
||||
LIFECYCLE_AFTER_SALES,
|
||||
LIFECYCLE_PRODUCTION,
|
||||
)
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task
|
||||
from app.models.task_log import TaskLog
|
||||
|
||||
# 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径
|
||||
FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
|
||||
|
||||
# 仓储动作日志类型(由 webhooks / product_finalize_service 写入)
|
||||
LOG_WAREHOUSE_INBOUND = "warehouse_inbound"
|
||||
LOG_WAREHOUSE_OUTBOUND = "warehouse_outbound"
|
||||
|
||||
|
||||
def _not_finished():
|
||||
"""未完结条件 —— overall_status 为 NULL 或不在已完结集合内。
|
||||
|
||||
注意 PostgreSQL 中 `NULL NOT IN (...)` 结果为 NULL 而非 TRUE,
|
||||
必须显式带上 IS NULL 分支,否则建单后未流转的设备会被整批漏掉。
|
||||
"""
|
||||
return or_(
|
||||
Product.overall_status.is_(None),
|
||||
~Product.overall_status.in_(FINISHED_OVERALL),
|
||||
)
|
||||
|
||||
|
||||
def _month_bounds() -> tuple[datetime, datetime, str]:
|
||||
"""返回 (本月起点 UTC, 当前时刻 UTC, 'YYYY-MM')。
|
||||
|
||||
本月起点 = 北京时间当月 1 日 00:00 —— 直接换算成 UTC 参与 timestamptz 比较,
|
||||
不依赖数据库会话时区设置。
|
||||
"""
|
||||
now_bj = get_beijing_time()
|
||||
month_start_bj = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
return (
|
||||
month_start_bj.astimezone(timezone.utc),
|
||||
now_bj.astimezone(timezone.utc),
|
||||
month_start_bj.strftime("%Y-%m"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. 当月吞吐指标
|
||||
# ============================================================
|
||||
|
||||
class MonthlyMetrics(BaseModel):
|
||||
"""大屏顶部四张数字卡(当月视角)"""
|
||||
month: str # "2026-09"
|
||||
month_start: str # "2026-09-01"(北京时间)
|
||||
month_production: int # 本月有流转记录或新建的生产态设备数
|
||||
month_inbound: int # 本月扫码入库数
|
||||
month_outbound: int # 本月扫码出库数
|
||||
month_returned: int # 本月进入售后/回流状态的设备数
|
||||
|
||||
|
||||
async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
|
||||
"""当月吞吐四联指标。
|
||||
|
||||
口径:
|
||||
- month_production:lifecycle_phase = PRODUCTION,且「本月新建」或
|
||||
「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。
|
||||
反映这个月实际被推着走的机器有多少。
|
||||
- month_inbound / month_outbound:task_logs 中 action_type =
|
||||
warehouse_inbound / warehouse_outbound 的记录数(MOM 扫码回调写入)。
|
||||
- month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的
|
||||
设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES
|
||||
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
|
||||
"""
|
||||
month_start_utc, now_utc, month_label = _month_bounds()
|
||||
|
||||
# 本月该设备产生过任意流转日志
|
||||
has_activity = (
|
||||
select(TaskLog.id)
|
||||
.join(Task, Task.id == TaskLog.task_id)
|
||||
.where(
|
||||
Task.product_id == Product.id,
|
||||
TaskLog.created_at >= month_start_utc,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
month_production = await db.scalar(
|
||||
select(func.count(func.distinct(Product.id))).where(
|
||||
Product.lifecycle_phase == LIFECYCLE_PRODUCTION,
|
||||
or_(Product.created_at >= month_start_utc, has_activity),
|
||||
)
|
||||
) or 0
|
||||
|
||||
async def _count_log(action_type: str) -> int:
|
||||
return await db.scalar(
|
||||
select(func.count(TaskLog.id)).where(
|
||||
TaskLog.action_type == action_type,
|
||||
TaskLog.created_at >= month_start_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
|
||||
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
|
||||
|
||||
month_returned = await db.scalar(
|
||||
select(func.count(func.distinct(Task.product_id)))
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Product.lifecycle_phase == LIFECYCLE_AFTER_SALES,
|
||||
Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)),
|
||||
Task.created_at >= month_start_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
return MonthlyMetrics(
|
||||
month=month_label,
|
||||
month_start=f"{month_label}-01",
|
||||
month_production=int(month_production),
|
||||
month_inbound=int(month_inbound),
|
||||
month_outbound=int(month_outbound),
|
||||
month_returned=int(month_returned),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. 工序积压分布(柱状图)
|
||||
# ============================================================
|
||||
|
||||
class WipStage(BaseModel):
|
||||
stage: str # 工序名(中文,直接作为图表类目)
|
||||
phase: str # PRODUCTION / AFTER_SALES,供前端分区着色
|
||||
count: int
|
||||
|
||||
|
||||
class WipDistributionResponse(BaseModel):
|
||||
total: int # 未完结设备总数
|
||||
items: list[WipStage]
|
||||
|
||||
|
||||
# 阶段与流转顺序 —— 顺序即「工序先后」,前端据此排布柱子
|
||||
WIP_STAGES: tuple[tuple[str, str], ...] = (
|
||||
("待启动", LIFECYCLE_PRODUCTION), # overall_status 为空:建单后尚未流转
|
||||
("备货", LIFECYCLE_PRODUCTION),
|
||||
("生产", LIFECYCLE_PRODUCTION),
|
||||
("测试", LIFECYCLE_PRODUCTION),
|
||||
("维修", LIFECYCLE_PRODUCTION),
|
||||
("发货测试", LIFECYCLE_AFTER_SALES),
|
||||
("售后维修", LIFECYCLE_AFTER_SALES),
|
||||
)
|
||||
|
||||
# 「待启动」对应的真实分组键(overall_status IS NULL / 空串)
|
||||
_UNSTARTED_KEY = ""
|
||||
|
||||
|
||||
async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse:
|
||||
"""工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。
|
||||
|
||||
返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而
|
||||
整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的
|
||||
count 之和恒等于 total(避免统计悄悄漏数)。
|
||||
"""
|
||||
rows = await db.execute(
|
||||
select(Product.overall_status, func.count(Product.id))
|
||||
.where(_not_finished())
|
||||
.group_by(Product.overall_status)
|
||||
)
|
||||
counts: dict[str, int] = {}
|
||||
for raw_status, cnt in rows.all():
|
||||
key = (raw_status or "").strip()
|
||||
counts[key] = counts.get(key, 0) + cnt
|
||||
|
||||
total = sum(counts.values())
|
||||
|
||||
items: list[WipStage] = []
|
||||
for stage, phase in WIP_STAGES:
|
||||
key = _UNSTARTED_KEY if stage == "待启动" else stage
|
||||
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0)))
|
||||
|
||||
known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in WIP_STAGES}
|
||||
for key, cnt in counts.items():
|
||||
if key not in known_keys and cnt:
|
||||
items.append(WipStage(
|
||||
stage=key or "待启动",
|
||||
phase=LIFECYCLE_PRODUCTION,
|
||||
count=cnt,
|
||||
))
|
||||
|
||||
return WipDistributionResponse(total=total, items=items)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. 系统使用活跃度(本月人员排行)
|
||||
# ============================================================
|
||||
|
||||
class ActiveUser(BaseModel):
|
||||
user_id: str
|
||||
user_name: str
|
||||
receive_count: int # 接收
|
||||
transfer_count: int # 转交
|
||||
record_count: int # 上传备注
|
||||
total: int
|
||||
|
||||
|
||||
class ActiveUsersResponse(BaseModel):
|
||||
month: str
|
||||
items: list[ActiveUser]
|
||||
|
||||
|
||||
async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse:
|
||||
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
|
||||
|
||||
不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete),
|
||||
上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员,
|
||||
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。
|
||||
"""
|
||||
from app.services.dashboard_service import get_user_operations
|
||||
|
||||
month_start_utc, _now, month_label = _month_bounds()
|
||||
operations = await get_user_operations(db, since=month_start_utc, until=None)
|
||||
active = [op for op in operations if op.total > 0][:top_n]
|
||||
|
||||
return ActiveUsersResponse(
|
||||
month=month_label,
|
||||
items=[
|
||||
ActiveUser(
|
||||
user_id=op.user_id,
|
||||
user_name=op.user_name,
|
||||
receive_count=op.receive_count,
|
||||
transfer_count=op.transfer_count,
|
||||
record_count=op.record_count,
|
||||
total=op.total,
|
||||
)
|
||||
for op in active
|
||||
],
|
||||
)
|
||||
@ -1,5 +1,6 @@
|
||||
"""任务服务 — 核心业务逻辑:接收、驳回返工、裂变转交、无限嵌套子任务"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import uuid
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, delete
|
||||
@ -9,6 +10,16 @@ from sqlalchemy.orm import selectinload
|
||||
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED
|
||||
from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.core.lifecycle import (
|
||||
AFTER_SALES_REPAIR_STEPS,
|
||||
LIFECYCLE_AFTER_SALES,
|
||||
PRODUCTION_ONLY_STEPS,
|
||||
allowed_steps,
|
||||
is_step_allowed,
|
||||
phase_label,
|
||||
resolve_phase_for_step,
|
||||
sync_product_status,
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.task_log import TaskLog
|
||||
from app.schemas.task import (
|
||||
@ -81,13 +92,141 @@ async def _recalc_product_location(
|
||||
result = await db.execute(stmt)
|
||||
main_task = result.scalar_one_or_none()
|
||||
|
||||
# 上面的排序把 WIP(3) > PENDING(2) 放最前,所以取到的这条即最高优先级:
|
||||
# 它是 WIP/PENDING 说明产品仍有活跃主线任务,否则视为闲置。
|
||||
has_active_main = main_task is not None and main_task.status in (
|
||||
TASK_STATUS_WIP, TASK_STATUS_PENDING,
|
||||
)
|
||||
|
||||
new_location = main_task.assignee_id if main_task else None
|
||||
|
||||
# 🚚 「货发走就离场」——已出库且闲置的设备,厂内不再有它的位置。
|
||||
# 直接完结后若继续停在最后经手人名下(或残留 virtual_warehouse),
|
||||
# 列表里会与「已出库」自相矛盾。置空后前端统一显示「—」。
|
||||
# ⚠️ 只清「已出库」:已入库 / 在库 的设备确实还在仓库里,位置必须保留。
|
||||
if product.overall_status == "已出库" and not has_active_main:
|
||||
new_location = None
|
||||
|
||||
if product.current_location_id != new_location:
|
||||
product.current_location_id = new_location
|
||||
await db.flush() # 唯一的落盘点
|
||||
|
||||
|
||||
# 绝对物理终态 — 描述「设备此刻物理上在哪」的宏观状态,由 MOM 仓储/发货回调驱动。
|
||||
# 接收 / 派发 / 转交任务时【禁止】用任务名覆写它们:否则「已出库」会被
|
||||
# 「发货测试」这类工序名静默抹掉,设备在按终态口径统计的报表里就不再是已出库
|
||||
# (WIP 矩阵因为优先看活跃任务而看不出问题,但全局概览 / 大屏会把它移出已完结)。
|
||||
#
|
||||
# ⚠️ 刻意不含「待仓库收货」:那是「车间完工待实收」的过渡态,设备随后仍会被
|
||||
# 重新派活,宏观状态理应随工序更新。
|
||||
PHYSICAL_TERMINAL_OVERALL = ("已入库", "在库", "已出库")
|
||||
|
||||
|
||||
def _is_physical_terminal(overall: str | None) -> bool:
|
||||
"""该宏观状态是否为「绝对物理终态」(不应被任务名覆写)"""
|
||||
return (overall or "").strip() in PHYSICAL_TERMINAL_OVERALL
|
||||
|
||||
|
||||
async def _mark_after_sales_if_reactivated(
|
||||
db: AsyncSession, product_id: uuid.UUID,
|
||||
steps: str | list[str | None] | None = None,
|
||||
) -> bool:
|
||||
"""出库后又接到【明确修机指令】= 设备回流返厂 → 生命周期切到 AFTER_SALES。
|
||||
|
||||
判定依据(必须同时满足):
|
||||
1. 产品当前处于「已出库」终态(overall_status == '已出库' 或 status == 'OUTBOUND')
|
||||
2. 本次接到的工序是明确修机指令(售后维修)
|
||||
|
||||
⚠️ 出厂质检(发货测试)【不】触发。设备可能压根没回厂,只是补做发货前测试。
|
||||
历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的
|
||||
AFTER_SALES 烙印(售后死锁),且设备的物理终态「已出库」被工序名抹掉。
|
||||
|
||||
该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把
|
||||
生产阶段的「发货测试」与回流后的「售后维修」区分开。
|
||||
|
||||
调用时机必须早于调用方改写 overall_status,否则会漏判。
|
||||
|
||||
:param steps: 本次接到的工序名;转交场景可能多分支,故接受列表。
|
||||
返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中
|
||||
identity map,不产生额外查询。
|
||||
"""
|
||||
product = await db.get(Product, product_id)
|
||||
if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES:
|
||||
return False
|
||||
|
||||
names = [steps] if isinstance(steps, str) else [s for s in (steps or []) if s]
|
||||
if not any(n.strip() in AFTER_SALES_REPAIR_STEPS for n in names):
|
||||
return False
|
||||
|
||||
is_outbound = (
|
||||
product.overall_status == "已出库"
|
||||
or (product.status or "").upper() == "OUTBOUND"
|
||||
)
|
||||
if not is_outbound:
|
||||
return False
|
||||
|
||||
product.lifecycle_phase = LIFECYCLE_AFTER_SALES
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def _enforce_step_isolation(
|
||||
product: Product, step: str | None, *, action: str,
|
||||
) -> None:
|
||||
"""选项隔离守卫 — 售后回流设备禁止被重新排产回「备货 / 生产」等前期工序。
|
||||
|
||||
行为两步:
|
||||
1. 选定售后专属工序(发货测试 / 售后维修)→ 设备随即进入售后生命周期。
|
||||
这是「无历史记录的老设备」进入售后阶段的入口。
|
||||
2. 再校验该工序在当前阶段是否合法,非法直接 400 —— 前端下拉被绕过、
|
||||
请求被伪造时,仍在此拦下。
|
||||
|
||||
必须在 _mark_after_sales_if_reactivated 之后调用,这样出库回流的设备
|
||||
已处于 AFTER_SALES,自然排不回前期工序。
|
||||
|
||||
注意:「待确认」与仓库虚拟节点属建单占位符,由 is_step_allowed 直接放行,
|
||||
否则建单 / 转交入库整条链路会被卡死。
|
||||
"""
|
||||
phase = resolve_phase_for_step(product.lifecycle_phase, step)
|
||||
if not is_step_allowed(phase, step):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"产品当前处于{phase_label(phase)},{action}不允许使用工序「{step}」。"
|
||||
f"该阶段可选:{'、'.join(allowed_steps(phase))}"
|
||||
),
|
||||
)
|
||||
if phase != product.lifecycle_phase:
|
||||
product.lifecycle_phase = phase
|
||||
|
||||
|
||||
def _reject_cross_phase_steps(
|
||||
phase: str | None, steps: list[str | None], *, action: str,
|
||||
) -> None:
|
||||
"""跨阶段工序拦截 — 用于「转交」这类自由文本工序名路径。
|
||||
|
||||
转交对话框允许用户手打工序名(喷漆 / 老化 / 待确认…),所以不能用
|
||||
白名单,否则会误伤合法命名;但"售后回流设备被转交到【生产】"必须挡住。
|
||||
因此这里只拒绝**生产阶段专属**词(备货 / 生产 / 测试 / 维修)。
|
||||
|
||||
「在库 / 已入库 / 已出库」两阶段通用,占位符同样放行。
|
||||
"""
|
||||
if (phase or "") != LIFECYCLE_AFTER_SALES:
|
||||
return
|
||||
bad = sorted({
|
||||
s.strip() for s in steps
|
||||
if s and s.strip() in PRODUCTION_ONLY_STEPS
|
||||
})
|
||||
if bad:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"产品当前处于{phase_label(LIFECYCLE_AFTER_SALES)},{action}不允许使用前期工序:"
|
||||
f"{'、'.join(bad)}。该阶段可选:{'、'.join(allowed_steps(LIFECYCLE_AFTER_SALES))}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None:
|
||||
"""权限校验:本人 或 管理员/主管 可操作"""
|
||||
if operator_role and operator_role in ADMIN_ROLES:
|
||||
@ -277,8 +416,22 @@ async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse:
|
||||
product_result = await db.execute(select(Product).where(Product.id == data.product_id))
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product:
|
||||
if data.task_name and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")):
|
||||
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
|
||||
# (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
|
||||
await _mark_after_sales_if_reactivated(db, data.product_id, data.task_name)
|
||||
# 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参)
|
||||
_enforce_step_isolation(product, data.task_name, action="创建任务")
|
||||
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— 设备已是
|
||||
# 已入库/在库/已出库 时禁止用任务名覆写,否则「已出库」会被工序名抹掉。
|
||||
if (
|
||||
data.task_name
|
||||
and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY"))
|
||||
and not _is_physical_terminal(product.overall_status)
|
||||
):
|
||||
product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name
|
||||
# 🔧 双字段同步:overall_status 改动后必须对齐 status,
|
||||
# 否则出库回流设备的 status 会永远停在 OUTBOUND,污染统计口径
|
||||
sync_product_status(product)
|
||||
# 派发给人 → 产品离开仓库
|
||||
if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE:
|
||||
product.current_location_id = data.assignee_id
|
||||
@ -569,12 +722,30 @@ async def receive_task(
|
||||
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product:
|
||||
# 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期
|
||||
# (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。
|
||||
await _mark_after_sales_if_reactivated(
|
||||
db, task.product_id, task_name or task.task_name,
|
||||
)
|
||||
# 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内
|
||||
#(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」)
|
||||
_enforce_step_isolation(product, task_name, action="接收任务")
|
||||
if task.assignee_id:
|
||||
product.current_location_id = task.assignee_id
|
||||
if task_name and (
|
||||
not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")
|
||||
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
|
||||
# 设备已是 已入库/在库/已出库 时禁止用任务名覆写。
|
||||
# 这是「发货测试 抹掉 已出库」的关键拦截点:若不拦,设备会在
|
||||
# 概览/大屏等按终态口径统计的报表里不再是已出库,而矩阵因优先看
|
||||
# 活跃任务看不出问题(跨报表口径就此打架)。
|
||||
if (
|
||||
task_name
|
||||
and (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
|
||||
and not _is_physical_terminal(product.overall_status)
|
||||
):
|
||||
product.overall_status = task_name # 只主线任务同步宏观状态
|
||||
product.overall_status = task_name
|
||||
# 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留
|
||||
# (如整体已是「发货测试」而 status 还停在 OUTBOUND)
|
||||
sync_product_status(product)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
@ -598,8 +769,9 @@ async def reject_task(
|
||||
2. 查找当前任务的父任务 (parent_task) 的负责人 (assignee_id)。
|
||||
- 若有父任务:返工任务分配给父任务的 assignee_id。
|
||||
- 若无父任务(顶层任务):返工任务分配给当前任务自己的 assignee_id。
|
||||
3. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。
|
||||
4. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。
|
||||
3. 把驳回原因 + 异常图片写入 TaskRecord,挂到被驳回的任务下(前端时间线可见)。
|
||||
4. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。
|
||||
5. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
@ -655,7 +827,16 @@ async def reject_task(
|
||||
if not rework_assignee_id:
|
||||
rework_assignee_id = task.assignee_id
|
||||
|
||||
# --- 3. 创建返工任务 ---
|
||||
# --- 3. 写入驳回记录(原因 + 异常图片)→ 修复前端时间线缺失 ---
|
||||
# 图片为选填(编号错误等场景可不传),空数组落库为 "[]",前端时间线照常渲染
|
||||
db.add(TaskRecord(
|
||||
task_id=task.id,
|
||||
remark=f"[品质驳回] {request.reason}(返工任务已派发给 {rework_assignee_id or '未指派'})",
|
||||
images=json.dumps(request.images, ensure_ascii=False),
|
||||
created_at=now,
|
||||
))
|
||||
|
||||
# --- 4. 创建返工任务 ---
|
||||
rework_task = Task(
|
||||
product_id=task.product_id,
|
||||
parent_task_id=task.parent_task_id, # 与原任务同级
|
||||
@ -720,6 +901,12 @@ async def transfer_task(
|
||||
- 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为 'virtual_warehouse'。
|
||||
- 为每一个 assignee_id(非 virtual_warehouse)新建一条 Task 记录(状态 PENDING)。
|
||||
|
||||
动作 2'(直接完结 finish_directly=True):
|
||||
- 不解析任何下家,分支强制为空,直接落入下方"无下家"兜底分支。
|
||||
- 用于「售后返厂直接发走 / 半成品被提走」这类**无需入库**的收官场景:
|
||||
工人不必再借道「入库(virtual_warehouse)」来关闭任务,
|
||||
从而避免把产品误标成「待仓库收货」并卡住 MOM 对账。
|
||||
|
||||
裂变逻辑:
|
||||
- 如果 len(next_assignees) > 1:多路裂变 → 所有新任务挂到当前任务下(parent_task_id = 当前任务ID)。
|
||||
- 如果当前任务本身就是子任务(有 parent_task_id):单路转交也挂到同一父任务下。
|
||||
@ -731,6 +918,18 @@ async def transfer_task(
|
||||
# 权限校验:本人 或 管理员/主管 可转交
|
||||
_check_permission(task.assignee_id, operator_id, operator_role)
|
||||
|
||||
# ── 🏁 直接完结:额外收紧为「仅管理员/主管」──
|
||||
# 这是【正向】拦截(非管理员一律拒绝),刻意不复用 _check_permission 的反向写法:
|
||||
# 后者在 operator_id 为空时会静默放行,而直接完结是不可逆的收官动作
|
||||
# (不产生下游任务、产品落终态),必须默认拒绝。
|
||||
# ⚠️ 判据只能是 operator_role —— 它来自签名保护的 JWT;
|
||||
# operator_id 是客户端可通过 ?operator_id= 自行填写的,不可作为权限依据。
|
||||
if request.finish_directly and not (operator_role and operator_role in ADMIN_ROLES):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅超管和主管有权限进行直接完结操作",
|
||||
)
|
||||
|
||||
# 校验:不能重复完成
|
||||
if task.status == TASK_STATUS_COMPLETED:
|
||||
raise HTTPException(
|
||||
@ -755,6 +954,16 @@ async def transfer_task(
|
||||
now = get_beijing_time()
|
||||
|
||||
# --- 动作 1:闭环当前节点 ---
|
||||
# 🔧 直接完结走独立文案:action_type 沿用 "complete"(不新增取值,避免扰动现有
|
||||
# 看板的完成数口径),仅在 remark 里区分,审计时可按文本筛出「直接完结」。
|
||||
finish_directly = bool(request.finish_directly)
|
||||
if finish_directly:
|
||||
log_remark = request.note or f"直接完结任务「{task.task_name}」(无下游,不入库)"
|
||||
record_remark = request.note or "[直接完结] 无下游任务,产品宏观状态保持不变"
|
||||
else:
|
||||
log_remark = request.note or f"完成任务「{task.task_name}」,转交至下一道工序"
|
||||
record_remark = request.note or "[完工转交] 移交下一工序"
|
||||
|
||||
task.status = TASK_STATUS_COMPLETED
|
||||
task.completed_at = now
|
||||
|
||||
@ -762,16 +971,26 @@ async def transfer_task(
|
||||
db, task_id,
|
||||
action_type="complete",
|
||||
operator_id=operator_id,
|
||||
remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序",
|
||||
remark=log_remark,
|
||||
task_assignee_id=task.assignee_id,
|
||||
)
|
||||
db.add(TaskRecord(task_id=task.id,
|
||||
remark=(request.note or f"[完工转交] 移交下一工序") + _admin_proxy_note(operator_id, task.assignee_id),
|
||||
remark=record_remark + _admin_proxy_note(operator_id, task.assignee_id),
|
||||
images="[]"))
|
||||
|
||||
# --- 动作 2:解析下家 & 裂变 ---
|
||||
# 🔧 提前查产品:下面的阶段隔离校验依赖它的生命周期阶段。
|
||||
# (通知也需要 product_sn,原本在创建任务之后才查,提前不增加查询次数)
|
||||
product_result = await db.execute(
|
||||
select(Product).where(Product.id == task.product_id)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
|
||||
# 兼容新旧格式
|
||||
if request.next_tasks:
|
||||
# 🔧 直接完结:不解析任何下家,分支强制为空,直接命中下方"无下家"兜底分支。
|
||||
if finish_directly:
|
||||
branches: list[tuple[str | None, str]] = []
|
||||
elif request.next_tasks:
|
||||
branches = [
|
||||
(b.task_name, a)
|
||||
for b in request.next_tasks
|
||||
@ -786,18 +1005,40 @@ async def transfer_task(
|
||||
has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches)
|
||||
real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE]
|
||||
|
||||
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期。
|
||||
# 必须在下方阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。
|
||||
# (故本调用安排在分支解析之后、_reject_cross_phase_steps 之前。)
|
||||
# ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过:
|
||||
# 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。
|
||||
# ⚠️ 出厂质检(发货测试)同样不触发 —— 详见 _mark_after_sales_if_reactivated。
|
||||
if product and not finish_directly:
|
||||
await _mark_after_sales_if_reactivated(
|
||||
db, task.product_id, [tn for tn, _ in real_branches],
|
||||
)
|
||||
|
||||
# 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。
|
||||
# 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。
|
||||
if product:
|
||||
_reject_cross_phase_steps(
|
||||
product.lifecycle_phase, [tn for tn, _ in real_branches], action="转交",
|
||||
)
|
||||
|
||||
is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1)
|
||||
is_child_task = task.parent_task_id is not None
|
||||
|
||||
created_tasks: list[Task] = []
|
||||
|
||||
for task_name, assignee_id in real_branches:
|
||||
# 🔧 动态推导 task_type:禁止分支任务转交被硬编码为 TRANSFER(否则 is_main 判定误升为主线)
|
||||
if is_fission:
|
||||
new_parent_task_id = task.id
|
||||
new_task_type = "SPAWN" # 裂变产生新分支
|
||||
elif is_child_task:
|
||||
new_parent_task_id = task.parent_task_id
|
||||
new_task_type = task.task_type or "SPAWN" # 继承父任务的分支血统
|
||||
else:
|
||||
new_parent_task_id = task.id
|
||||
new_task_type = "TRANSFER" # 主干常规流转
|
||||
|
||||
new_task = Task(
|
||||
product_id=task.product_id,
|
||||
@ -805,7 +1046,7 @@ async def transfer_task(
|
||||
task_name=task_name,
|
||||
assignee_id=assignee_id,
|
||||
status=TASK_STATUS_PENDING,
|
||||
task_type="TRANSFER",
|
||||
task_type=new_task_type, # ← 使用动态推导的类型
|
||||
notify_parent_on_complete=False,
|
||||
is_rework=False,
|
||||
remark=request.note or None,
|
||||
@ -816,11 +1057,6 @@ async def transfer_task(
|
||||
# 批量 flush 以生成 ID
|
||||
await db.flush()
|
||||
|
||||
# 提前查询产品(通知需要 product_sn)
|
||||
product_result = await db.execute(
|
||||
select(Product).where(Product.id == task.product_id)
|
||||
)
|
||||
product = product_result.scalar_one_or_none()
|
||||
product_sn = product.serial_number if product else ""
|
||||
|
||||
for nt in created_tasks:
|
||||
@ -831,6 +1067,16 @@ async def transfer_task(
|
||||
remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}",
|
||||
task_assignee_id=nt.assignee_id,
|
||||
)
|
||||
# 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上。
|
||||
# 原先留言只落在「已完工的上游任务记录」+「新任务的 Task.remark 字段」里,
|
||||
# 接手人打开自己任务的「操作日志」只能看到 [接收],看不到交接上下文,
|
||||
# 转交备注的协作意义就丢了。这里补齐下游视角。
|
||||
if request.note and request.note.strip():
|
||||
db.add(TaskRecord(
|
||||
task_id=nt.id,
|
||||
remark=f"[上一工序转交留言]:{request.note.strip()}",
|
||||
images="[]",
|
||||
))
|
||||
# 🔔 通知:新任务派发
|
||||
if nt.assignee_id:
|
||||
db.add(Notification(
|
||||
@ -841,19 +1087,44 @@ async def transfer_task(
|
||||
task_id=nt.id,
|
||||
))
|
||||
|
||||
# --- 更新 Product 的 current_location_id ---
|
||||
# --- 更新 Product 的 current_location_id / overall_status ---
|
||||
# 🏁 直接完结【不改动 overall_status】:它只是一个「任务闭环动作」,
|
||||
# 不改变产品的物理状态。已出库的设备完结后依然是已出库,
|
||||
# 从而回归 WIP 矩阵的【已出库】列(无活跃任务 → 落终结态判定)。
|
||||
# 下方 has_warehouse / real_branches 两个分支对直接完结均为假,
|
||||
# overall_status 自然原样保留。
|
||||
if product:
|
||||
if has_warehouse and not real_branches:
|
||||
# 转入虚拟仓库池:位置一定改(下一手才能在这里看到并接手),
|
||||
# 但宏观状态要分情况 ——
|
||||
# · 绝对物理终态(已入库 / 在库 / 已出库):禁止改写成「待仓库收货」。
|
||||
# 已出库设备售后返修时选择【入库】是为了「放回池子让下一个人继续转」,
|
||||
# 改成「待仓库收货」会抹掉它的物理终态,且与 MOM 侧「已发货」的记录冲突
|
||||
# (MOM 认为货已出门,Track 却说「等仓库收货」,双方对不上账)。
|
||||
# 已入库 / 在库设备本就在仓库,再入库更不该回退成「待收货」。
|
||||
# · 普通生产设备(备货/生产/测试/维修…):维持原行为,正常置为「待仓库收货」。
|
||||
product.current_location_id = VIRTUAL_WAREHOUSE
|
||||
product.overall_status = "待仓库收货"
|
||||
# 双字段同步:完工转交入库 → 产品标记为已完成(MOM 扫码实收后才变已入库)
|
||||
product.status = "COMPLETED"
|
||||
if not _is_physical_terminal(product.overall_status):
|
||||
product.overall_status = "待仓库收货"
|
||||
elif real_branches:
|
||||
product.current_location_id = real_branches[0][1]
|
||||
if not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"):
|
||||
# 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】——
|
||||
# 设备已是 已入库/在库/已出库 时禁止用任务名覆写,
|
||||
# 否则「已出库」会被「发货测试」这类工序名抹掉。
|
||||
if (
|
||||
(not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY"))
|
||||
and not _is_physical_terminal(product.overall_status)
|
||||
):
|
||||
product.overall_status = real_branches[0][0]
|
||||
# 🔧 双字段同步:各分支统一在这里对齐一次(原先只有入库分支硬编码
|
||||
# product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND)
|
||||
# 注意顺序:必须在上面的 overall_status 赋值【之后】,否则 status 同步到旧值。
|
||||
sync_product_status(product)
|
||||
|
||||
# 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务
|
||||
# 「直接完结」(finish_directly) 与旧版空分支都落到这里:
|
||||
# current_location_id 回溯到最后一手经手人(业务确认:位置停在经手人合理)。
|
||||
# 注意它只改 location,不碰 overall_status,故上面的终态不会被覆盖。
|
||||
if not real_branches and not has_warehouse:
|
||||
await _recalc_product_location(db, task.product_id, task_id)
|
||||
|
||||
@ -866,19 +1137,25 @@ async def transfer_task(
|
||||
await db.refresh(nt)
|
||||
created_task_responses.append(_to_response(nt))
|
||||
|
||||
assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
|
||||
location_info = ""
|
||||
if has_warehouse:
|
||||
location_info = ",产品已入库(virtual_warehouse)"
|
||||
# 🔧 直接完结没有下家,不能套用"已创建 N 个任务(接收人: 仓库)"的模板
|
||||
# (否则会拼出「已创建 0 个下一道工序任务「None」(接收人: 仓库)」这种误导文案)
|
||||
if finish_directly:
|
||||
message = f"任务「{task.task_name}」已直接完结(无下游任务,产品状态保持不变)"
|
||||
else:
|
||||
assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库"
|
||||
location_info = ""
|
||||
if has_warehouse:
|
||||
location_info = ",产品已入库(virtual_warehouse)"
|
||||
message = (
|
||||
f"任务「{task.task_name}」已完成,"
|
||||
f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」"
|
||||
f"(接收人: {assignee_list}){location_info}"
|
||||
)
|
||||
|
||||
return TaskTransferResponse(
|
||||
completed_task=_to_response(refreshed_task),
|
||||
created_tasks=created_task_responses,
|
||||
message=(
|
||||
f"任务「{task.task_name}」已完成,"
|
||||
f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」"
|
||||
f"(接收人: {assignee_list}){location_info}"
|
||||
),
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@ -933,10 +1210,26 @@ async def complete_task(
|
||||
remark=request.remark or f"完成任务: {task.task_name}",
|
||||
task_assignee_id=task.assignee_id,
|
||||
)
|
||||
# 🔧 完工留痕(与 transfer_task 同口径):备注要写成 TaskRecord,
|
||||
# 否则「操作日志」时间线只读 TaskRecord、看不到 TaskLog,交接内容就丢了。
|
||||
_will_transfer = bool(request.next_task_name and request.next_assignee_id)
|
||||
db.add(TaskRecord(
|
||||
task_id=task.id,
|
||||
remark=(
|
||||
request.remark
|
||||
or ("[完工转交] 移交下一工序" if _will_transfer else "[完工] 任务已完成")
|
||||
) + _admin_proxy_note(request.operator_id, task.assignee_id),
|
||||
images="[]",
|
||||
))
|
||||
|
||||
# --- 4. 可选:创建下一步任务(转交) ---
|
||||
next_task = None
|
||||
if request.next_task_name and request.next_assignee_id:
|
||||
# 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期
|
||||
# (出厂质检「发货测试」不触发)
|
||||
await _mark_after_sales_if_reactivated(
|
||||
db, task.product_id, request.next_task_name,
|
||||
)
|
||||
# 🚀 智能父节点继承算法
|
||||
# 主线任务转交 → 保持平级继承(主分支永远在一维主干上)
|
||||
# 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝)
|
||||
@ -965,6 +1258,15 @@ async def complete_task(
|
||||
remark=f"由任务「{task.task_name}」完成后转交创建",
|
||||
task_assignee_id=next_task.assignee_id,
|
||||
)
|
||||
# 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上,
|
||||
# 让接手人打开自己的「操作日志」就能看到交接上下文
|
||||
# (与 transfer_task 同口径,保证两条转交路径行为一致)
|
||||
if request.remark and request.remark.strip():
|
||||
db.add(TaskRecord(
|
||||
task_id=next_task.id,
|
||||
remark=f"[上一工序转交留言]:{request.remark.strip()}",
|
||||
images="[]",
|
||||
))
|
||||
|
||||
# 🔧 位置回溯:老接口也触发(父任务优先)
|
||||
await _recalc_product_location(db, task.product_id, task_id)
|
||||
|
||||
1
backend/scripts/__init__.py
Normal file
1
backend/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""一次性运维脚本集合(按模块方式运行:python -m scripts.<name>)"""
|
||||
84
backend/scripts/fix_product_status.py
Normal file
84
backend/scripts/fix_product_status.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""一次性数据清洗 — 修复 Product.status 与 overall_status 脱节的历史存量
|
||||
|
||||
背景
|
||||
----
|
||||
app/core/lifecycle.py 记录了历史缺陷:早期 receive_task / transfer_task 只改
|
||||
overall_status、不改 status,导致部分设备的 status 残留为 'pending' / 'OUTBOUND'
|
||||
等旧值。
|
||||
|
||||
现状(重要)
|
||||
----------
|
||||
所有 overall_status 的写入点**都已补上 sync_product_status()**:
|
||||
task_service.py:387 / :691 / :1007、product_service.py:512、
|
||||
webhooks.py:84 / :256、product_finalize_service.py:107
|
||||
因此新数据不会再脱节,本脚本只处理历史存量。
|
||||
|
||||
影响面
|
||||
------
|
||||
前端列表筛选走的是 macro_status(由 overall_status 实时派生,见
|
||||
product_service._resolve_macro_status),所以这批脏数据**基本不影响页面展示**。
|
||||
但 _mark_after_sales_if_reactivated 等逻辑会读 product.status 判断"是否出库回流",
|
||||
脏值可能引发误判 —— 故仍需清洗。
|
||||
|
||||
用法
|
||||
----
|
||||
python -m scripts.fix_product_status # 干跑:只打印待修复清单
|
||||
python -m scripts.fix_product_status --apply # 确认后真正写库
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.lifecycle import overall_to_product_status
|
||||
from app.models.product import Product
|
||||
|
||||
|
||||
async def main(apply: bool) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
products = (
|
||||
await db.execute(select(Product).order_by(Product.serial_number))
|
||||
).scalars().all()
|
||||
|
||||
fixes: list[tuple[Product, str, str]] = []
|
||||
for p in products:
|
||||
expected = overall_to_product_status(p.overall_status)
|
||||
current = (p.status or "").strip()
|
||||
# 大小写不敏感比较:历史数据里存在小写 'pending'
|
||||
if current.upper() == expected:
|
||||
continue
|
||||
fixes.append((p, current or "(空)", expected))
|
||||
|
||||
print(f"扫描 {len(products)} 台设备,需修复 {len(fixes)} 台\n")
|
||||
|
||||
if fixes:
|
||||
header = f"{'序列号':<18}{'overall_status':<16}{'当前 status':<14}→ 目标 status"
|
||||
print(header)
|
||||
print("-" * len(header) * 2)
|
||||
for p, cur, exp in fixes:
|
||||
overall = p.overall_status or "(NULL)"
|
||||
phase = p.lifecycle_phase or "-"
|
||||
print(f"{p.serial_number:<18}{overall:<16}{cur:<14}→ {exp} [{phase}]")
|
||||
else:
|
||||
print("所有设备的 status 均已与 overall_status 对齐。")
|
||||
|
||||
if not apply:
|
||||
print("\n[干跑] 未写库。确认清单无误后,加 --apply 执行。")
|
||||
return
|
||||
|
||||
if not fixes:
|
||||
return
|
||||
|
||||
for p, _cur, exp in fixes:
|
||||
p.status = exp
|
||||
await db.commit()
|
||||
print(f"\n[已提交] {len(fixes)} 台设备的 status 已对齐到 overall_status。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="对齐 Product.status 与 overall_status 的历史脏数据")
|
||||
parser.add_argument("--apply", action="store_true", help="真正写库(默认仅干跑)")
|
||||
asyncio.run(main(parser.parse_args().apply))
|
||||
@ -41,7 +41,11 @@ services:
|
||||
DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db:5432/track_production
|
||||
SECRET_KEY: change-me-in-production
|
||||
DEBUG: "true"
|
||||
CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","tauri://localhost"]'
|
||||
# 8010 = PC 管理端;5173/8020 = track-uniapp 的 H5 调试服务
|
||||
# (HBuilderX「运行到浏览器」起在 5173,直接跑 vite 按 track-uniapp/vite.config.ts 是 8020)。
|
||||
# ⚠️ 漏了 uniapp 的来源时,浏览器会在 CORS 预检阶段就掐掉请求,
|
||||
# 表现是「点登录没反应」,且控制台只报一句笼统的 404/400。
|
||||
CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","http://localhost:5173","https://localhost:5173","http://192.168.9.80:5173","https://192.168.9.80:5173","http://localhost:8020","https://localhost:8020","tauri://localhost"]'
|
||||
# 🚀 MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不再写死 IP
|
||||
MOM_DB_HOST: inventory_db
|
||||
MOM_DB_PORT: "5432"
|
||||
|
||||
@ -2,6 +2,24 @@ import { Suspense, lazy } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { App as AntApp, ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import dayjs from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
|
||||
// ── dayjs 中文 locale ────────────────────────────────────────────────
|
||||
// 必须设在应用根模块,不能下放到各页面。
|
||||
//
|
||||
// antd 的 ConfigProvider locale 只负责「请选择日期 / 今天 / 此刻」这类**文案**;
|
||||
// 日期面板上的**月份与星期名**是 rc-picker 交给 dayjs 取的 ——
|
||||
// @rc-component/picker/es/generate/dayjs.js 里:
|
||||
// getShortWeekDays: locale => dayjs().locale(parseLocale(locale)).localeData().weekdaysMin()
|
||||
// parseLocale 把 antd 传来的 "zh_CN"(下划线)在 localeMap 未命中后按 '_' 切分,
|
||||
// 得到 "zh",最终查的是 dayjs 的 locale 表 —— 不注册就永远是默认的 en。
|
||||
//
|
||||
// 此前这两行被分别写在 AdminPeoplePage / AnalyticsDashboard 两个页面里,而路由是
|
||||
// lazy() 代码分割:直接打开 /admin/dashboard 或 /admin/matrix 时那两个模块根本没
|
||||
// 被加载,dayjs 仍是 en,面板就露出 Sep / Su / Mo —— 表现为「有的页面中文、有的
|
||||
// 页面英文」。放到根模块可保证任何路由下都先生效。
|
||||
dayjs.locale("zh-cn");
|
||||
|
||||
import { ToastProvider } from "./components/ui/Toast";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
@ -25,6 +43,7 @@ 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"));
|
||||
const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard"));
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@ -47,6 +66,9 @@ export default function App() {
|
||||
{/* PC 管理端 — 登录页(独立,无侧边栏) */}
|
||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
||||
|
||||
{/* PC 管理端 — 管理层数据大屏(独立全屏,无侧边栏,组件内自带鉴权守卫) */}
|
||||
<Route path="/admin/screen" element={<ScreenDashboard />} />
|
||||
|
||||
{/* PC 管理端 — 需要登录 */}
|
||||
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
* 支持 onEvents(常规事件)与 onZrClick(ZRender 底层点击,扩大热区)。 */
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts/core";
|
||||
import { LineChart, BarChart, CustomChart } from "echarts/charts";
|
||||
import { LineChart, BarChart, CustomChart, GaugeChart } from "echarts/charts";
|
||||
import {
|
||||
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent,
|
||||
GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, GraphicComponent,
|
||||
} from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
@ -14,10 +14,12 @@ echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
CustomChart,
|
||||
GaugeChart, // 仪表盘(预留,当前大屏未使用)
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
DataZoomComponent,
|
||||
GraphicComponent, // 大屏饼图中心文字(graphic 需显式注册,否则静默不渲染)
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
|
||||
@ -73,6 +73,7 @@ export default function QrScanner({
|
||||
|
||||
// 3. 创建全新实例
|
||||
const scanner = new Html5Qrcode(SCANNER_ID, {
|
||||
verbose: false,
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
formatsToSupport: [
|
||||
Html5QrcodeSupportedFormats.CODE_128,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { memo, useMemo, useState, useEffect } from "react";
|
||||
import { Image } from "antd";
|
||||
import { FileText, Package, User, X } from "lucide-react";
|
||||
import { Package, User, X } from "lucide-react";
|
||||
import type { TaskResponse } from "../../types/api";
|
||||
import { TASK_STATUS } from "../../types/api";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
@ -87,14 +87,13 @@ const SIZE_MAP = {
|
||||
// 极简卡片
|
||||
// ============================================================
|
||||
const SlimCard = memo(function SlimCard({
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm", assigneeNames,
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm",
|
||||
}: {
|
||||
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
|
||||
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
|
||||
onViewRecords?: (t: TaskResponse) => void;
|
||||
rootMainId?: string;
|
||||
size?: "sm" | "lg";
|
||||
assigneeNames?: Record<string, string>;
|
||||
}) {
|
||||
const cfg = getStatusConfig(task.status);
|
||||
const sz = SIZE_MAP[size];
|
||||
@ -146,6 +145,13 @@ const SlimCard = memo(function SlimCard({
|
||||
⏰ {fmtTime(task.created_at)}
|
||||
{task.completed_at ? ` → ${fmtTime(task.completed_at)}` : " → 至今"}
|
||||
</p>
|
||||
{/* 驳回/返工原因:原 REJECTED 取 reject_reason;返工节点取含“驳回/返工”的备注 */}
|
||||
{(() => {
|
||||
const rj = task.reject_reason;
|
||||
const rm = task.remark;
|
||||
const txt = rj ? `❌ 驳回: ${rj}` : (rm && /驳回|返工/.test(rm) ? rm : null);
|
||||
return txt ? <p className={`${sz.sub} text-red-500 whitespace-pre-wrap`}>{txt}</p> : null;
|
||||
})()}
|
||||
{legacy && <p className={`${sz.sub} text-orange-500`}>源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
|
||||
{isNestedSpawn && <p className={`${sz.sub} text-purple-500`}>协助: {findParent(task)?.assignee_id || "—"}</p>}
|
||||
{/* 操作按钮 */}
|
||||
@ -225,7 +231,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
const arrow = side === 'left'
|
||||
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
|
||||
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>);
|
||||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} assigneeNames={assigneeNames} />;
|
||||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} />;
|
||||
const kidsContainer = kids.length > 0 ? (
|
||||
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
|
||||
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
|
||||
@ -292,7 +298,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
{/* 中央 */}
|
||||
<div className="shrink-0 z-10 relative">
|
||||
<SlimCard task={mainTask} active={active(mainTask.status)}
|
||||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} assigneeNames={assigneeNames} />
|
||||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} />
|
||||
{active(mainTask.status) && (
|
||||
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
|
||||
)}
|
||||
|
||||
@ -19,7 +19,10 @@ import {
|
||||
} from "../../services/taskApi";
|
||||
import { useToast } from "../ui/Toast";
|
||||
import type { ProductScanResponse, TaskResponse } from "../../types/api";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
import { getStatusConfig, isAdminRole } from "../../constants/task";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import ImageUploader from "../ui/ImageUploader";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
|
||||
// ============================================================
|
||||
// 通用 Modal 容器
|
||||
@ -142,9 +145,10 @@ export const RejectModal = memo(function RejectModal({
|
||||
task: TaskResponse | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (reason: string) => void;
|
||||
onSubmit: (reason: string, images: string[]) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
@ -152,12 +156,15 @@ export const RejectModal = memo(function RejectModal({
|
||||
e.preventDefault();
|
||||
const trimmed = reason.trim();
|
||||
if (!trimmed) return;
|
||||
onSubmit(trimmed);
|
||||
// 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交
|
||||
onSubmit(trimmed, images);
|
||||
setReason("");
|
||||
setImages([]);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setReason("");
|
||||
setImages([]);
|
||||
onClose();
|
||||
}
|
||||
|
||||
@ -179,29 +186,37 @@ export const RejectModal = memo(function RejectModal({
|
||||
placeholder="请详细说明品质驳回的原因(必填)"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="mb-4 w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
|
||||
className="w-full resize-none rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">{reason.length}/500</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !reason.trim()}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认驳回
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3 mt-1 text-right text-xs text-gray-400">{reason.length}/500</div>
|
||||
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
异常图片 <span className="text-xs font-normal text-gray-400">(选填,最多 9 张)</span>
|
||||
</label>
|
||||
<ImageUploader
|
||||
value={images}
|
||||
onChange={setImages}
|
||||
disabled={submitting}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !reason.trim()}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认驳回
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@ -223,14 +238,45 @@ export const TransferModal = memo(function TransferModal({
|
||||
task: TaskResponse | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (nextTaskName: string, assignees: string[], note: string) => void;
|
||||
onSubmit: (
|
||||
nextTaskName: string,
|
||||
assignees: string[],
|
||||
note: string,
|
||||
finishDirectly: boolean
|
||||
) => void;
|
||||
}) {
|
||||
const [nextTaskName, setNextTaskName] = useState("");
|
||||
const [assigneeInput, setAssigneeInput] = useState("");
|
||||
const [assignees, setAssignees] = useState<string[]>([]);
|
||||
const [useWarehouse, setUseWarehouse] = useState(false);
|
||||
// 🏁 直接完结:无下游、不入库。与「转交个人 / 入库」互斥
|
||||
const [finishDirectly, setFinishDirectly] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
// 🔄 每次打开都重置:TransferModal 被 memo 后常驻挂载(task 为 null 时只是 return null),
|
||||
// 状态会跨次打开残留,必须显式清零。
|
||||
// ⚠️ 必须放在 `if (!task) return null` 之前 —— 否则是条件提前返回后再调 Hook,
|
||||
// 违反 Rules of Hooks,task 从 null 变非 null 时 Hook 数量错位会直接崩。
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setNextTaskName("");
|
||||
setAssigneeInput("");
|
||||
setAssignees([]);
|
||||
setUseWarehouse(false);
|
||||
setFinishDirectly(false);
|
||||
setNote("");
|
||||
}, [open]);
|
||||
|
||||
// 🔒 直接完结入口仅超管/主管【可见】:普通人看不到这个选项,而不是点了才被后端 403。
|
||||
// 后端那道硬拦截依然保留 —— 这里是体验层,角色来自客户端存储、可被伪造。
|
||||
const { user } = useAuth();
|
||||
const canFinishDirectly = isAdminRole(user?.role);
|
||||
|
||||
// 防御:非管理员若因任何原因残留了选中态,强制清掉,避免提交出 finish_directly
|
||||
useEffect(() => {
|
||||
if (!canFinishDirectly) setFinishDirectly(false);
|
||||
}, [canFinishDirectly]);
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
function handleAddAssignee() {
|
||||
@ -257,44 +303,80 @@ export const TransferModal = memo(function TransferModal({
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = nextTaskName.trim();
|
||||
if (!name) return;
|
||||
|
||||
const finalAssignees = [...assignees];
|
||||
if (useWarehouse) {
|
||||
finalAssignees.push("virtual_warehouse");
|
||||
// 🏁 直接完结:不需要工序名与接收人,只闭环当前任务
|
||||
if (!finishDirectly) {
|
||||
const name = nextTaskName.trim();
|
||||
if (!name) return;
|
||||
|
||||
const finalAssignees = [...assignees];
|
||||
if (useWarehouse) {
|
||||
finalAssignees.push("virtual_warehouse");
|
||||
}
|
||||
if (finalAssignees.length === 0) return;
|
||||
|
||||
onSubmit(name, finalAssignees, note.trim(), false);
|
||||
} else {
|
||||
onSubmit("", [], note.trim(), true);
|
||||
}
|
||||
if (finalAssignees.length === 0) return;
|
||||
// ⚠️ 此处刻意不重置表单:请求是异步的,父组件要等 await 结束才关闭弹窗。
|
||||
// 若在此清空,提交等待期间标题/预览会从「直接完结」闪回普通转交。
|
||||
// 统一改由下方的「打开时重置」负责。
|
||||
}
|
||||
|
||||
onSubmit(name, finalAssignees, note.trim());
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
setNextTaskName("");
|
||||
setAssignees([]);
|
||||
setAssigneeInput("");
|
||||
setUseWarehouse(false);
|
||||
setFinishDirectly(false);
|
||||
setNote("");
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setNextTaskName("");
|
||||
setAssignees([]);
|
||||
setAssigneeInput("");
|
||||
setUseWarehouse(false);
|
||||
setNote("");
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
// 🏁 直接完结时,下游相关字段全部失效(禁用而非清空,便于用户反悔切回)
|
||||
const downstreamDisabled = finishDirectly;
|
||||
// 互斥:勾选直接完结即撤掉入库,避免两个开关同时生效
|
||||
function handleToggleFinishDirectly(checked: boolean) {
|
||||
setFinishDirectly(checked);
|
||||
if (checked) setUseWarehouse(false);
|
||||
}
|
||||
|
||||
const allAssignees = [
|
||||
...assignees,
|
||||
...(useWarehouse ? ["virtual_warehouse"] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title="完工转交 — 裂变到下道工序">
|
||||
<div className="mb-4 rounded-lg bg-green-50 px-3 py-2.5 text-sm text-green-700">
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
finishDirectly
|
||||
? "完工转交 — 🏁 直接完结(无下游,不入库)"
|
||||
: "完工转交 — 裂变到下道工序"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`mb-4 rounded-lg px-3 py-2.5 text-sm ${
|
||||
finishDirectly
|
||||
? "bg-amber-50 text-amber-700"
|
||||
: "bg-green-50 text-green-700"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium">{task.task_name}</p>
|
||||
<p className="mt-0.5 text-xs text-green-500">
|
||||
完成任务并批量创建下一道工序任务,支持多路裂变分支
|
||||
<p
|
||||
className={`mt-0.5 text-xs ${
|
||||
finishDirectly ? "text-amber-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{finishDirectly
|
||||
? "结束本任务且不创建下游任务,产品状态保持原样"
|
||||
: "完成任务并批量创建下一道工序任务,支持多路裂变分支"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -310,7 +392,8 @@ export const TransferModal = memo(function TransferModal({
|
||||
onChange={(e) => setNextTaskName(e.target.value)}
|
||||
placeholder="如:组装、质检、包装"
|
||||
maxLength={200}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
|
||||
disabled={downstreamDisabled}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@ -361,12 +444,13 @@ export const TransferModal = memo(function TransferModal({
|
||||
onKeyDown={handleAssigneeKeyDown}
|
||||
placeholder="输入接收人 ID,按 Enter 添加"
|
||||
maxLength={64}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100"
|
||||
disabled={downstreamDisabled}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
disabled={!assigneeInput.trim() || downstreamDisabled}
|
||||
className="flex items-center gap-1 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
@ -376,11 +460,16 @@ export const TransferModal = memo(function TransferModal({
|
||||
</div>
|
||||
|
||||
{/* 虚拟入库 */}
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<label
|
||||
className={`flex items-center gap-2 select-none ${
|
||||
downstreamDisabled ? "cursor-not-allowed opacity-40" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useWarehouse}
|
||||
onChange={(e) => setUseWarehouse(e.target.checked)}
|
||||
disabled={downstreamDisabled}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<Warehouse className="h-4 w-4 text-purple-500" />
|
||||
@ -389,6 +478,32 @@ export const TransferModal = memo(function TransferModal({
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* 🏁 直接完结 — 与转交/入库互斥。仅超管/主管可见 */}
|
||||
{canFinishDirectly && (
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={finishDirectly}
|
||||
onChange={(e) => handleToggleFinishDirectly(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
🏁 直接完结(无下游,不入库)
|
||||
</span>
|
||||
</label>
|
||||
{finishDirectly && (
|
||||
<p className="mt-1.5 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
任务将直接完结,不创建任何下游任务。
|
||||
<span className="font-semibold">它只是一个任务闭环动作,不改动产品状态</span>
|
||||
:已出库的设备完结后依然是<span className="font-semibold">「已出库」</span>
|
||||
,回归 WIP 矩阵的已出库列;不会变成「待仓库收货」,也无需仓库扫码实收。
|
||||
适用于售后返厂直接发走、半成品被直接提走等无需入库的收官场景。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 备注 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
@ -403,8 +518,21 @@ export const TransferModal = memo(function TransferModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预览 — 🏁 直接完结走独立文案,不能套用「将创建 N 个任务」模板 */}
|
||||
{finishDirectly && (
|
||||
<div className="rounded-lg bg-amber-50 px-3 py-2.5 text-xs text-amber-700">
|
||||
<p className="font-medium">🏁 直接完结预览:</p>
|
||||
<p className="mt-1">
|
||||
任务「<span className="font-semibold">{task.task_name}</span>
|
||||
」将直接结束,
|
||||
<span className="font-semibold">不创建任何下游任务</span>
|
||||
;产品宏观状态与当前位置保持不变。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
{allAssignees.length > 0 && nextTaskName.trim() && (
|
||||
{!finishDirectly && allAssignees.length > 0 && nextTaskName.trim() && (
|
||||
<div className="rounded-lg bg-gray-50 px-3 py-2.5 text-xs text-gray-500">
|
||||
<p className="font-medium text-gray-600">裂变预览:</p>
|
||||
<p className="mt-1">
|
||||
@ -446,13 +574,17 @@ export const TransferModal = memo(function TransferModal({
|
||||
type="submit"
|
||||
disabled={
|
||||
submitting ||
|
||||
!nextTaskName.trim() ||
|
||||
allAssignees.length === 0
|
||||
(!finishDirectly &&
|
||||
(!nextTaskName.trim() || allAssignees.length === 0))
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
|
||||
className={`flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white disabled:opacity-50 ${
|
||||
finishDirectly
|
||||
? "bg-amber-600 hover:bg-amber-700"
|
||||
: "bg-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
确认转交
|
||||
{finishDirectly ? "🏁 确认直接完结" : "确认转交"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -517,9 +649,7 @@ export default function TaskTreeViewer() {
|
||||
const msg =
|
||||
err?.response?.status === 404
|
||||
? `未找到产品身份证 ${trimmed} 对应的产品`
|
||||
: err?.response?.data?.detail ??
|
||||
err?.message ??
|
||||
"查询失败,请检查后端服务";
|
||||
: extractErrorMessage(err, "查询失败,请检查后端服务");
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -537,28 +667,22 @@ export default function TaskTreeViewer() {
|
||||
setModalTarget(null);
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
toast(
|
||||
err?.response?.data?.detail ?? err?.message ?? "接收失败",
|
||||
"error"
|
||||
);
|
||||
toast(extractErrorMessage(err, "接收失败"), "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(reason: string) {
|
||||
async function handleReject(reason: string, images: string[]) {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await rejectTask(modalTarget.task.id, reason);
|
||||
await rejectTask(modalTarget.task.id, reason, images);
|
||||
toast("任务已驳回,已自动创建返工任务", "success");
|
||||
setModalTarget(null);
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
toast(
|
||||
err?.response?.data?.detail ?? err?.message ?? "驳回失败",
|
||||
"error"
|
||||
);
|
||||
toast(extractErrorMessage(err, "驳回失败"), "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -567,7 +691,8 @@ export default function TaskTreeViewer() {
|
||||
async function handleTransfer(
|
||||
nextTaskName: string,
|
||||
assignees: string[],
|
||||
note: string
|
||||
note: string,
|
||||
finishDirectly: boolean
|
||||
) {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
@ -576,16 +701,14 @@ export default function TaskTreeViewer() {
|
||||
modalTarget.task.id,
|
||||
assignees,
|
||||
nextTaskName,
|
||||
note || undefined
|
||||
note || undefined,
|
||||
finishDirectly
|
||||
);
|
||||
toast(result.message, "success");
|
||||
setModalTarget(null);
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
toast(
|
||||
err?.response?.data?.detail ?? err?.message ?? "转交失败",
|
||||
"error"
|
||||
);
|
||||
toast(extractErrorMessage(err, "转交失败"), "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
114
frontend/src/components/admin/UserOperationDetailDrawer.tsx
Normal file
114
frontend/src/components/admin/UserOperationDetailDrawer.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
/** 人员操作明细抽屉 — 数据源 /dashboard/user-operations/detail
|
||||
*
|
||||
* AdminDashboard(全局概览 · 人员操作统计)与 ScreenDashboard(大屏 · 系统活跃度)
|
||||
* 共用同一实现,保证两处的下钻交互与呈现完全一致。
|
||||
*
|
||||
* 用法:ref.open({ userId, userName, actionType, label, since, until })
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { fetchOperationDetail, type OperationDetail } from "../../services/dashboardApi";
|
||||
|
||||
export interface OperationDetailRequest {
|
||||
userId: string;
|
||||
userName: string;
|
||||
/** 操作类型: receive / transfer / record */
|
||||
actionType: string;
|
||||
/** 类型中文名(接收 / 转交 / 上传备注),用于标题 */
|
||||
label: string;
|
||||
/** 时间范围 ISO;缺省则不加时间过滤 */
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface UserOperationDetailDrawerHandle {
|
||||
open: (req: OperationDetailRequest) => void;
|
||||
}
|
||||
|
||||
const UserOperationDetailDrawer = forwardRef<
|
||||
UserOperationDetailDrawerHandle,
|
||||
{ theme?: "light" | "dark" }
|
||||
>(function UserOperationDetailDrawer({ theme = "light" }, ref) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [list, setList] = useState<OperationDetail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const dark = theme === "dark";
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: (req) => {
|
||||
setOpen(true);
|
||||
setTitle(`${req.userName} · ${req.label}`);
|
||||
setLoading(true);
|
||||
fetchOperationDetail(req.userId, req.actionType, req.since, req.until)
|
||||
.then(setList)
|
||||
.catch(() => setList([]))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<span className={`text-base font-bold ${dark ? "text-slate-100" : ""}`}>
|
||||
📋 {title}
|
||||
<span className={`ml-1 font-normal ${dark ? "text-slate-500" : "text-gray-400"}`}>
|
||||
{list.length} 条
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
size="large"
|
||||
styles={{
|
||||
header: dark ? { background: "#0f172a", borderBottom: "1px solid #1e293b" } : undefined,
|
||||
content: dark ? { background: "#0b1220" } : undefined,
|
||||
body: { padding: 16, background: dark ? "#0b1220" : "#f8fafc" },
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className={`h-6 w-6 animate-spin ${dark ? "text-cyan-500" : "text-blue-500"}`} />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className={`py-16 text-center text-sm ${dark ? "text-slate-600" : "text-gray-400"}`}>
|
||||
该时段暂无相关记录
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{list.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`mb-2 rounded-lg border px-4 py-3 shadow-sm ${
|
||||
dark ? "border-slate-800 bg-slate-900/50" : "border-gray-100 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-semibold ${dark ? "text-slate-100" : "text-gray-800"}`}>
|
||||
{d.task_name}
|
||||
</span>
|
||||
<span className={`shrink-0 text-xs ${dark ? "text-slate-500" : "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 ${dark ? "text-slate-300" : "text-gray-600"}`}>
|
||||
{d.remark}
|
||||
</p>
|
||||
)}
|
||||
<div className={`mt-1.5 text-[11px] ${dark ? "text-slate-500" : "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>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserOperationDetailDrawer;
|
||||
@ -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, Table2 } from "lucide-react";
|
||||
import { QrCode, Package, ArrowLeft, LayoutDashboard, Smartphone, GitBranch, LogOut, User, Users, BarChart3, Table2, Tv } from "lucide-react";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
const MENU = [
|
||||
@ -39,6 +39,12 @@ const MENU = [
|
||||
icon: Table2,
|
||||
description: "规格型号 × 人员/工序 在制品透视表",
|
||||
},
|
||||
{
|
||||
title: "管理层大屏",
|
||||
path: "/admin/screen",
|
||||
icon: Tv,
|
||||
description: "全屏数据大盘 · 直通率 / 产量趋势 / 不良分布",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/** 产品信息卡片 */
|
||||
import { Package } from "lucide-react";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import { statusColor, statusLabel } from "../../constants/task";
|
||||
import { lifecycleBadge, statusColor, statusLabel } from "../../constants/task";
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductScanResponse;
|
||||
@ -10,6 +10,8 @@ interface ProductCardProps {
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
// 优先显示宏观状态(整体流转),回退到产品状态字段
|
||||
const displayStatus = product.overall_status || product.status;
|
||||
// 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示
|
||||
const lifeBadge = lifecycleBadge(product.overall_status, product.lifecycle_phase);
|
||||
return (
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
@ -18,6 +20,11 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
<span className={`ml-auto rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColor(displayStatus)}`}>
|
||||
{statusLabel(displayStatus)}
|
||||
</span>
|
||||
{lifeBadge && (
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold ${lifeBadge.className}`}>
|
||||
{lifeBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/** 任务进度列表卡片 — 递归渲染 task_tree */
|
||||
import { useMemo, memo } from "react";
|
||||
import { ClipboardList, GitBranch, AlertTriangle } from "lucide-react";
|
||||
import type { TaskResponse, TaskSummary } from "../../types/api";
|
||||
import type { TaskResponse } from "../../types/api";
|
||||
import { statusColor, statusLabel } from "../../constants/task";
|
||||
|
||||
interface TaskListCardProps {
|
||||
|
||||
133
frontend/src/components/ui/ImageUploader.tsx
Normal file
133
frontend/src/components/ui/ImageUploader.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
/** 图片上传器 — 选图后直传 /api/v1/upload/,回填可访问的 URL 列表
|
||||
*
|
||||
* 用途:品质驳回弹窗(后端 TaskRejectRequest.images 为选填,可不上传)。
|
||||
* 样式与 RejectModal 的原生控件保持一致(非 antd),支持缩略图预览与移除。
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import { ImagePlus, Loader2, X } from "lucide-react";
|
||||
import api from "../../services/api";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
|
||||
// 与后端 TaskRejectRequest.images 的 max_length 对齐
|
||||
const MAX_IMAGES = 9;
|
||||
|
||||
interface ImageUploaderProps {
|
||||
value: string[];
|
||||
onChange: (urls: string[]) => void;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ImageUploader({
|
||||
value,
|
||||
onChange,
|
||||
max = MAX_IMAGES,
|
||||
disabled,
|
||||
}: ImageUploaderProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
setError("");
|
||||
|
||||
const slots = max - value.length;
|
||||
if (slots <= 0) {
|
||||
setError(`最多上传 ${max} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const picked = Array.from(files).slice(0, slots);
|
||||
const notImage = picked.find((f) => !f.type.startsWith("image/"));
|
||||
if (notImage) {
|
||||
setError(`「${notImage.name}」不是图片文件`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded: string[] = [];
|
||||
for (const file of picked) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
// api 实例默认 Content-Type 为 application/json,此处必须覆盖为 multipart
|
||||
const { data } = await api.post<{ url: string }>("/upload/", form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 60000, // 图片上传比普通请求慢,放宽超时
|
||||
});
|
||||
uploaded.push(data.url);
|
||||
}
|
||||
onChange([...value, ...uploaded]);
|
||||
} catch (err: any) {
|
||||
setError(extractErrorMessage(err, "上传失败,请重试"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// 清空 input,允许重复选择同一文件
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeAt(idx: number) {
|
||||
onChange(value.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
const full = value.length >= max;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{value.map((url, i) => (
|
||||
<div
|
||||
key={`${url}-${i}`}
|
||||
className="group relative h-20 w-20 overflow-hidden rounded-lg border border-gray-200"
|
||||
>
|
||||
<img src={url} alt={`异常图片 ${i + 1}`} className="h-full w-full object-cover" />
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
title="移除"
|
||||
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!full && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex h-20 w-20 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-gray-300 text-gray-400 transition-colors hover:border-red-400 hover:text-red-500 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
)}
|
||||
<span className="text-[11px]">{uploading ? "上传中" : "添加图片"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400">
|
||||
{value.length}/{max}
|
||||
</span>
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
* 供 TaskTreeViewer / TaskListCard / ProductCard 等组件共用,
|
||||
* 避免在多处重复维护相同的映射逻辑。
|
||||
*/
|
||||
import { TASK_STATUS } from "../types/api";
|
||||
import { TASK_STATUS, type LifecyclePhase } from "../types/api";
|
||||
|
||||
export interface StatusStyle {
|
||||
bg: string;
|
||||
@ -112,6 +112,22 @@ export const STATUS_CONFIG: Record<string, StatusStyle> = {
|
||||
ring: "ring-blue-400",
|
||||
label: "维修",
|
||||
},
|
||||
// ---- 售后回流阶段专属工序(紫系)----
|
||||
// 选紫色而非红色:红色在本系统里是「驳回 / 危险」的语义色,售后只是另一条
|
||||
// 流转支线而非异常,用红色会让操作员误以为设备报错。也不选靛蓝——那已被
|
||||
// 「已出库 OUTBOUND」占用,两者相邻显示会混淆。
|
||||
发货测试: {
|
||||
bg: "bg-purple-50",
|
||||
text: "text-purple-700",
|
||||
ring: "ring-purple-300",
|
||||
label: "发货测试",
|
||||
},
|
||||
售后维修: {
|
||||
bg: "bg-purple-50",
|
||||
text: "text-purple-700",
|
||||
ring: "ring-purple-300",
|
||||
label: "售后维修",
|
||||
},
|
||||
};
|
||||
|
||||
const FALLBACK: StatusStyle = {
|
||||
@ -137,3 +153,127 @@ export function statusColor(status: string): string {
|
||||
export function statusLabel(status: string): string {
|
||||
return getStatusConfig(status).label;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 🔧 生命周期阶段(lifecycle_phase)与工序选项隔离
|
||||
//
|
||||
// 生产制造(PRODUCTION)与售后回流(AFTER_SALES)各有一套合法工序名。
|
||||
// ⚠️ 本段与后端 app/core/lifecycle.py 是同一份口径,改动请两边同步。
|
||||
//
|
||||
// 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
|
||||
// 「测试 / 维修」—— 这样看板、下钻、导出无需 JOIN lifecycle_phase 即可区分。
|
||||
// ============================================================
|
||||
|
||||
export const LIFECYCLE_PHASE = {
|
||||
PRODUCTION: "PRODUCTION",
|
||||
AFTER_SALES: "AFTER_SALES",
|
||||
} as const satisfies Record<string, LifecyclePhase>;
|
||||
|
||||
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
|
||||
export const STEP_SHIP_TEST = "发货测试";
|
||||
export const STEP_AFTER_SALES_REPAIR = "售后维修";
|
||||
|
||||
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
|
||||
|
||||
// ── 生产制造阶段合法工序(原有词表,一字未改)──
|
||||
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
export const PRODUCTION_OVERALL_OPTIONS = [
|
||||
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
|
||||
];
|
||||
|
||||
// ── 售后回流阶段合法工序:只保留「发货测试 / 售后维修 / 入库出库」──
|
||||
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
|
||||
export const AFTER_SALES_OVERALL_OPTIONS = [
|
||||
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
|
||||
];
|
||||
|
||||
/**
|
||||
* 按生命周期阶段取「可选工序」——选项隔离的 UI 侧实现。
|
||||
*
|
||||
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**,
|
||||
* 让操作员自行声明这是生产设备还是售后回流设备:一旦选中售后专属工序,
|
||||
* 后端即把设备判为 AFTER_SALES,之后下拉就只剩售后选项。
|
||||
*
|
||||
* 注意:这只是体验层的第一道防线,真正的拦截在后端
|
||||
* task_service._enforce_step_isolation(防伪造传参)。
|
||||
*/
|
||||
export function taskOptionsFor(
|
||||
phase: string | null | undefined,
|
||||
hasHistory = true,
|
||||
overallStatus?: string | null,
|
||||
): string[] {
|
||||
// 🔧 出库设备与售后生命周期同等待遇(与 track-uniapp/src/utils/lifecycle.js 同步):
|
||||
// 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备翻成
|
||||
// AFTER_SALES。若这里仍只认 phase,工人永远选不到「发货测试 / 售后维修」。
|
||||
if (
|
||||
phase === LIFECYCLE_PHASE.AFTER_SALES ||
|
||||
String(overallStatus || "").trim() === "已出库"
|
||||
) {
|
||||
return AFTER_SALES_TASK_OPTIONS;
|
||||
}
|
||||
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
|
||||
return PRODUCTION_TASK_OPTIONS;
|
||||
}
|
||||
|
||||
/** 按生命周期阶段取「可选宏观状态」— 同上,含已入库/已出库等终态 */
|
||||
export function overallOptionsFor(
|
||||
phase: string | null | undefined,
|
||||
hasHistory = true,
|
||||
): string[] {
|
||||
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
|
||||
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
|
||||
return PRODUCTION_OVERALL_OPTIONS;
|
||||
}
|
||||
|
||||
/** 列表筛选枚举 — 两阶段并集(用于筛选,不是录入项) */
|
||||
/**
|
||||
* 管理角色 — 必须与后端 task_service.ADMIN_ROLES 保持一致。
|
||||
*
|
||||
* ⚠️ 收敛到这里的理由:此前这段判断散落在多处(TaskFlowView / AdminProductsPage),
|
||||
* 而移动端那份只判了 SUPER_ADMIN、漏了 SUPERVISOR,导致主管被前端误挡。
|
||||
* 新增判断一律用 isAdminRole(),不要再手写 === 比较。
|
||||
*
|
||||
* 注意:这是【体验层】判断,角色来自客户端存储、可被伪造。
|
||||
* 真正的拦截在后端(transfer_task 对 finish_directly 有 403 硬校验)。
|
||||
*/
|
||||
export const ADMIN_ROLES = ["SUPER_ADMIN", "SUPERVISOR"] as const;
|
||||
|
||||
export function isAdminRole(role?: string | null): boolean {
|
||||
return !!role && (ADMIN_ROLES as readonly string[]).includes(role);
|
||||
}
|
||||
|
||||
export const ALL_OVERALL_OPTIONS = [
|
||||
...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS,
|
||||
];
|
||||
|
||||
/** 售后服务相关环节 — 只有落到这些工序才打红色标签 */
|
||||
const AFTER_SALES_TAG_STEPS = new Set<string>(AFTER_SALES_ONLY_STEPS);
|
||||
|
||||
export interface LifecycleBadge {
|
||||
label: string;
|
||||
className: string;
|
||||
phase: LifecyclePhase;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期标签 — 只在设备确实处于**售后回流环节**时返回:
|
||||
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
|
||||
* → 紫底白字,文案即工序名。
|
||||
*
|
||||
* 生产制造阶段一律返回 null(不打标),避免和售后设备混淆。
|
||||
* 配色用紫色:红色在本系统是「驳回 / 危险」语义,售后只是另一条流转支线,
|
||||
* 用红色会让操作员误以为设备报错。
|
||||
*/
|
||||
export function lifecycleBadge(
|
||||
overallStatus: string | null | undefined,
|
||||
phase: string | null | undefined,
|
||||
): LifecycleBadge | null {
|
||||
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
|
||||
const step = (overallStatus || "").trim();
|
||||
if (!AFTER_SALES_TAG_STEPS.has(step)) return null;
|
||||
return {
|
||||
label: step,
|
||||
className: "bg-purple-600 text-white ring-1 ring-purple-600",
|
||||
phase: LIFECYCLE_PHASE.AFTER_SALES,
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,11 +8,22 @@ import { Loader2 } from "lucide-react";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { fetchWipMatrix, fetchWipMatrixDetail, type WipMatrixRow, type WipMatrixDetailRow } from "../services/dashboardApi";
|
||||
import { fetchDeviceRecords, type DeviceRecord } from "../services/analyticsApi";
|
||||
import { lifecycleBadge } from "../constants/task";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// 🔧 固定标准主轴:核心工序 + 状态列始终显示(即使计数为 0),表头不随操作内容增减
|
||||
const FIXED_STEP_COLUMNS = ["备货", "生产", "测试", "维修", "待确认", "已完成", "已入库", "已出库"];
|
||||
// 🔧 三区段列定义:生产区 / 售后区 / 完结区
|
||||
// 售后区必须独立成列 —— 出库回流设备的「发货测试 / 售后维修」若与生产区的
|
||||
// 「测试 / 维修」共用一列,两类设备会被加在一起,统计完全失真。
|
||||
// accent 用于给分组表头着色,售后区用紫色呼应标签配色(红=驳回语义,不占用)。
|
||||
const COLUMN_GROUPS: { title: string; keys: string[]; accent: string }[] = [
|
||||
{ title: "生产区", keys: ["待接收", "备货", "生产", "测试", "维修"], accent: "text-blue-700" },
|
||||
{ title: "售后区", keys: ["发货测试", "售后维修"], accent: "text-purple-700" },
|
||||
{ title: "完结区", keys: ["已完成", "已入库", "已出库"], accent: "text-gray-700" },
|
||||
];
|
||||
|
||||
// 固定标准主轴:三区段的全部列始终显示(即使计数为 0),表头不随操作内容增减
|
||||
const FIXED_STEP_COLUMNS = COLUMN_GROUPS.flatMap((g) => g.keys);
|
||||
|
||||
// ⏰ 时间筛选(与全局概览一致)
|
||||
type DateRangeKey = "today" | "7d" | "30d" | "custom";
|
||||
@ -42,6 +53,21 @@ function imageUrl(u: string) {
|
||||
return base + path;
|
||||
}
|
||||
|
||||
// ─── 单元格负责人聚合预览:数量解析 + TOP 折叠 ────────────────────
|
||||
function labelCount(label: string): number {
|
||||
const m = label.match(/\((\d+)\)\s*$/);
|
||||
return m ? Number(m[1]) : 1;
|
||||
}
|
||||
function summarizeAssignees(list: string[]): string {
|
||||
if (!list.length) return "";
|
||||
// 数量降序(“未分配”同样参与按台数排序),数量最多者排最前
|
||||
const sorted = [...list].sort((a, b) => labelCount(b) - labelCount(a));
|
||||
if (sorted.length <= 2) return sorted.join("、");
|
||||
// ≥3 人:只显示台数最多的第 1 人(去掉数量后缀),折叠为 “等 N 人”
|
||||
const first = sorted[0].replace(/\(\d+\)\s*$/, "");
|
||||
return `${first}等 ${sorted.length} 人`;
|
||||
}
|
||||
|
||||
export default function MatrixBoard() {
|
||||
const [dateKey, setDateKey] = useState<DateRangeKey>("today");
|
||||
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
@ -96,7 +122,18 @@ export default function MatrixBoard() {
|
||||
// 🔧 全局唯一表头 + 单表数据:固定标准主轴在前,动态工序(数据中出现的新工序)追加在后
|
||||
const { columns, dataSource, keys } = useMemo(() => {
|
||||
const dynamicKeys = Array.from(new Set(data.map((d) => d.dimension_key)));
|
||||
const keys = Array.from(new Set([...FIXED_STEP_COLUMNS, ...dynamicKeys]));
|
||||
const fixedKeys = new Set(FIXED_STEP_COLUMNS);
|
||||
const extraKeys = dynamicKeys.filter((k) => !fixedKeys.has(k));
|
||||
|
||||
// 🔧 三区段分组:自定义工序(喷漆 / 老化…)挂到生产区末尾 —— 它们是产线自建工序,
|
||||
// 与售后区那两个受控工序名不同,无法从名字反推归属,故按生产口径展示。
|
||||
// 注:售后设备的历史工序名已在后端归一(测试→发货测试 / 维修→售后维修),
|
||||
// 所以这里拿到的 dimension_key 已经是正确的区段归属。
|
||||
const groups = COLUMN_GROUPS.map((g, i) => ({
|
||||
...g,
|
||||
keys: i === 0 ? [...g.keys, ...extraKeys] : g.keys,
|
||||
}));
|
||||
const keys = groups.flatMap((g) => g.keys);
|
||||
|
||||
const rowsMap = new Map<string, any>();
|
||||
for (const item of data) {
|
||||
@ -125,31 +162,32 @@ export default function MatrixBoard() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...keys.map((key) => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
align: "center" as const,
|
||||
width: 96,
|
||||
render: (val: number, record: any) => {
|
||||
const v = val || 0;
|
||||
const assignees = record._assignees?.[key] || [];
|
||||
return (
|
||||
<div
|
||||
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
|
||||
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
|
||||
>
|
||||
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
|
||||
{assignees.length > 0 && (
|
||||
<div
|
||||
className="mx-auto max-w-[90px] truncate text-[10px] leading-tight text-gray-400"
|
||||
title={assignees.join("、")}
|
||||
>
|
||||
{assignees.join("、")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// 🔧 两行表头:第一行区段名(生产区 / 售后区 / 完结区),第二行具体工序
|
||||
...groups.map((g) => ({
|
||||
title: <span className={`text-xs font-bold ${g.accent}`}>{g.title}</span>,
|
||||
children: g.keys.map((key) => ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
align: "center" as const,
|
||||
width: 96,
|
||||
render: (val: number, record: any) => {
|
||||
const v = val || 0;
|
||||
const assignees = record._assignees?.[key] || [];
|
||||
return (
|
||||
<div
|
||||
className={`py-1 ${v > 0 ? "cursor-pointer hover:bg-blue-50" : ""}`}
|
||||
onClick={() => v > 0 && openDrill(record.spec_model, key, record.product_name)}
|
||||
>
|
||||
<div className={`text-sm font-bold leading-none ${v > 0 ? "text-blue-600" : "text-gray-800"}`}>{v}</div>
|
||||
{assignees.length > 0 && (
|
||||
<div className="mx-auto max-w-[92px] text-xs leading-tight text-gray-500">
|
||||
{summarizeAssignees(assignees)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})),
|
||||
})),
|
||||
{
|
||||
title: "合计",
|
||||
@ -169,11 +207,15 @@ export default function MatrixBoard() {
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-gray-500">生产分布透视表:规格型号 × 工序 · 含售后回流与已完结</p>
|
||||
{/* 🔴 术语解释:区分"已完成"(车间完工待实收)与"已入库"(仓库已扫码实收) */}
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
ⓘ "已完成" = 车间完工已转交仓库,等待仓库扫码实收;"已入库" = 仓库已扫码确认实收。
|
||||
</p>
|
||||
{/* 🟣 三区段说明:强调售后区独立计数,不与生产区同名工序混算 */}
|
||||
<p className="mt-1 text-xs text-purple-600">
|
||||
ⓘ 表头分三区:<b>生产区</b>(产线工序)|<b>售后区</b>(出库回流设备的「发货测试 / 售后维修」,独立计数,不并入生产区的测试 / 维修)|<b>完结区</b>。
|
||||
</p>
|
||||
</div>
|
||||
{/* ⏰ 时间筛选 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@ -273,8 +315,21 @@ export default function MatrixBoard() {
|
||||
{
|
||||
title: "产品名称",
|
||||
dataIndex: "material_name",
|
||||
width: 160,
|
||||
render: (v) => <span className="font-semibold text-blue-600">{v || "—"}</span>,
|
||||
width: 200,
|
||||
render: (v: string, record: WipMatrixDetailRow) => {
|
||||
// 🔧 发货测试 / 售后维修 — 工序取当前下钻的列名(点产品名时为全部工序,不显示)
|
||||
const lb = lifecycleBadge(drill?.process, record.lifecycle_phase);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-blue-600">{v || "—"}</span>
|
||||
{lb && (
|
||||
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
|
||||
{lb.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "身份证号",
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
/** 我的任务 — 1:1 复刻 uni-app pages/tasks/index.vue */
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../services/api";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
|
||||
@ -6,6 +6,8 @@ import { scanProduct } from "../services/productApi";
|
||||
import type { ProductScanResponse } from "../types/api";
|
||||
import ManualInput from "../components/scan/ManualInput";
|
||||
import QueryResult from "../components/scan/QueryResult";
|
||||
import { lifecycleBadge } from "../constants/task";
|
||||
import { extractErrorMessage } from "../utils/errorMessage";
|
||||
|
||||
// 🚀 CameraScanner → QrScanner → html5-qrcode (~200KB),仅在首次点开摄像头时加载
|
||||
const CameraScanner = lazy(() => import("../components/scan/CameraScanner"));
|
||||
@ -31,11 +33,7 @@ export default function ScanPage() {
|
||||
try {
|
||||
setProduct(await scanProduct(sn));
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
err && typeof err === "object" && "response" in err
|
||||
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
: null;
|
||||
setError(detail ?? "网络错误,请检查后端服务");
|
||||
setError(extractErrorMessage(err, "网络错误,请检查后端服务"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -74,6 +72,11 @@ export default function ScanPage() {
|
||||
<span className={`flex-1 text-sm font-bold ${product.overall_status === "已入库" ? "text-gray-500" : product.overall_status === "待仓库收货" ? "text-orange-600" : product.overall_status ? "text-blue-600" : "text-red-500"}`}>
|
||||
{product.overall_status || "未设定"}
|
||||
</span>
|
||||
{/* 🔧 售后回流标识:处于发货测试/售后维修环节时红底白字提示 */}
|
||||
{(() => {
|
||||
const lb = lifecycleBadge(product.overall_status, product.lifecycle_phase);
|
||||
return lb ? <span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${lb.className}`}>{lb.label}</span> : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -126,6 +129,11 @@ export default function ScanPage() {
|
||||
<span className={`flex-1 text-sm font-bold ${product.overall_status === "已入库" ? "text-gray-500" : product.overall_status === "待仓库收货" ? "text-orange-600" : product.overall_status ? "text-blue-600" : "text-red-500"}`}>
|
||||
{product.overall_status || "未设定"}
|
||||
</span>
|
||||
{/* 🔧 售后回流标识:处于发货测试/售后维修环节时红底白字提示 */}
|
||||
{(() => {
|
||||
const lb = lifecycleBadge(product.overall_status, product.lifecycle_phase);
|
||||
return lb ? <span className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold ${lb.className}`}>{lb.label}</span> : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Package, ClipboardList, Bell, TrendingUp, AlertTriangle,
|
||||
RefreshCw, Loader2, AlertCircle, ArrowRight, ArrowUp, ArrowDown, ArrowUpDown,
|
||||
Clock, MessageCircle, CheckCircle2, Users,
|
||||
Clock, MessageCircle, CheckCircle2, Users, Info,
|
||||
} from "lucide-react";
|
||||
import { Radio, DatePicker, Drawer, Input } from "antd";
|
||||
import { Radio, DatePicker, Drawer, Input, Tooltip } from "antd";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import {
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations, fetchOperationDetail,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation, type OperationDetail,
|
||||
fetchDashboardStats, fetchWipTasks, fetchDashboardMessages, fetchCompletedTasks, fetchRejectedTasks, fetchUserOperations,
|
||||
type DashboardStats, type WipTask, type ProductMessageItem, type CompletedTask, type RejectedTask, type UserOperation,
|
||||
} from "../../services/dashboardApi";
|
||||
import UserOperationDetailDrawer, {
|
||||
type UserOperationDetailDrawerHandle,
|
||||
} from "../../components/admin/UserOperationDetailDrawer";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@ -31,10 +34,16 @@ function rangeToParams(key: DateRangeKey, customRange: [Dayjs, Dayjs] | null) {
|
||||
}
|
||||
|
||||
// ─── 进度条(支持3或4段) ─────────────────────────────────
|
||||
function ProgressBar({ a, b, c, total, labels, d }: {
|
||||
function ProgressBar({ a, b, c, total, labels, d, hints }: {
|
||||
a: number; b: number; c: number; total: number;
|
||||
labels: [string, string, string];
|
||||
d?: number;
|
||||
/**
|
||||
* 图例口径说明:label → 悬浮提示文案。
|
||||
* 用于消除「产品数 > 任务数」这类口径差异带来的误判 —— 底层逻辑没问题,
|
||||
* 但不写清楚就极像算错数据。
|
||||
*/
|
||||
hints?: Record<string, string>;
|
||||
}) {
|
||||
if (total === 0) return <div className="py-4 text-center text-xs text-gray-400">暂无数据</div>;
|
||||
const pct = (n: number) => Math.round((n / total) * 100);
|
||||
@ -57,6 +66,11 @@ function ProgressBar({ a, b, c, total, labels, d }: {
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
|
||||
{s.label} {s.n}({pct(s.n)}%)
|
||||
{hints?.[s.label] && (
|
||||
<Tooltip title={hints[s.label]}>
|
||||
<Info className="h-3 w-3 shrink-0 cursor-help text-gray-400 transition-colors hover:text-gray-600" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@ -187,43 +201,34 @@ function CompletedRow({ t }: { t: CompletedTask }) {
|
||||
}
|
||||
|
||||
// ─── 被驳回明细项(卡片式) ──────────────────────────────
|
||||
/**
|
||||
* 驳回明细行。
|
||||
*
|
||||
* 只渲染「已驳回」这一种形态:一次驳回必然派生一条返工任务,两条同时成行会
|
||||
* 让看板看起来「有两条异常」。而本行已经带了「返工给: xxx」,一条就足以说清
|
||||
* 「这道工序被驳回了,现在由谁在返工」的完整语义,返工任务无需再独立成行。
|
||||
*/
|
||||
function RejectedRow({ t }: { t: RejectedTask }) {
|
||||
const timeStr = t.rejected_at ? dayjs(t.rejected_at).format("YYYY-MM-DD HH:mm") : "";
|
||||
const isRework = t.kind === "rework";
|
||||
return (
|
||||
<div
|
||||
onClick={() => t.product_sn && window.open(`/admin/tasks?sn=${t.product_sn}`, "_blank")}
|
||||
className="cursor-pointer rounded-lg border border-gray-100 bg-white px-4 py-3 transition-shadow hover:border-red-200 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isRework ? <RefreshCw className="h-4 w-4 shrink-0 text-orange-500" /> : <AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />}
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-red-500" />
|
||||
<span className="text-sm font-semibold text-gray-800 truncate">{t.task_name}</span>
|
||||
<span className="text-xs text-gray-500">|</span>
|
||||
{isRework ? (
|
||||
<span className="shrink-0 rounded-full bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
|
||||
{t.status === "WIP" ? "返工中" : t.status === "PENDING" ? "待返工" : "已返工"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 shrink-0">
|
||||
{isRework ? `返工人: ${t.rework_assignee}` : `驳回人: ${t.rejected_by}`}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600">已驳回</span>
|
||||
<span className="text-xs text-gray-500 shrink-0">驳回人: {t.rejected_by}</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-gray-400">{timeStr}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
{!isRework && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
|
||||
🔁 返工给: {t.rework_assignee}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
|
||||
</>
|
||||
)}
|
||||
{isRework && (
|
||||
<span className="truncate text-orange-600/80">🔁 返工任务 · {t.rework_assignee} 负责</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1 rounded bg-orange-50 px-1.5 py-0.5 font-medium text-orange-600">
|
||||
🔁 返工给: {t.rework_assignee}
|
||||
</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="truncate text-red-500/80" title={t.reject_reason || ""}>原因: {t.reject_reason || "—"}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<span className="font-medium text-gray-500">{t.material_name || "未知设备"}</span>
|
||||
@ -242,6 +247,9 @@ function RejectedRow({ t }: { t: RejectedTask }) {
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [wipTasks, setWipTasks] = useState<WipTask[]>([]);
|
||||
// 在制品列表的真实总数,与「任务状态」卡片的活跃任务同源(待接收 + 进行中)。
|
||||
// 用它而不是 wipTasks.length 做计数,避免接口截断时列表谎报总数。
|
||||
const activeTaskCount = (stats?.tasks_pending ?? 0) + (stats?.tasks_in_progress ?? 0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -276,11 +284,8 @@ export default function AdminDashboard() {
|
||||
// 抽屉内独立时间筛选(不依赖顶部)
|
||||
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("");
|
||||
// 操作明细下钻 — 交给共用抽屉组件(与大屏 ScreenDashboard 同一实现)
|
||||
const opDetailRef = useRef<UserOperationDetailDrawerHandle>(null);
|
||||
|
||||
// ── 加载主数据 ──
|
||||
const loadData = useCallback((key: DateRangeKey, range: [Dayjs, Dayjs] | null) => {
|
||||
@ -288,7 +293,7 @@ export default function AdminDashboard() {
|
||||
const { since, until } = rangeToParams(key, range);
|
||||
Promise.all([
|
||||
fetchDashboardStats(since, until),
|
||||
fetchWipTasks(20),
|
||||
fetchWipTasks(), // 不传 limit:取全量在制品,避免被截断后与卡片数字对不上
|
||||
])
|
||||
.then(([s, w]) => { setStats(s); setWipTasks(w); setError(null); })
|
||||
.catch(() => setError("加载失败,请确认后端已启动"))
|
||||
@ -395,14 +400,15 @@ export default function AdminDashboard() {
|
||||
|
||||
// 点击数字下钻查看明细(跟随抽屉内时间)
|
||||
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));
|
||||
opDetailRef.current?.open({
|
||||
userId: user.user_id,
|
||||
userName: user.user_name,
|
||||
actionType,
|
||||
label,
|
||||
since,
|
||||
until,
|
||||
});
|
||||
};
|
||||
|
||||
const onMsgSearch = (value: string) => {
|
||||
@ -435,7 +441,7 @@ export default function AdminDashboard() {
|
||||
<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">
|
||||
@ -468,12 +474,6 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示:已完结受时间筛选 */}
|
||||
{dateKey !== "today" && (
|
||||
<div className="rounded-lg bg-blue-50 px-3 py-1.5 text-[11px] text-blue-600">
|
||||
📐 当前时间筛选仅影响<strong>已完成/已驳回</strong>计数,在制品和总数始终为实时快照
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 4 卡片 ═══ */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@ -484,9 +484,12 @@ export default function AdminDashboard() {
|
||||
<h3 className="text-sm font-semibold text-gray-700">📦 产品流转</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-800">{stats.products_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
|
||||
<p className="mb-3 text-[11px] text-gray-400">实时快照(不受时间筛选影响)</p>
|
||||
<p className="mb-3 text-[11px] text-gray-400">在制品为实时快照;「已完结」受右上角时间筛选影响</p>
|
||||
<ProgressBar a={stats.products_pending} b={stats.products_in_progress} c={stats.products_completed}
|
||||
total={stats.products_total} labels={["待流转", "流转中", "已完结"]} />
|
||||
total={stats.products_total} labels={["待流转", "流转中", "已完结"]}
|
||||
hints={{
|
||||
"待流转": "指已建档,但尚未派发首道工序,或当前无任何活跃工序挂载的物理产品。因此待流转数可能会大于待接收任务数。",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 任务状态 */}
|
||||
@ -498,7 +501,10 @@ export default function AdminDashboard() {
|
||||
<p className="text-3xl font-bold text-gray-800">{stats.tasks_total}<span className="text-sm font-normal text-gray-400"> 个</span></p>
|
||||
<p className="mb-3 text-[11px] text-gray-400">PENDING/WIP 实时 | COMPLETED 按时段</p>
|
||||
<ProgressBar a={stats.tasks_pending} b={stats.tasks_in_progress} c={stats.tasks_completed}
|
||||
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected} />
|
||||
total={stats.tasks_total} labels={["待接收", "进行中", "已完成"]} d={stats.tasks_rejected}
|
||||
hints={{
|
||||
"待接收": "指系统已生成具体工序单并指派给工人,但工人尚未在手机端点击接单的任务。",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 品质 & 留言 */}
|
||||
@ -570,12 +576,18 @@ export default function AdminDashboard() {
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<Clock className="h-4 w-4 text-orange-500" /> 当前在制品(实时)
|
||||
</h3>
|
||||
<span className="text-[11px] text-gray-400">共 {wipTasks.length} 个</span>
|
||||
{/* 计数取「任务状态」卡片同源的真实活跃数,而不是 wipTasks.length ——
|
||||
后者一旦被接口截断就会比实际少,出现「卡片 23、列表共 20」的假象 */}
|
||||
<span className="text-[11px] text-gray-400">
|
||||
共 {activeTaskCount} 个
|
||||
{wipTasks.length < activeTaskCount && `(仅显示前 ${wipTasks.length} 条)`}
|
||||
</span>
|
||||
</div>
|
||||
{wipTasks.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-gray-400">🎉 暂无滞留任务</div>
|
||||
) : (
|
||||
<div>
|
||||
// 容器内滚动:列表可能很长(全量在制品),不让它把整页撑开
|
||||
<div className="max-h-[420px] overflow-y-auto pr-1">
|
||||
<div className="mb-1 flex items-center gap-3 text-[11px] font-medium text-gray-400">
|
||||
<span className="w-14 shrink-0">状态</span>
|
||||
<span className="flex-1">任务 · 负责人</span>
|
||||
@ -691,7 +703,7 @@ export default function AdminDashboard() {
|
||||
|
||||
{/* ═══ 驳回/返工明细抽屉 ═══ */}
|
||||
<Drawer
|
||||
title={<span className="text-base font-bold">🔴 驳回/返工明细 <span className="font-normal text-gray-400">共 {rejectedTasks.length} 条</span></span>}
|
||||
title={<span className="text-base font-bold">🔴 驳回明细 <span className="font-normal text-gray-400">共 {rejectedTasks.filter(t => t.kind === "rejected").length} 条</span></span>}
|
||||
open={rejectedDrawerOpen}
|
||||
onClose={() => setRejectedDrawerOpen(false)}
|
||||
size="large"
|
||||
@ -701,37 +713,29 @@ export default function AdminDashboard() {
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-red-500" />
|
||||
</div>
|
||||
) : rejectedTasks.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-gray-400">
|
||||
该时段暂无驳回/返工记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
|
||||
const rework = rejectedTasks.filter(t => t.kind === "rework");
|
||||
(() => {
|
||||
// 只展示「已驳回」行:返工任务是驳回自动派生的,独立成行会让同一次
|
||||
// 异常看起来像两条(详见 RejectedRow 顶部说明)。
|
||||
const rejected = rejectedTasks.filter(t => t.kind === "rejected");
|
||||
if (rejected.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{rejected.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条
|
||||
</h4>
|
||||
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
{rework.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-orange-600">
|
||||
<RefreshCw className="h-3.5 w-3.5" />返工任务 · {rework.length} 条
|
||||
</h4>
|
||||
{rework.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div className="py-16 text-center text-sm text-gray-400">
|
||||
该时段暂无驳回记录
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-xs font-bold text-red-600">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />已驳回 · {rejected.length} 条
|
||||
</h4>
|
||||
{rejected.map(t => <RejectedRow key={t.task_id} t={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@ -859,38 +863,8 @@ export default function AdminDashboard() {
|
||||
)}
|
||||
</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>
|
||||
{/* ═══ 操作明细抽屉 — 公共组件,与大屏 ScreenDashboard 共用同一实现 ═══ */}
|
||||
<UserOperationDetailDrawer ref={opDetailRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,8 +6,7 @@ import {
|
||||
import { Radio, DatePicker, Table, Button, AutoComplete, Modal, Timeline, Image, Select } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import "dayjs/locale/zh-cn";
|
||||
dayjs.locale("zh-cn");
|
||||
// dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
|
||||
import {
|
||||
fetchPeopleWorkload, fetchPeopleHistory, downloadPeopleHistory,
|
||||
type PersonWorkload, type PersonHistoryRecord, type PeopleHistoryQuery,
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings, Save, Loader2, Wifi, WifiOff } from "lucide-react";
|
||||
import { Save, Loader2, Wifi, WifiOff } from "lucide-react";
|
||||
import {
|
||||
getPrinterConfig,
|
||||
updatePrinterConfig,
|
||||
type PrinterConfig,
|
||||
} from "../../services/printApi";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
|
||||
export default function AdminPrintConfigPage() {
|
||||
const { toast } = useToast();
|
||||
@ -33,7 +34,7 @@ export default function AdminPrintConfigPage() {
|
||||
setPort(lp.port);
|
||||
setEnabled(lp.enabled ?? true);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? "加载配置失败", "error");
|
||||
toast(extractErrorMessage(err, "加载配置失败"), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -50,7 +51,7 @@ export default function AdminPrintConfigPage() {
|
||||
const result = await updatePrinterConfig(ip.trim(), port, enabled);
|
||||
toast(result.message, "success");
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? "保存失败", "error");
|
||||
toast(extractErrorMessage(err, "保存失败"), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@ -13,7 +13,8 @@ import {
|
||||
} from "../../services/printApi";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
import { getStatusConfig, lifecycleBadge } from "../../constants/task";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
|
||||
const QR_BASE = "/api/v1/products/qrcode";
|
||||
|
||||
@ -125,12 +126,12 @@ export default function AdminProductsPage() {
|
||||
}
|
||||
|
||||
// ---- 打印 ----
|
||||
async function handleOpenPrint(product: ProductResponse) { /* unchanged */ setPrintTarget(product); setPreviewUrl(null); setPrintCopies(1); setPrintLoading(true); try { setPreviewUrl(await getLabelPreview({ serial_number: product.serial_number, material_name: product.material_name ?? product.material_id ?? "", spec_model: product.spec_model ?? "", order_no: product.order_no ?? "" })); } catch (err: any) { toast(err?.response?.data?.detail ?? "生成预览失败", "error"); setPrintTarget(null); } finally { setPrintLoading(false); } }
|
||||
async function handleConfirmPrint() { if (!printTarget) return; setPrinting(true); try { const r = await executePrint({ serial_number: printTarget.serial_number, material_name: printTarget.material_name ?? "", spec_model: printTarget.spec_model ?? "", order_no: printTarget.order_no ?? "", copies: printCopies }); toast(r.message, "success"); setPrintTarget(null); } catch (err: any) { toast(err?.response?.data?.detail ?? "打印失败", "error"); } finally { setPrinting(false); } }
|
||||
async function handleOpenPrint(product: ProductResponse) { /* unchanged */ setPrintTarget(product); setPreviewUrl(null); setPrintCopies(1); setPrintLoading(true); try { setPreviewUrl(await getLabelPreview({ serial_number: product.serial_number, material_name: product.material_name ?? product.material_id ?? "", spec_model: product.spec_model ?? "", order_no: product.order_no ?? "" })); } catch (err: unknown) { toast(extractErrorMessage(err, "生成预览失败"), "error"); setPrintTarget(null); } finally { setPrintLoading(false); } }
|
||||
async function handleConfirmPrint() { if (!printTarget) return; setPrinting(true); try { const r = await executePrint({ serial_number: printTarget.serial_number, material_name: printTarget.material_name ?? "", spec_model: printTarget.spec_model ?? "", order_no: printTarget.order_no ?? "", copies: printCopies }); toast(r.message, "success"); setPrintTarget(null); } catch (err: unknown) { toast(extractErrorMessage(err, "打印失败"), "error"); } finally { setPrinting(false); } }
|
||||
|
||||
// ---- 编辑 ----
|
||||
function openEdit(p: ProductResponse) { setEditTarget(p); setEditOrderNo(p.order_no ?? ""); setEditExternalSerial(p.external_serial ?? ""); }
|
||||
async function handleSaveEdit() { if (!editTarget) return; setEditSaving(true); try { await api.patch(`/products/${editTarget.id}`, { order_no: editOrderNo.trim() || null, external_serial: editExternalSerial.trim() || null }); toast("保存成功", "success"); setEditTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "保存失败", "error"); } finally { setEditSaving(false); } }
|
||||
async function handleSaveEdit() { if (!editTarget) return; setEditSaving(true); try { await api.patch(`/products/${editTarget.id}`, { order_no: editOrderNo.trim() || null, external_serial: editExternalSerial.trim() || null }); toast("保存成功", "success"); setEditTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "保存失败"), "error"); } finally { setEditSaving(false); } }
|
||||
|
||||
// ---- 删除 ----
|
||||
useEffect(() => {
|
||||
@ -141,7 +142,7 @@ export default function AdminProductsPage() {
|
||||
}, [deleteTarget?.id]); // eslint-disable-line
|
||||
|
||||
function confirmDelete(p: ProductResponse) { setDeleteTarget(p); }
|
||||
async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "删除失败", "error"); } finally { setDeleting(false); } }
|
||||
async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: unknown) { toast(extractErrorMessage(err, "删除失败"), "error"); } finally { setDeleting(false); } }
|
||||
|
||||
// ---- 状态收口(入库 / 出库,管理员)----
|
||||
// 🛡️ 双重确认倒计时 + 必须输入产品身份证,防误触
|
||||
@ -164,8 +165,8 @@ export default function AdminProductsPage() {
|
||||
toast(`已收口为「${finalizeStatus}」`, "success");
|
||||
setFinalizeTarget(null);
|
||||
loadProducts();
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? "操作失败", "error");
|
||||
} catch (err: unknown) {
|
||||
toast(extractErrorMessage(err, "操作失败"), "error");
|
||||
} finally {
|
||||
setFinalizing(false);
|
||||
}
|
||||
@ -275,12 +276,21 @@ export default function AdminProductsPage() {
|
||||
<div className="flex flex-col items-center px-4 pt-5 pb-3">
|
||||
<img src={`${QR_BASE}/${p.serial_number}`} alt={`QR-${p.serial_number}`} className="h-32 w-32 rounded-lg border border-gray-100" loading="lazy" />
|
||||
<p className="mt-3 font-mono text-sm font-bold tracking-wider text-gray-800">{p.serial_number}</p>
|
||||
{(() => {
|
||||
const cfg = getStatusConfig(p.macro_status || p.status);
|
||||
return cfg.label ? (
|
||||
<span className={`mt-1 inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
) : null;
|
||||
})()}
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-1.5">
|
||||
{(() => {
|
||||
const cfg = getStatusConfig(p.macro_status || p.status);
|
||||
return cfg.label ? (
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
) : null;
|
||||
})()}
|
||||
{/* 🔧 发货测试 / 售后维修 — 仅「测试 / 维修」工序下显示 */}
|
||||
{(() => {
|
||||
const lb = lifecycleBadge(p.overall_status, p.lifecycle_phase);
|
||||
return lb ? (
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold ${lb.className}`}>{lb.label}</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 border-t border-gray-50 px-4 py-3">
|
||||
<InfoRow icon={Package} label="物料名称" value={p.material_name || p.material_id || "—"} />
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
Search, Loader2, Package, ChevronDown, ChevronRight,
|
||||
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
|
||||
} from "lucide-react";
|
||||
import { Tooltip, Popover, Checkbox, Input } from "antd";
|
||||
import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
|
||||
import api from "../../services/api";
|
||||
import { scanProduct } from "../../services/productApi";
|
||||
import {
|
||||
@ -17,15 +17,20 @@ import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../co
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
import { getStatusConfig, lifecycleBadge, ALL_OVERALL_OPTIONS } from "../../constants/task";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
|
||||
const STATUS_TABS = [
|
||||
const STATUS_TABS: { key: string; label: string; accent?: boolean }[] = [
|
||||
{ key: "PENDING", label: "待接收" },
|
||||
{ key: "WIP", label: "进行中" },
|
||||
{ key: "COMPLETED", label: "已完成" },
|
||||
{ key: "ARCHIVED", label: "已入库" },
|
||||
{ key: "OUTBOUND", label: "已出库" },
|
||||
// 🔧 售后流转中 = 出库回流设备中正在流转的(lifecycle_phase=AFTER_SALES 且 当前 WIP)。
|
||||
// 它与上面几个"状态码"不是同一维度(靠 lifecycle_phase 判定),故单列一个独立 key;
|
||||
// 多选时与其它状态是「或」关系 —— 只勾它即可一键剥离全部生产数据。
|
||||
{ key: "AFTER_SALES_WIP", label: "售后流转中", accent: true },
|
||||
];
|
||||
|
||||
type SortOrder = "asc" | "desc";
|
||||
@ -60,11 +65,15 @@ function formatDuration(hours: number | null): string {
|
||||
export default function AdminTasksPage() {
|
||||
const { toast } = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// 搜索 & 筛选
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilters, setStatusFilters] = useState<Set<string>>(new Set());
|
||||
// 从大屏下钻: ?search=xxx 灌进搜索框、?status=WIP,ARCHIVED 点亮状态 Tabs
|
||||
// —— 直接复用原生筛选状态,表现与用户手点完全一致,不引入任何额外 UI
|
||||
const [keyword, setKeyword] = useState(() => searchParams.get("search") || "");
|
||||
const [statusFilters, setStatusFilters] = useState<Set<string>>(
|
||||
() => new Set((searchParams.get("status") || "").split(",").map((s) => s.trim()).filter(Boolean)),
|
||||
);
|
||||
const [groupBy, setGroupBy] = useState<"order" | "device">("device");
|
||||
const [products, setProducts] = useState<ProductResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -105,10 +114,27 @@ export default function AdminTasksPage() {
|
||||
render: (p) => <div className="text-xs text-gray-500 truncate">{p.spec_model || p.material_name || p.material_id || "—"}</div>,
|
||||
},
|
||||
{
|
||||
key: "overall_status", label: "当前工序", colSpan: 1,
|
||||
filterType: "enum", getFilterValue: (p) => p.overall_status || "—",
|
||||
enumOptions: ["备货", "生产", "测试", "维修", "待仓库收货", "已入库", "已出库"].map((v) => ({ value: v, label: v })),
|
||||
render: (p) => <span className="text-xs font-medium text-gray-700">{p.overall_status || "—"}</span>,
|
||||
key: "current_step", label: "当前工序", colSpan: 1,
|
||||
// 🔧 改用后端派生的 current_step,而不是 overall_status ——
|
||||
// 后者的取值被"最新主干任务名"覆盖(含已 COMPLETED 的历史工序),
|
||||
// 会让「扫码出库 / 测试 / 发货测试」等已完结工序冒充"当前工序"。
|
||||
// current_step 只认活跃主干任务;无活跃任务时返回终态或空。
|
||||
filterType: "enum", getFilterValue: (p) => p.current_step || "—",
|
||||
// 🔧 筛选用两阶段并集:售后设备的「发货测试 / 售后维修」也要能被筛出来
|
||||
enumOptions: ALL_OVERALL_OPTIONS.map((v) => ({ value: v, label: v })),
|
||||
render: (p) => {
|
||||
const lb = lifecycleBadge(p.current_step, p.lifecycle_phase);
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-gray-700">{p.current_step || "—"}</span>
|
||||
{lb && (
|
||||
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none ${lb.className}`}>
|
||||
{lb.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "status", label: "产品状态", colSpan: 1,
|
||||
@ -194,7 +220,14 @@ export default function AdminTasksPage() {
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>(() => columns.map((c) => c.key));
|
||||
const [sort, setSort] = useState<{ key: string; order: SortOrder } | null>(null);
|
||||
const [textFilters, setTextFilters] = useState<Record<string, string>>({});
|
||||
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>({});
|
||||
// 从大屏下钻: ?stage=测试 → 点亮「当前工序」列的筛选(该列显示值对空状态用「—」,
|
||||
// 故「待启动」需折算成「—」才能命中)
|
||||
const [enumFilters, setEnumFilters] = useState<Record<string, Set<string>>>(() => {
|
||||
const stage = searchParams.get("stage");
|
||||
const initial: Record<string, Set<string>> = {};
|
||||
if (stage) initial.overall_status = new Set([stage === "待启动" ? "—" : stage]);
|
||||
return initial;
|
||||
});
|
||||
const [dragCol, setDragCol] = useState<string | null>(null);
|
||||
const [dragOverCol, setDragOverCol] = useState<string | null>(null);
|
||||
// 🔧 列显隐:默认全部显示,勾选取消的列从表格中去掉(操作列始终显示)
|
||||
@ -256,7 +289,13 @@ export default function AdminTasksPage() {
|
||||
for (const p of products) {
|
||||
if (statusFilters.size > 0) {
|
||||
const currentStatus = (p.macro_status || p.status).toUpperCase();
|
||||
if (!statusFilters.has(currentStatus)) continue;
|
||||
// 🔧 「售后流转中」按 lifecycle_phase 判定,与状态码不同维,单独算:
|
||||
// 只勾它时,未勾任何状态码 → 仅留下售后回流且正在流转的设备。
|
||||
const isAfterSalesWip =
|
||||
p.lifecycle_phase === "AFTER_SALES" && currentStatus === "WIP";
|
||||
const matchedByStatus = statusFilters.has(currentStatus);
|
||||
const matchedByAfterSales = statusFilters.has("AFTER_SALES_WIP") && isAfterSalesWip;
|
||||
if (!matchedByStatus && !matchedByAfterSales) continue;
|
||||
}
|
||||
// 列筛选(所有列)
|
||||
let skip = false;
|
||||
@ -308,7 +347,51 @@ export default function AdminTasksPage() {
|
||||
});
|
||||
}, [products, statusFilters, groupBy, textFilters, enumFilters, sort]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** 删除 URL 上的指定跳转预设参数(只删传入的 key,不动其它无关参数) */
|
||||
function clearUrlPresets(keys: string[]) {
|
||||
const hit = keys.filter((k) => searchParams.has(k));
|
||||
if (hit.length === 0) return;
|
||||
const next = new URLSearchParams(searchParams);
|
||||
hit.forEach((k) => next.delete(k));
|
||||
setSearchParams(next, { replace: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 解锁大屏带过来的 ?stage —— URL 参数与本地列筛选一起清掉。
|
||||
*
|
||||
* 该筛选只应在「从大屏跳进来的那一刻」生效一次。它藏在列筛选浮层里,
|
||||
* 界面上几乎不可见,若不清除会与状态 Tab 叠加成互斥条件、筛出空列表,
|
||||
* 用户却找不到原因。用户手动改了任何分类条件 = 最新意图优先。
|
||||
*/
|
||||
function clearStageLock() {
|
||||
clearUrlPresets(["stage"]);
|
||||
setEnumFilters((prev) => {
|
||||
if (!prev.overall_status) return prev;
|
||||
const next = { ...prev };
|
||||
delete next.overall_status;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** 全局重置:清空全部筛选条件与跳转预设,并强制一次无条件全量查询 */
|
||||
function handleReset() {
|
||||
// stage(工序锁)与 sn(设备定位)都是跳转带进来的预设,重置时一并丢弃,
|
||||
// 否则用户刷新页面会被旧条件"复活"回去
|
||||
clearUrlPresets(["stage", "sn"]);
|
||||
setKeyword("");
|
||||
setStatusFilters(new Set());
|
||||
setTextFilters({});
|
||||
setEnumFilters({});
|
||||
setSort(null);
|
||||
setExpandedOrders(new Set());
|
||||
setTaskTrees({});
|
||||
setActiveTreeProductId(null);
|
||||
loadProducts("");
|
||||
}
|
||||
|
||||
// 用户手动切换状态分类 = 最新意图,丢弃大屏带来的工序限制
|
||||
function toggleStatusFilter(key: string) {
|
||||
clearStageLock();
|
||||
setStatusFilters((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
@ -395,7 +478,7 @@ export default function AdminTasksPage() {
|
||||
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
|
||||
scanProduct(serialNumber)
|
||||
.then((result) => setTaskTrees((s) => ({ ...s, [serialNumber]: result })))
|
||||
.catch((err: any) => toast(err?.response?.data?.detail ?? err?.message ?? "加载任务树失败", "error"))
|
||||
.catch((err: unknown) => toast(extractErrorMessage(err, "加载任务树失败"), "error"))
|
||||
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
|
||||
}
|
||||
}
|
||||
@ -422,24 +505,24 @@ export default function AdminTasksPage() {
|
||||
if (sn) await refreshProductTree(sn);
|
||||
await loadProducts(keyword);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "接收失败", "error");
|
||||
toast(extractErrorMessage(err, "接收失败"), "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(reason: string) {
|
||||
async function handleReject(reason: string, images: string[]) {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await rejectTask(modalTarget.task.id, reason);
|
||||
await rejectTask(modalTarget.task.id, reason, images);
|
||||
toast("任务已驳回", "success");
|
||||
const sn = modalTarget.task.product_sn || "";
|
||||
setModalTarget(null);
|
||||
if (sn) await refreshProductTree(sn);
|
||||
await loadProducts(keyword);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "驳回失败", "error");
|
||||
toast(extractErrorMessage(err, "驳回失败"), "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -449,12 +532,13 @@ export default function AdminTasksPage() {
|
||||
nextTaskName: string,
|
||||
assignees: string[],
|
||||
note: string,
|
||||
finishDirectly: boolean,
|
||||
) {
|
||||
if (!modalTarget) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await transferTask(
|
||||
modalTarget.task.id, assignees, nextTaskName, note || undefined,
|
||||
modalTarget.task.id, assignees, nextTaskName, note || undefined, finishDirectly,
|
||||
);
|
||||
toast(result.message, "success");
|
||||
const sn = modalTarget.task.product_sn || "";
|
||||
@ -462,7 +546,7 @@ export default function AdminTasksPage() {
|
||||
if (sn) await refreshProductTree(sn);
|
||||
await loadProducts(keyword);
|
||||
} catch (err: any) {
|
||||
toast(err?.response?.data?.detail ?? err?.message ?? "转交失败", "error");
|
||||
toast(extractErrorMessage(err, "转交失败"), "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -566,6 +650,7 @@ export default function AdminTasksPage() {
|
||||
className="rounded px-2 py-1 text-[11px] text-gray-400 hover:bg-gray-100">全部折叠</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
@ -594,12 +679,13 @@ export default function AdminTasksPage() {
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
查询
|
||||
</button>
|
||||
<Button onClick={handleReset} disabled={loading}>重置</Button>
|
||||
</form>
|
||||
|
||||
{/* 状态筛选 Tabs(多选) */}
|
||||
<div className="mt-3 flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setStatusFilters(new Set())}
|
||||
onClick={() => { clearStageLock(); setStatusFilters(new Set()); }}
|
||||
className={`rounded-full px-3.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
statusFilters.size === 0
|
||||
? "bg-blue-600 text-white"
|
||||
@ -616,8 +702,8 @@ export default function AdminTasksPage() {
|
||||
onClick={() => toggleStatusFilter(tab.key)}
|
||||
className={`rounded-full px-3.5 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"
|
||||
? tab.accent ? "bg-purple-600 text-white" : "bg-blue-600 text-white"
|
||||
: tab.accent ? "bg-purple-50 text-purple-700 hover:bg-purple-100" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
@ -6,7 +6,7 @@ import { DatePicker, Tabs, Select, Button, Empty, Modal, Timeline, Image, Radio
|
||||
import { Users, GitBranch, RefreshCw, Loader2, AlertCircle, CalendarDays } from "lucide-react";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
import "dayjs/locale/zh-cn";
|
||||
// dayjs 的中文 locale 已在应用根(App.tsx)统一设置,此处不再重复
|
||||
|
||||
import BaseEChart from "../../components/BaseEChart";
|
||||
import {
|
||||
@ -15,8 +15,6 @@ import {
|
||||
type CapabilityQuery, type FlowQuery, type DeviceRecord, type FlowDevice, type FlowInterval,
|
||||
} from "../../services/analyticsApi";
|
||||
|
||||
dayjs.locale("zh-cn");
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// ─── 状态中文映射 ─────────────────────────────────────────
|
||||
|
||||
@ -3,6 +3,7 @@ import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } f
|
||||
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import api from "../../services/api";
|
||||
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
|
||||
import { extractErrorMessage } from "../../utils/errorMessage";
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
@ -184,8 +185,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
});
|
||||
setCreatedSn(data.serial_number);
|
||||
onCreated();
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.detail ?? "创建失败");
|
||||
} catch (err: unknown) {
|
||||
message.error(extractErrorMessage(err, "创建失败"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
482
frontend/src/pages/admin/ScreenDashboard.tsx
Normal file
482
frontend/src/pages/admin/ScreenDashboard.tsx
Normal file
@ -0,0 +1,482 @@
|
||||
/** 管理层数据大屏 — 全屏无侧边栏 · 亮色主题(与系统其他页面统一)· 60s 自动轮询
|
||||
*
|
||||
* 视角:日常运营与督导 —— 当月吞吐 / 当前卡点 / 系统活跃度。
|
||||
*
|
||||
* 布局:
|
||||
* 顶部一排 4 张当月指标卡(生产流转 / 已入库 / 已出库 / 返厂回流)
|
||||
* 左·主图区 【车间工序积压分布】横向柱状图,按生产段/售后段着色
|
||||
* 右·侧边区 【系统活跃度排行】本月 接收 / 转交 / 备注 次数表(主体,撑满高度)
|
||||
*
|
||||
* 跳转:下钻一律用 window.open 开新标签页 —— 领导看板时不丢失大盘视图。
|
||||
* 过滤:URL 参数直接映射到 AdminTasksPage 的原生筛选状态
|
||||
* (?status= 点亮状态 Tabs、?stage= 点亮「当前工序」列筛选),
|
||||
* 不引入任何大屏专用的额外 UI。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { Maximize2, Minimize2, RefreshCw, ArrowLeft } from "lucide-react";
|
||||
import type { EChartsCoreOption } from "echarts/core";
|
||||
|
||||
import BaseEChart from "../../components/BaseEChart";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import {
|
||||
fetchMonthlyMetrics,
|
||||
fetchWipDistribution,
|
||||
fetchActiveUsers,
|
||||
type MonthlyMetrics,
|
||||
type WipDistributionResponse,
|
||||
type ActiveUser,
|
||||
type ActiveUsersResponse,
|
||||
} from "../../services/screenApi";
|
||||
import UserOperationDetailDrawer, {
|
||||
type UserOperationDetailDrawerHandle,
|
||||
} from "../../components/admin/UserOperationDetailDrawer";
|
||||
|
||||
// 大屏轮询间隔
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
// 亮色主题下的 ECharts 配色
|
||||
const AXIS_COLOR = "#6b7280"; // 坐标轴文字 gray-500
|
||||
const GRID_COLOR = "#e5e7eb"; // 网格线 gray-200
|
||||
const TEXT_COLOR = "#374151"; // 标签文字 gray-700
|
||||
const TOOLTIP_STYLE = {
|
||||
backgroundColor: "rgba(255,255,255,0.97)",
|
||||
borderColor: "#d1d5db",
|
||||
textStyle: { color: "#1f2937" },
|
||||
extraCssText: "box-shadow:0 4px 12px rgba(0,0,0,0.08);",
|
||||
};
|
||||
|
||||
// 生产段 / 售后段的柱子配色
|
||||
const COLOR_PRODUCTION = "#0891b2"; // cyan-600
|
||||
const COLOR_AFTER_SALES = "#ea580c"; // orange-600
|
||||
|
||||
// 操作类型 → 中文名(明细抽屉标题用)
|
||||
const OP_LABELS: Record<string, string> = {
|
||||
receive: "接收",
|
||||
transfer: "转交",
|
||||
record: "上传备注",
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 鉴权外壳 — 与 AdminLayout 同款守卫,但不渲染侧边栏
|
||||
// ============================================================
|
||||
|
||||
export default function ScreenDashboard() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-gray-50">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isAuthenticated) return <Navigate to="/admin/login" replace />;
|
||||
|
||||
return <ScreenBoard />;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 大屏主体
|
||||
// ============================================================
|
||||
|
||||
function ScreenBoard() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [metrics, setMetrics] = useState<MonthlyMetrics | null>(null);
|
||||
const [dist, setDist] = useState<WipDistributionResponse | null>(null);
|
||||
const [users, setUsers] = useState<ActiveUsersResponse | null>(null);
|
||||
|
||||
// 操作明细下钻 — 与「全局概览」AdminDashboard 的人员操作统计共用同一抽屉组件
|
||||
const opDetailRef = useRef<UserOperationDetailDrawerHandle>(null);
|
||||
|
||||
const [updatedAt, setUpdatedAt] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// 卸载后不再 setState;reqId 丢弃过期响应(防止慢请求覆盖新数据)
|
||||
const aliveRef = useRef(true);
|
||||
const reqIdRef = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const reqId = ++reqIdRef.current;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [m, d, u] = await Promise.all([
|
||||
fetchMonthlyMetrics(),
|
||||
fetchWipDistribution(),
|
||||
fetchActiveUsers(10), // 多取一些,过滤掉 0 操作的人后再展示
|
||||
]);
|
||||
if (!aliveRef.current || reqId !== reqIdRef.current) return;
|
||||
setMetrics(m);
|
||||
setDist(d);
|
||||
setUsers(u);
|
||||
setError("");
|
||||
setUpdatedAt(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
|
||||
} catch {
|
||||
if (!aliveRef.current || reqId !== reqIdRef.current) return;
|
||||
setError("数据加载失败,将自动重试");
|
||||
} finally {
|
||||
if (aliveRef.current && reqId === reqIdRef.current) setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 首屏加载 + 60s 轮询
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
load();
|
||||
const timer = setInterval(load, REFRESH_MS);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
// 全屏状态同步(用户按 Esc 退出时图标要跟着变)
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
else void document.documentElement.requestFullscreen();
|
||||
}
|
||||
|
||||
/** 下钻一律开新标签页 —— 领导看板时不丢失大盘视图 */
|
||||
const openDrill = useCallback((url: string) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}, []);
|
||||
|
||||
// 柱状图下钻:点击某道工序 → 任务全景页点亮「当前工序」列筛选
|
||||
const handleBarClick = useCallback((params: any) => {
|
||||
const stage = params?.name;
|
||||
if (!stage) return;
|
||||
openDrill(`/admin/tasks?stage=${encodeURIComponent(stage)}`);
|
||||
}, [openDrill]);
|
||||
|
||||
// ── 横向柱状图:车间工序积压分布 ──
|
||||
const distOption = useMemo<EChartsCoreOption>(() => {
|
||||
const items = dist?.items ?? [];
|
||||
const total = dist?.total ?? 0;
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "shadow" },
|
||||
formatter: (params: any) => {
|
||||
const row = items[params[0]?.dataIndex ?? 0];
|
||||
if (!row) return "";
|
||||
const pct = total ? ((row.count / total) * 100).toFixed(1) : "0.0";
|
||||
const phaseLabel = row.phase === "AFTER_SALES" ? "售后段" : "生产段";
|
||||
return `${row.stage}(${phaseLabel})<br/><b>${row.count}</b> 台 · 占比 ${pct}%`;
|
||||
},
|
||||
...TOOLTIP_STYLE,
|
||||
},
|
||||
grid: { left: 6, right: 52, top: 8, bottom: 2, containLabel: true },
|
||||
xAxis: {
|
||||
type: "value",
|
||||
minInterval: 1,
|
||||
axisLabel: { color: AXIS_COLOR, fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: GRID_COLOR } },
|
||||
},
|
||||
yAxis: {
|
||||
type: "category",
|
||||
data: items.map((i) => i.stage),
|
||||
inverse: true, // 首项(待启动)排在最上方,保持工序先后顺序
|
||||
axisLine: { lineStyle: { color: "#d1d5db" } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: TEXT_COLOR, fontSize: 13 },
|
||||
},
|
||||
series: [{
|
||||
type: "bar",
|
||||
barMaxWidth: 26,
|
||||
data: items.map((i) => i.count),
|
||||
itemStyle: {
|
||||
// 回调按生命周期阶段区分生产段 / 售后段
|
||||
color: (p: any) => (items[p.dataIndex]?.phase === "AFTER_SALES"
|
||||
? COLOR_AFTER_SALES
|
||||
: COLOR_PRODUCTION),
|
||||
borderRadius: [0, 4, 4, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: "right",
|
||||
color: TEXT_COLOR,
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
}],
|
||||
};
|
||||
}, [dist]);
|
||||
|
||||
// 仅展示本月确实有操作的人员 —— 0 接收 / 0 转交 / 0 备注 一律隐藏
|
||||
const activeUsers = (users?.items ?? []).filter((u) => u.total > 0);
|
||||
const maxTotal = activeUsers.length ? Math.max(...activeUsers.map((u) => u.total)) : 1;
|
||||
|
||||
// 明细抽屉的时间范围取「本月」,与顶部卡片口径一致
|
||||
const monthSince = metrics
|
||||
? new Date(`${metrics.month_start}T00:00:00+08:00`).toISOString()
|
||||
: undefined;
|
||||
|
||||
function openUserDetail(u: ActiveUser, actionType: string, label: string) {
|
||||
opDetailRef.current?.open({
|
||||
userId: u.user_id,
|
||||
userName: u.user_name,
|
||||
actionType,
|
||||
label,
|
||||
since: monthSince,
|
||||
});
|
||||
}
|
||||
|
||||
/** 整行点击:默认展开该人首个有记录的操作类型(与行内数字按钮同源) */
|
||||
function openUserRow(u: ActiveUser) {
|
||||
if (u.receive_count > 0) return openUserDetail(u, "receive", "接收");
|
||||
if (u.transfer_count > 0) return openUserDetail(u, "transfer", "转交");
|
||||
return openUserDetail(u, "record", "上传备注");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden bg-gray-50 text-gray-800">
|
||||
{/* ── 顶栏 ── */}
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-1.5 rounded-full bg-blue-600" />
|
||||
<h1 className="text-lg font-bold tracking-wide text-gray-800">
|
||||
生产流转 · 管理层数据大屏
|
||||
</h1>
|
||||
{metrics?.month && (
|
||||
<span className="rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
{metrics.month} 当月
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="rounded bg-red-50 px-2 py-0.5 text-xs text-red-600">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="tabular-nums">最后更新 {updatedAt || "—"}</span>
|
||||
<button
|
||||
onClick={load}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||
{isFullscreen ? "退出全屏" : "全屏"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/admin/dashboard")}
|
||||
className="flex items-center gap-1.5 rounded-md border border-gray-300 px-2.5 py-1 transition-colors hover:border-blue-500 hover:text-blue-600"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
返回后台
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||
{/* ── 顶部一排:4 张当月指标卡(点击新标签页下钻任务全景) ── */}
|
||||
<div className="grid shrink-0 grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="本月生产流转"
|
||||
value={metrics?.month_production ?? 0}
|
||||
tone="text-cyan-600"
|
||||
hint="本月有流转或新建的生产态设备"
|
||||
onClick={() => openDrill("/admin/tasks?status=WIP")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="本月已入库"
|
||||
value={metrics?.month_inbound ?? 0}
|
||||
tone="text-sky-600"
|
||||
hint="扫码入库完成"
|
||||
/* 直接点亮任务页原生的「已入库」Tab */
|
||||
onClick={() => openDrill("/admin/tasks?status=ARCHIVED")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="本月已出库"
|
||||
value={metrics?.month_outbound ?? 0}
|
||||
tone="text-green-600"
|
||||
hint="扫码出库发货"
|
||||
onClick={() => openDrill("/admin/tasks?status=OUTBOUND")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="本月返厂回流"
|
||||
value={metrics?.month_returned ?? 0}
|
||||
tone="text-orange-600"
|
||||
hint="本月转入售后阶段"
|
||||
/* 直接点亮原生的「售后流转中」Tab */
|
||||
onClick={() => openDrill("/admin/tasks?status=AFTER_SALES_WIP")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 主区:左积压分布 + 右活跃度 ── */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4">
|
||||
{/* 左·主图区 */}
|
||||
<section className="col-span-7 flex min-h-0 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<PanelTitle
|
||||
extra={
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_PRODUCTION }} />
|
||||
生产段
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-gray-500">
|
||||
<span className="h-2 w-2 rounded-sm" style={{ background: COLOR_AFTER_SALES }} />
|
||||
售后段
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
未完结合计 <b className="text-cyan-600">{dist?.total ?? 0}</b> 台
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
车间工序积压分布
|
||||
</PanelTitle>
|
||||
<div className="min-h-0 flex-1">
|
||||
<BaseEChart
|
||||
option={distOption}
|
||||
height="100%"
|
||||
onEvents={{ click: handleBarClick }}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 右·侧边区:系统活跃度排行(主体,撑满右侧高度) */}
|
||||
<div className="col-span-5 flex min-h-0 flex-col">
|
||||
<section className="flex min-h-0 flex-1 flex-col rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<PanelTitle
|
||||
extra={
|
||||
<span className="text-xs text-gray-400">
|
||||
本月活跃 {activeUsers.length} 人 · 点击查看明细
|
||||
</span>
|
||||
}
|
||||
>
|
||||
系统活跃度排行
|
||||
</PanelTitle>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{activeUsers.length > 0 ? (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400">
|
||||
<th className="pb-1.5 text-left font-normal">姓名</th>
|
||||
<th className="w-10 pb-1.5 text-right font-normal">接收</th>
|
||||
<th className="w-10 pb-1.5 text-right font-normal">转交</th>
|
||||
<th className="w-10 pb-1.5 text-right font-normal">备注</th>
|
||||
<th className="w-12 pb-1.5 text-right font-normal">合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeUsers.map((u) => (
|
||||
<tr
|
||||
key={u.user_id}
|
||||
title="点击查看该人员的操作明细"
|
||||
onClick={() => openUserRow(u)}
|
||||
className="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-1.5 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 truncate text-gray-700">
|
||||
{u.user_name}
|
||||
</span>
|
||||
{/* 胶囊条:相对活跃度 */}
|
||||
<div className="h-1 min-w-0 flex-1 rounded-full bg-gray-100">
|
||||
<div
|
||||
className="h-1 rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.max((u.total / maxTotal) * 100, 6)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* 与「全局概览 · 人员操作统计」一致:点数字看对应类型的明细 */}
|
||||
{([
|
||||
["receive", u.receive_count],
|
||||
["transfer", u.transfer_count],
|
||||
["record", u.record_count],
|
||||
] as const).map(([type, cnt]) => (
|
||||
<td key={type} className="py-1.5 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 不冒泡触发整行的默认下钻
|
||||
openUserDetail(u, type, OP_LABELS[type]);
|
||||
}}
|
||||
className="cursor-pointer tabular-nums text-gray-500 transition-colors hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{cnt}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1.5 text-right font-bold tabular-nums text-blue-600">{u.total}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-400">
|
||||
{users ? "本月暂无操作记录" : "加载中…"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 操作明细抽屉 — 与「全局概览」共用同一组件(亮色主题,与系统一致) */}
|
||||
<UserOperationDetailDrawer ref={opDetailRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 局部小组件
|
||||
// ============================================================
|
||||
|
||||
function PanelTitle({ children, extra }: { children: ReactNode; extra?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-2 flex shrink-0 items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold tracking-wide text-gray-700">
|
||||
<span className="h-3.5 w-1 rounded-full bg-blue-600" />
|
||||
{children}
|
||||
</h2>
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label, value, tone, hint, onClick,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: string;
|
||||
hint: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
role={onClick ? "button" : undefined}
|
||||
className={`rounded-xl border border-gray-200 bg-white px-5 py-3 shadow-sm transition-colors ${
|
||||
onClick ? "cursor-pointer hover:border-blue-400 hover:bg-blue-50/40" : ""
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<div className="mt-0.5 flex items-baseline gap-1.5">
|
||||
<span className={`text-4xl font-bold tabular-nums ${tone}`}>{value}</span>
|
||||
<span className="text-xs text-gray-400">台</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-gray-400">{hint}</p>
|
||||
{onClick && <p className="mt-1 text-[10px] text-blue-500">点击下钻(新标签页)→</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
/** 认证 API — 登录、刷新 Token、获取用户信息 */
|
||||
import type { UserInfo } from "../contexts/AuthContext";
|
||||
import { extractErrorMessage } from "../utils/errorMessage";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
@ -23,7 +24,7 @@ export async function login(username: string, password: string): Promise<LoginRe
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err as any).detail ?? "登录失败");
|
||||
throw new Error(extractErrorMessage(err, "登录失败"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import api from "./api";
|
||||
import type { LifecyclePhase } from "../types/api";
|
||||
|
||||
export interface DashboardStats {
|
||||
products_total: number;
|
||||
@ -86,6 +87,8 @@ export interface WipMatrixDetailRow {
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
task_status: string;
|
||||
/** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
|
||||
lifecycle_phase?: LifecyclePhase;
|
||||
assignee_id: string;
|
||||
assignee: string;
|
||||
duration_hours: number;
|
||||
@ -161,7 +164,8 @@ export async function fetchDashboardStats(since?: string, until?: string): Promi
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchWipTasks(limit = 20): Promise<WipTask[]> {
|
||||
/** 在制品任务。limit 只是防御性上限,默认值足以覆盖全量 —— 不要拿它做分页截断。 */
|
||||
export async function fetchWipTasks(limit = 500): Promise<WipTask[]> {
|
||||
const { data } = await api.get<WipTask[]>("/dashboard/wip-tasks", { params: { limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
62
frontend/src/services/screenApi.ts
Normal file
62
frontend/src/services/screenApi.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/** 管理层数据大屏 — 前后端数据契约类型 + API
|
||||
* 对应后端 app/services/screen_service.py 的 Pydantic Schema
|
||||
*/
|
||||
import api from "./api";
|
||||
|
||||
// ─── 当月吞吐(顶部数字卡) ────────────────────────────────
|
||||
export interface MonthlyMetrics {
|
||||
month: string; // "2026-09"
|
||||
month_start: string; // "2026-09-01"(北京时间)
|
||||
month_production: number; // 本月有流转记录或新建的生产态设备数
|
||||
month_inbound: number; // 本月扫码入库数
|
||||
month_outbound: number; // 本月扫码出库数
|
||||
month_returned: number; // 本月进入售后/回流状态的设备数
|
||||
}
|
||||
|
||||
// ─── 工序积压分布(柱状图) ────────────────────────────────
|
||||
export interface WipStage {
|
||||
stage: string;
|
||||
phase: "PRODUCTION" | "AFTER_SALES";
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface WipDistributionResponse {
|
||||
total: number;
|
||||
items: WipStage[];
|
||||
}
|
||||
|
||||
// ─── 系统使用活跃度 ────────────────────────────────────────
|
||||
export interface ActiveUser {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
receive_count: number;
|
||||
transfer_count: number;
|
||||
record_count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ActiveUsersResponse {
|
||||
month: string;
|
||||
items: ActiveUser[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 方法
|
||||
// ============================================================
|
||||
|
||||
export async function fetchMonthlyMetrics(): Promise<MonthlyMetrics> {
|
||||
const { data } = await api.get<MonthlyMetrics>("/screen/monthly-metrics");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchWipDistribution(): Promise<WipDistributionResponse> {
|
||||
const { data } = await api.get<WipDistributionResponse>("/screen/wip-distribution");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchActiveUsers(topN = 5): Promise<ActiveUsersResponse> {
|
||||
const { data } = await api.get<ActiveUsersResponse>("/screen/active-users", {
|
||||
params: { top_n: topN },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
@ -96,14 +96,17 @@ export async function receiveTask(taskId: string): Promise<TaskResponse> {
|
||||
/**
|
||||
* 品质驳回 — → REJECTED,后端自动创建返工任务。
|
||||
* 对应后端 POST /api/v1/tasks/{taskId}/reject
|
||||
*
|
||||
* images 为选填,支持空数组(编号错误、选错工序等场景无需拍照举证)。
|
||||
*/
|
||||
export async function rejectTask(
|
||||
taskId: string,
|
||||
reason: string
|
||||
reason: string,
|
||||
images: string[]
|
||||
): Promise<TaskResponse> {
|
||||
const { data } = await api.post<TaskResponse>(
|
||||
`/tasks/${taskId}/reject`,
|
||||
{ reason } satisfies TaskRejectPayload
|
||||
{ reason, images } satisfies TaskRejectPayload
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@ -111,20 +114,25 @@ export async function rejectTask(
|
||||
/**
|
||||
* 完工裂变转交 — → COMPLETED,批量创建下家任务。
|
||||
* 对应后端 POST /api/v1/tasks/{taskId}/transfer
|
||||
*
|
||||
* finishDirectly=true 时走「直接完结」通道:next_tasks 留空 + finish_directly=true,
|
||||
* 后端据此跳过分支解析,只闭环任务【不产生下游任务】、且不改产品宏观状态
|
||||
* (不会把产品误标成「待仓库收货」)。用于售后返厂直接发走 / 半成品被提走等无需入库的收官场景。
|
||||
*/
|
||||
export async function transferTask(
|
||||
taskId: string,
|
||||
nextAssignees: string[],
|
||||
nextTaskName: string,
|
||||
note?: string
|
||||
note?: string,
|
||||
finishDirectly = false
|
||||
): Promise<TaskTransferResponse> {
|
||||
const payload: TaskTransferPayload = finishDirectly
|
||||
? { next_tasks: [], finish_directly: true, note }
|
||||
: { next_assignees: nextAssignees, next_task_name: nextTaskName, note };
|
||||
|
||||
const { data } = await api.post<TaskTransferResponse>(
|
||||
`/tasks/${taskId}/transfer`,
|
||||
{
|
||||
next_assignees: nextAssignees,
|
||||
next_task_name: nextTaskName,
|
||||
note,
|
||||
} satisfies TaskTransferPayload
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/** 管理端 API 响应类型 */
|
||||
import type { LifecyclePhase } from "./api";
|
||||
|
||||
export interface ProductResponse {
|
||||
id: string;
|
||||
@ -16,7 +17,17 @@ export interface ProductResponse {
|
||||
current_location_name: string | null;
|
||||
macro_status: string | null;
|
||||
overall_status: string | null;
|
||||
/**
|
||||
* 【当前工序】— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
|
||||
* · 有活跃主干任务(WIP/PENDING) → 该工序名
|
||||
* · 否则处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态
|
||||
* · 否则(活已干完)→ ""(列表显示「—」)
|
||||
* ⚠️ 不要用 overall_status 渲染该列:它会被"最新主干任务名"覆盖,含已 COMPLETED 的。
|
||||
*/
|
||||
current_step?: string;
|
||||
status: string;
|
||||
/** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
|
||||
lifecycle_phase?: LifecyclePhase;
|
||||
created_at: string;
|
||||
latest_record_time: string | null;
|
||||
latest_record_content: string | null;
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
/** 后端 API 响应类型 */
|
||||
|
||||
// ============================================================
|
||||
// 生命周期阶段 — 区分「生产阶段的测试(发货测试)」与「出库后返厂(售后维修)」
|
||||
// ============================================================
|
||||
|
||||
export type LifecyclePhase = "PRODUCTION" | "AFTER_SALES";
|
||||
|
||||
// ============================================================
|
||||
// 任务状态常量
|
||||
// ============================================================
|
||||
@ -75,11 +81,28 @@ export interface TaskListResponse {
|
||||
|
||||
export interface TaskRejectPayload {
|
||||
reason: string;
|
||||
/** 异常图片 URL 列表(选填,支持空数组,最多 9 张)
|
||||
* 对应后端 TaskRejectRequest.images —— 缺失或空数组均合法 */
|
||||
images: string[];
|
||||
}
|
||||
|
||||
/** 裂变分支 — assignees 允许为空数组,但【仅当】finish_directly=true 时合法;
|
||||
* 否则后端 TaskTransferRequest 校验会直接 422 拒绝(防「转交」静默退化成「直接完结」的丢件隐患)。 */
|
||||
export interface TaskTransferBranch {
|
||||
task_name: string;
|
||||
assignees: string[];
|
||||
}
|
||||
|
||||
export interface TaskTransferPayload {
|
||||
next_assignees: string[];
|
||||
next_task_name: string;
|
||||
/** [旧版] 下一道工序接收人列表 — 与 next_tasks 二选一 */
|
||||
next_assignees?: string[];
|
||||
/** [旧版] 下一道工序名称 — 与 next_tasks 二选一 */
|
||||
next_task_name?: string;
|
||||
/** [新版] 多分支任务列表 */
|
||||
next_tasks?: TaskTransferBranch[];
|
||||
/** 🏁 直接完结:闭环当前任务但【不产生任何下游任务】,产品 overall_status 保持原样
|
||||
* (不入库 → 不会变成「待仓库收货」)。置 true 时后端忽略 next_tasks / next_assignees。 */
|
||||
finish_directly?: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@ -93,10 +116,16 @@ export interface ProductScanResponse {
|
||||
order_id: string;
|
||||
order_no: string;
|
||||
material_id: string | null;
|
||||
/** 后端 schemas/product.py:83 返回,卡片展示用 */
|
||||
material_name: string | null;
|
||||
/** 后端 schemas/product.py:84 返回,卡片展示用 */
|
||||
spec_model: string | null;
|
||||
parent_product_id: string | null;
|
||||
current_location_id: string | null;
|
||||
status: string;
|
||||
overall_status: string | null;
|
||||
/** 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) */
|
||||
lifecycle_phase?: LifecyclePhase;
|
||||
created_at: string;
|
||||
top_level_tasks: TaskSummary[];
|
||||
/** 完整递归任务树 — 供十字矩阵树状图渲染 */
|
||||
|
||||
71
frontend/src/utils/errorMessage.ts
Normal file
71
frontend/src/utils/errorMessage.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 错误文案提取 — 把 FastAPI 的各类 detail 统一转成可读中文。
|
||||
*
|
||||
* 背景:各处 catch 里直接 `toast(err?.response?.data?.detail ?? "失败")` 是有坑的 ——
|
||||
* 请求体校验失败(422)时 detail 是一个数组,React 拿到数组会把里面每个对象渲染成
|
||||
* "[object Object]",用户看不懂,排查也无从下手。
|
||||
*
|
||||
* 这里对齐移动端 track-uniapp/src/utils/request.js 的 extractErrorDetail 行为,
|
||||
* 覆盖三种形态:
|
||||
* 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"}
|
||||
* 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, ..."}]}
|
||||
* 3. 对象: {"detail": {"msg": "..."}}
|
||||
* 解析不出内容时回落到调用方给的 fallback,绝不把原始对象丢给 UI。
|
||||
*/
|
||||
|
||||
/** loc 里这些是 FastAPI 的位置前缀,不是业务字段名,展示时去掉 */
|
||||
const LOC_PREFIXES = new Set(["body", "query", "path", "header", "cookie"]);
|
||||
|
||||
/** 校验错误数组 → "字段: 原因;字段2: 原因2" */
|
||||
function flattenValidationErrors(items: unknown[]): string {
|
||||
const messages = items
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (!item || typeof item !== "object") return "";
|
||||
const { msg, message, loc } = item as {
|
||||
msg?: unknown;
|
||||
message?: unknown;
|
||||
loc?: unknown;
|
||||
};
|
||||
// Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,纯噪音,去掉
|
||||
const text = String(msg ?? message ?? "").replace(/^Value error,\s*/, "");
|
||||
const field = Array.isArray(loc)
|
||||
? loc.filter((seg) => !LOC_PREFIXES.has(String(seg))).join(".")
|
||||
: "";
|
||||
return field && text ? `${field}: ${text}` : text;
|
||||
})
|
||||
.filter(Boolean);
|
||||
// 同一字段可能有多条规则失败,去重后再拼,避免「images: x;images: x」
|
||||
return Array.from(new Set(messages)).join(";");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任意抛出物里取出可读的中文报错。
|
||||
* @param err catch 到的任意值(axios 错误、被抛出的裸 JSON、Error、字符串皆可)
|
||||
* @param fallback 解析不出任何内容时的兜底文案
|
||||
*/
|
||||
export function extractErrorMessage(err: unknown, fallback = "操作失败"): string {
|
||||
const responseData = (err as { response?: { data?: unknown } })?.response?.data;
|
||||
// detail 优先取 axios 的 response.data.detail,其次兼容被直接抛出的裸对象/裸 JSON
|
||||
const detail =
|
||||
(responseData as { detail?: unknown } | undefined)?.detail ??
|
||||
(err as { detail?: unknown })?.detail;
|
||||
|
||||
if (typeof detail === "string" && detail.trim()) return detail.trim();
|
||||
if (Array.isArray(detail)) {
|
||||
const flattened = flattenValidationErrors(detail);
|
||||
if (flattened) return flattened;
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
const obj = detail as { msg?: unknown; message?: unknown };
|
||||
const text = String(obj.msg ?? obj.message ?? "").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
// 后端直接返回字符串 body 的情况
|
||||
if (typeof responseData === "string" && responseData.trim()) {
|
||||
return responseData.trim();
|
||||
}
|
||||
// 兜底沿用 axios / fetch 的原生 message(如 "Request failed with status code 500")
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
return fallback;
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log("生产流转 T1.0.0 启动");
|
||||
// 无 Token 跳转登录页
|
||||
const token = uni.getStorageSync("token");
|
||||
if (!token) {
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
}
|
||||
},
|
||||
onShow() {},
|
||||
onHide() {},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page {
|
||||
background-color: #f3f4f6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
@ -1,7 +0,0 @@
|
||||
import App from "./App.vue";
|
||||
import { createSSRApp } from "vue";
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
return { app };
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "Track",
|
||||
"appid": "__UNI__B572616",
|
||||
"description": "Track - 生产流转管理",
|
||||
"versionName": "T1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"vueVersion": "3",
|
||||
"app-plus": {
|
||||
"usingComponents": true,
|
||||
"nvueCompiler": "uni-app",
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>"
|
||||
]
|
||||
},
|
||||
"orientation": ["portrait-primary"]
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": ""
|
||||
},
|
||||
"title": "生产流转"
|
||||
}
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scan/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "扫码干活",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/tasks/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的任务",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息通知",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人中心",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "white",
|
||||
"navigationBarTitleText": "生产流转",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"backgroundColor": "#F3F4F6"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#9CA3AF",
|
||||
"selectedColor": "#2563EB",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/scan/index",
|
||||
"text": "扫码干活"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/tasks/index",
|
||||
"text": "我的任务"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/notify/index",
|
||||
"text": "消息"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="logo">🏭</text>
|
||||
<text class="title">Track</text>
|
||||
<text class="version">T1.0.0</text>
|
||||
</view>
|
||||
|
||||
<view class="form">
|
||||
<input v-model="username" class="input" placeholder="用户名" />
|
||||
<input v-model="password" class="input" type="password" placeholder="密码" />
|
||||
<button class="login-btn" @tap="handleLogin" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
<text v-if="error" class="error">{{ error }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { post } from "../../utils/request";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = "请输入用户名和密码";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await post("/auth/login", {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
// 保存 Token + 用户信息
|
||||
uni.setStorageSync("token", res.access_token);
|
||||
uni.setStorageSync("user", JSON.stringify(res.user));
|
||||
uni.showToast({ title: "登录成功", icon: "success" });
|
||||
// 跳转到扫码页
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: "/pages/scan/index" });
|
||||
}, 500);
|
||||
} catch {
|
||||
// request.js 已弹 toast
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; background: #f3f4f6; }
|
||||
.header { display: flex; flex-direction: column; align-items: center; margin-bottom: 40px; }
|
||||
.logo { font-size: 64px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; margin-top: 12px; }
|
||||
.version { font-size: 12px; color: #9ca3af; margin-top: 4px; }
|
||||
.form { width: 100%; max-width: 320px; }
|
||||
.input { width: 100%; height: 48px; padding: 0 16px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 15px; background: #fff; margin-bottom: 12px; box-sizing: border-box; }
|
||||
.login-btn { width: 100%; height: 48px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 16px; font-weight: 700; line-height: 48px; }
|
||||
.login-btn[disabled] { opacity: 0.6; }
|
||||
.error { display: block; text-align: center; color: #dc2626; font-size: 13px; margin-top: 12px; }
|
||||
</style>
|
||||
@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">消息通知</text>
|
||||
<text class="subtitle">任务流转和系统通知</text>
|
||||
</view>
|
||||
<view class="empty">
|
||||
<text class="empty-icon">🔔</text>
|
||||
<text class="empty-text">暂无新消息</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
</style>
|
||||
@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="user-card">
|
||||
<view class="avatar">{{ initial }}</view>
|
||||
<view class="user-info">
|
||||
<text class="user-name">{{ user?.display_name || '未登录' }}</text>
|
||||
<text class="user-role">{{ user?.role === 'admin' ? '管理员' : '操作员' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-card">
|
||||
<view v-for="item in ['工作统计', '设置', '帮助与反馈', '关于']" :key="item" class="menu-item">
|
||||
<text class="menu-text">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
<view class="version">T1.0.0</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
const user = ref(null);
|
||||
try { const r = uni.getStorageSync("user"); if (r) user.value = JSON.parse(r); } catch {}
|
||||
const initial = computed(() => (user.value?.display_name || "?")[0]);
|
||||
|
||||
function handleLogout() {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.user-card { display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.avatar { width: 48px; height: 48px; border-radius: 50%; background: #dbeafe; color: #2563EB; font-size: 20px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||||
.user-name { font-size: 16px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.user-role { font-size: 12px; color: #9ca3af; }
|
||||
.menu-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; }
|
||||
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f3f4f6; }
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
</style>
|
||||
@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- ======== 扫码按钮 ======== -->
|
||||
<view class="scan-area">
|
||||
<view class="scan-btn" @tap="handleScanCode">
|
||||
<text class="scan-icon">📷</text>
|
||||
<text class="scan-text">点击扫码</text>
|
||||
<text class="scan-hint">调用原生摄像头扫描二维码/条码</text>
|
||||
</view>
|
||||
|
||||
<!-- 手动输入 -->
|
||||
<view class="manual-input">
|
||||
<input
|
||||
v-model="serialNumber"
|
||||
class="input"
|
||||
type="text"
|
||||
maxlength="16"
|
||||
placeholder="手动输入16位序列号"
|
||||
@confirm="handleManualSearch"
|
||||
/>
|
||||
<button class="search-btn" @tap="handleManualSearch" :disabled="loading">
|
||||
{{ loading ? '查询中' : '查询' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ======== 最近扫描 ======== -->
|
||||
<view v-if="lastScanned" class="last-scan">
|
||||
最近扫描: <text class="sn-text">{{ lastScanned }}</text>
|
||||
</view>
|
||||
|
||||
<!-- ======== 加载中 / 错误 / 结果 ======== -->
|
||||
<view v-if="loading" class="loading">查询中...</view>
|
||||
<view v-if="error && !loading" class="error-box">{{ error }}</view>
|
||||
|
||||
<view v-if="product && !loading" class="result">
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📦 产品信息</text>
|
||||
<text :class="['status', statusClass(product.status)]">{{ statusLabel(product.status) }}</text>
|
||||
</view>
|
||||
<view class="info-grid">
|
||||
<view class="info-item"><text class="label">序列号</text><text class="value sn">{{ product.serial_number }}</text></view>
|
||||
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no }}</text></view>
|
||||
<view v-if="product.material_id" class="info-item"><text class="label">物料ID</text><text class="value">{{ product.material_id }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">📋 当前进度</text>
|
||||
<text class="task-count">{{ product.top_level_tasks.length }} 个任务</text>
|
||||
</view>
|
||||
<view v-if="product.top_level_tasks.length === 0" class="empty-tasks">暂无关联任务</view>
|
||||
<view v-for="task in product.top_level_tasks" :key="task.id" class="task-item">
|
||||
<view class="task-info">
|
||||
<text class="task-name">{{ task.task_name }}</text>
|
||||
<text class="task-assignee">负责人: {{ task.assignee_id || '未分配' }}</text>
|
||||
</view>
|
||||
<text :class="['status-sm', statusClass(task.status)]">{{ statusLabel(task.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!product && !loading && !error" class="empty">
|
||||
<text class="empty-icon">📱</text>
|
||||
<text class="empty-text">扫码或手动输入序列号查询产品进度</text>
|
||||
</view>
|
||||
|
||||
<view class="version">T1.0.0</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { get } from "../../utils/request";
|
||||
|
||||
const serialNumber = ref("");
|
||||
const lastScanned = ref("");
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const product = ref(null);
|
||||
|
||||
const STATUS_MAP = { pending: "待处理", in_progress: "进行中", completed: "已完成", cancelled: "已取消" };
|
||||
function statusLabel(s) { return STATUS_MAP[s] || s; }
|
||||
function statusClass(s) {
|
||||
switch (s) {
|
||||
case "pending": return "status-yellow";
|
||||
case "in_progress": return "status-blue";
|
||||
case "completed": return "status-green";
|
||||
default: return "status-gray";
|
||||
}
|
||||
}
|
||||
|
||||
async function doQuery(sn) {
|
||||
if (!sn || sn.length < 8) { error.value = "序列号至少需要 8 位"; return; }
|
||||
serialNumber.value = sn;
|
||||
lastScanned.value = sn;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
product.value = null;
|
||||
try { product.value = await get(`/products/scan/${sn}`); }
|
||||
catch { /* request.js 已弹 toast */ }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
|
||||
function handleScanCode() {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ["qrCode", "barCode"],
|
||||
success(res) {
|
||||
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
|
||||
doQuery(sn);
|
||||
},
|
||||
fail(err) {
|
||||
if (err.errMsg && err.errMsg.includes("cancel")) return;
|
||||
uni.showToast({ title: "扫码失败,请重试", icon: "none" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleManualSearch() { doQuery(serialNumber.value.trim()); }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.scan-btn { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 180px; background: linear-gradient(135deg, #2563EB, #3B82F6); border-radius: 16px; color: #fff; box-shadow: 0 4px 16px rgba(37,99,235,0.3); }
|
||||
.scan-icon { font-size: 44px; margin-bottom: 6px; }
|
||||
.scan-text { font-size: 18px; font-weight: 700; }
|
||||
.scan-hint { font-size: 12px; opacity: 0.8; margin-top: 4px; }
|
||||
.manual-input { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.input { flex: 1; height: 44px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; font-size: 14px; background: #fff; }
|
||||
.search-btn { height: 44px; padding: 0 20px; background: #2563EB; color: #fff; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; line-height: 44px; }
|
||||
.search-btn[disabled] { opacity: 0.6; }
|
||||
.last-scan { font-size: 12px; color: #9ca3af; margin: 12px 0; }
|
||||
.sn-text { font-family: monospace; color: #4b5563; }
|
||||
.loading { text-align: center; padding: 32px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||
.card-title { font-size: 15px; font-weight: 700; }
|
||||
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.label { font-size: 12px; color: #9ca3af; }
|
||||
.value { font-size: 14px; color: #1f2937; font-weight: 600; }
|
||||
.sn { font-family: monospace; }
|
||||
.status { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.status-sm { font-size: 11px; padding: 2px 8px; border-radius: 20px; font-weight: 600; flex-shrink: 0; }
|
||||
.status-yellow { background: #fef3c7; color: #b45309; }
|
||||
.status-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-green { background: #dcfce7; color: #15803d; }
|
||||
.status-gray { background: #f3f4f6; color: #6b7280; }
|
||||
.task-count { font-size: 12px; color: #9ca3af; }
|
||||
.empty-tasks { text-align: center; padding: 24px 0; color: #9ca3af; font-size: 13px; }
|
||||
.task-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-top: 1px solid #f3f4f6; }
|
||||
.task-info { flex: 1; min-width: 0; }
|
||||
.task-name { font-size: 14px; font-weight: 600; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-assignee { font-size: 12px; color: #9ca3af; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 60px; color: #9ca3af; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; padding: 16px 0; }
|
||||
</style>
|
||||
@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="header">
|
||||
<text class="title">我的任务</text>
|
||||
<text class="subtitle">待处理和进行中的任务</text>
|
||||
</view>
|
||||
<view class="empty">
|
||||
<text class="empty-icon">📋</text>
|
||||
<text class="empty-text">暂无待办任务</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; padding-bottom: 80px; }
|
||||
.header { margin-bottom: 24px; }
|
||||
.title { font-size: 20px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.subtitle { font-size: 13px; color: #9ca3af; margin-top: 4px; display: block; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding-top: 80px; }
|
||||
.empty-icon { font-size: 64px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: #9ca3af; }
|
||||
</style>
|
||||
@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { getNotifications } from "./api/notification";
|
||||
import { checkAppUpdate } from "./utils/ota";
|
||||
|
||||
export default {
|
||||
onLaunch() {
|
||||
@ -14,9 +15,9 @@ export default {
|
||||
}
|
||||
// 已登录 → 原地渲染 scan 页,不需任何跳转
|
||||
|
||||
// 🚀 OTA 热更新检测(仅 App 端生效)
|
||||
// 🚀 OTA 热更新检测(仅 App 端生效;实现见 utils/ota.js,设置页手动检查共用同一份)
|
||||
// #ifdef APP-PLUS
|
||||
this.checkUpdate();
|
||||
checkAppUpdate();
|
||||
// #endif
|
||||
},
|
||||
onShow() {
|
||||
@ -24,121 +25,13 @@ export default {
|
||||
this.updateTabBarBadge();
|
||||
// 🚀 每次回到前台也检查更新,用户无需杀后台就能感知新版本
|
||||
// #ifdef APP-PLUS
|
||||
this.checkUpdate();
|
||||
checkAppUpdate();
|
||||
// #endif
|
||||
},
|
||||
onHide() {
|
||||
console.log("App 隐藏");
|
||||
},
|
||||
methods: {
|
||||
// ==========================================================
|
||||
// OTA 热更新雷达 — 版本检测 + WGT 下载 + 静默安装
|
||||
// ==========================================================
|
||||
checkUpdate() {
|
||||
// 🚀 节流:5分钟内不重复检查,避免 onShow 频繁触发弹窗骚扰
|
||||
const now = Date.now();
|
||||
if (this._lastUpdateCheck && now - this._lastUpdateCheck < 5 * 60 * 1000) {
|
||||
console.log("[OTA] 距上次检查不足5分钟,跳过");
|
||||
return;
|
||||
}
|
||||
this._lastUpdateCheck = now;
|
||||
|
||||
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
|
||||
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
const wgtVer = sysInfo.appWgtVersion || sysInfo.appVersion || "0";
|
||||
const currentVersionCode = this.parseVersionCode(wgtVer);
|
||||
const baseUrl = uni.getStorageSync("env_base_url") || "http://track_back.iris-rs.cn/api/v1";
|
||||
|
||||
console.log("[OTA] 本地版本:", wgtVer, "→ 数字:", currentVersionCode);
|
||||
|
||||
uni.request({
|
||||
url: `${baseUrl}/app/check-update`,
|
||||
method: "GET",
|
||||
timeout: 8000,
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) return;
|
||||
const data = res.data;
|
||||
if (!data || !data.wgt_url) {
|
||||
console.log("[OTA] 服务端无可用更新包");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverVersionCode = data.version_code || 0;
|
||||
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
|
||||
|
||||
// 客户端自行对比:服务端 > 本地 = 需要更新
|
||||
if (serverVersionCode <= currentVersionCode) {
|
||||
console.log("[OTA] 已是最新版本,无需更新");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OTA] 发现新版本:", data.version);
|
||||
this.downloadAndInstall(data.wgt_url, data.version, data.description);
|
||||
},
|
||||
fail: () => {
|
||||
console.log("[OTA] 版本检测网络失败,跳过");
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
|
||||
parseVersionCode(v) {
|
||||
if (!v) return 0;
|
||||
const nums = v.match(/\d+/g);
|
||||
if (!nums || nums.length < 2) return 0;
|
||||
return parseInt(nums[0], 10) * 100 + parseInt(nums[nums.length - 1], 10);
|
||||
},
|
||||
|
||||
downloadAndInstall(wgtUrl, newVersion, description) {
|
||||
if (!wgtUrl) {
|
||||
console.log("[OTA] 无 WGT 下载地址");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = description
|
||||
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
|
||||
: `发现新版本 ${newVersion},是否立即更新?`;
|
||||
|
||||
uni.showModal({
|
||||
title: "版本更新",
|
||||
content,
|
||||
confirmText: "立即更新",
|
||||
cancelText: "稍后再说",
|
||||
success: (modalRes) => {
|
||||
if (!modalRes.confirm) return;
|
||||
|
||||
// 🚀 静默更新:无进度条、无 toast、不监听 onProgressUpdate
|
||||
uni.downloadFile({
|
||||
url: wgtUrl,
|
||||
success: (downloadRes) => {
|
||||
if (downloadRes.statusCode !== 200) {
|
||||
console.error("[OTA] 下载失败, statusCode:", downloadRes.statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
plus.runtime.install(
|
||||
downloadRes.tempFilePath,
|
||||
{ force: true },
|
||||
() => {
|
||||
console.log("[OTA] 安装成功,3秒后自动重启");
|
||||
setTimeout(() => {
|
||||
plus.runtime.restart();
|
||||
}, 3000);
|
||||
},
|
||||
(err) => {
|
||||
console.error("[OTA] 安装失败:", err.message);
|
||||
}
|
||||
);
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error("[OTA] 下载失败:", err.errMsg);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// ==========================================================
|
||||
// TabBar 消息红点
|
||||
// ==========================================================
|
||||
@ -177,4 +70,56 @@ page {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
上传成功角标 —— 图片缩略图右上角「绿底白勾」
|
||||
============================================================
|
||||
用法:给图片的【直接父容器】加 .success-badge-wrapper,
|
||||
容器内放 <image> 和一个空的 <view class="success-badge" />,
|
||||
后者仅在确认该图已上传成功时才渲染。
|
||||
|
||||
⚠️ 容器只能包住图片本身,不要连删除按钮一起包 —— overflow:hidden 会把
|
||||
定位在单元格外的 ✕ 按钮一并裁掉(各页面的 .img-del 都在 -12rpx 处)。
|
||||
============================================================ */
|
||||
.success-badge-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12rpx; /* 与缩略图圆角一致,负责裁剪贴边的角标 */
|
||||
}
|
||||
|
||||
/* 直角三角形本体:border-top 实色 + border-left 透明,直角落在右上角。
|
||||
注意别写成 border-top + border-right —— 那样得到的直角在左上,方向是反的。 */
|
||||
.success-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
z-index: 2;
|
||||
pointer-events: none; /* 不遮挡图片本身的点击(预览) */
|
||||
}
|
||||
.success-badge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 40rpx solid #67c23a;
|
||||
border-left: 40rpx solid transparent;
|
||||
}
|
||||
|
||||
/* 白色对勾:用两条边框旋转 -45° 画出来,不依赖字体。
|
||||
直接用 "✓" 字符的话,安卓与 iOS 的字形和基线差异很大,对不齐。 */
|
||||
.success-badge::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 9rpx;
|
||||
right: 7rpx;
|
||||
width: 12rpx;
|
||||
height: 7rpx;
|
||||
border-left: 3rpx solid #ffffff;
|
||||
border-bottom: 3rpx solid #ffffff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
37
track-uniapp/src/api/feedback.js
Normal file
37
track-uniapp/src/api/feedback.js
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 帮助与反馈 API。
|
||||
*
|
||||
* ⚠️ 后端目前【没有】feedback 接口(已核对 openapi 路由表)。这里不直接写死
|
||||
* 一个假函数,而是「先真后假」:先按最终契约 POST /feedback/,只有拿到
|
||||
* 404(端点尚未实现)才回退到本地 mock 成功。这样等后端补上接口后,前端
|
||||
* 一行都不用改就能自动接上真实链路;而 400/422/断网等真实错误仍会照常
|
||||
* 抛出,不会被 mock 悄悄吞掉 —— 否则工人会以为反馈提交成功了,其实没有。
|
||||
*/
|
||||
import request from "../utils/request";
|
||||
|
||||
/** 反馈问题类型(与页面选择项一一对应) */
|
||||
export const FEEDBACK_TYPES = [
|
||||
{ value: "system", label: "系统问题" },
|
||||
{ value: "device", label: "设备故障" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 提交一条反馈。
|
||||
* @param {{type: string, content: string, images?: string[], user_id?: string}} payload
|
||||
* @returns {Promise<{id?: string, mocked?: boolean}>}
|
||||
*/
|
||||
export async function submitFeedback(payload) {
|
||||
try {
|
||||
// silent:404 会走到下面的 mock 分支,不能先弹一句「请求失败 (404)」再弹「感谢反馈」
|
||||
return await request({ url: "/feedback/", method: "POST", data: payload, silent: true });
|
||||
} catch (e) {
|
||||
// 只认「端点不存在」这一种情况回退 mock;其余错误(校验失败/网络断)
|
||||
// 必须原样抛给页面去提示,不能让用户误以为提交成功
|
||||
if (e && e.statusCode === 404) {
|
||||
console.warn("[feedback] 后端暂无 /feedback/ 接口,回退本地 mock");
|
||||
return { id: null, mocked: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
27
track-uniapp/src/api/stats.js
Normal file
27
track-uniapp/src/api/stats.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 个人效能统计 API — 「个人中心 → 工作统计」数据源。
|
||||
*
|
||||
* 后端 /dashboard/* 系列是「上帝视角」(全厂数据、不按人过滤),且
|
||||
* people-history 只返回 WIP/PENDING/COMPLETED、rejected-tasks 压根没有
|
||||
* assignee_id 参数 —— 一线工人要看的「我的今日被驳回数」在这两个接口里
|
||||
* 都拿不到。故后端补了一个薄桥接端点 /dashboard/my-stats,沿用同一套统计
|
||||
* 口径,但强制按 assignee_id 过滤到本人。
|
||||
*/
|
||||
import { get } from "../utils/request";
|
||||
|
||||
/**
|
||||
* 获取当前登录用户的个人效能统计(支持自选时段)。
|
||||
*
|
||||
* 返回两组指标:
|
||||
* 生产战绩 — tasks_completed / tasks_rejected / products_touched
|
||||
* 操作统计 — receive_count / transfer_count / record_count / op_total
|
||||
* (与 PC「人员操作统计」严格同口径,已逐字段交叉验证)
|
||||
*
|
||||
* @param {string} assigneeId - 负责人ID(即登录用户的 username)
|
||||
* @param {string} [since] - 起始时间 ISO,**必须带时区偏移**(如 2026-09-01T00:00:00+08:00)
|
||||
* @param {string} [until] - 截止时间 ISO,同上
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export function getMyStats(assigneeId, since, until) {
|
||||
return get("/dashboard/my-stats", { assignee_id: assigneeId, since, until });
|
||||
}
|
||||
@ -36,7 +36,8 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的任务",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -44,7 +45,8 @@
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息通知",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -54,6 +56,31 @@
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/statistics",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工作统计",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/settings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/feedback",
|
||||
"style": {
|
||||
"navigationBarTitleText": "帮助与反馈",
|
||||
"navigationBarBackgroundColor": "#2563EB",
|
||||
"navigationBarTextStyle": "white"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
|
||||
@ -39,13 +39,18 @@
|
||||
</view>
|
||||
<text class="card-arrow">›</text>
|
||||
</view>
|
||||
|
||||
<!-- ⬇️ 触底加载状态:让工人知道"到底了"还是"还在拉" -->
|
||||
<view v-if="loadingMore" class="load-more">加载中...</view>
|
||||
<view v-else-if="!hasMore" class="load-more">— 没有更多了 —</view>
|
||||
<view v-else class="load-more">上拉加载更多</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { onShow, onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
|
||||
import { getNotifications, markNotificationRead } from "../../api/notification";
|
||||
|
||||
const TYPE_CONFIG = {
|
||||
@ -58,11 +63,17 @@ const notifications = ref([]);
|
||||
const loading = ref(true);
|
||||
let currentUser = null;
|
||||
|
||||
// 📄 分页状态:此前用默认 limit=20 但翻不动页,第 21 条以后的消息永远看不到
|
||||
const pageSize = 20;
|
||||
let page = 1;
|
||||
const hasMore = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
|
||||
onShow(() => {
|
||||
loadUser();
|
||||
setTimeout(() => {
|
||||
if (!currentUser) loadUser();
|
||||
fetchNotifications();
|
||||
fetchNotifications("initial");
|
||||
}, 200);
|
||||
});
|
||||
|
||||
@ -76,17 +87,69 @@ function loadUser() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchNotifications() {
|
||||
const userId = currentUser?.username || currentUser?.id || "";
|
||||
if (!userId) { loading.value = false; return; }
|
||||
loading.value = true;
|
||||
// 🚀 下拉刷新:界面提示了「请下拉刷新重试」,就必须把功能做实。
|
||||
// 分页后下拉必须重置 page 并清空列表 —— 否则新的第 1 页会和旧的第 2、3 页
|
||||
// 混在一起,出现重复项与排序错乱。
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
const res = await getNotifications(userId);
|
||||
notifications.value = res.notifications || [];
|
||||
} catch {
|
||||
notifications.value = [];
|
||||
loadUser();
|
||||
await fetchNotifications("refresh");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
|
||||
}
|
||||
});
|
||||
|
||||
// 🚀 触底加载下一页
|
||||
onReachBottom(() => {
|
||||
if (loading.value || loadingMore.value || !hasMore.value) return;
|
||||
loadingMore.value = true;
|
||||
fetchNotifications("more").finally(() => { loadingMore.value = false; });
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取通知
|
||||
* @param {'initial'|'refresh'|'more'} mode
|
||||
* initial — 首次进入/切页:显示整页 loading,失败清空
|
||||
* refresh — 下拉刷新:重置到第 1 页并清空重载
|
||||
* more — 触底加载:追加下一页,失败保留已加载内容只提示
|
||||
*/
|
||||
async function fetchNotifications(mode = "initial") {
|
||||
const userId = currentUser?.username || currentUser?.id || "";
|
||||
if (!userId) {
|
||||
if (mode === "initial") loading.value = false;
|
||||
else uni.showToast({ title: "未获取到登录信息,请重新登录", icon: "none", duration: 2500 });
|
||||
return;
|
||||
}
|
||||
// initial / refresh 都要从第 1 页重来:前者是切页回来,后者是下拉刷新。
|
||||
// 若 initial 沿用旧的 page,onShow 只会去拉"第 N 页"并追加,列表永远刷不新。
|
||||
if (mode === "initial" || mode === "refresh") {
|
||||
page = 1;
|
||||
hasMore.value = true;
|
||||
notifications.value = [];
|
||||
}
|
||||
if (mode === "initial") loading.value = true;
|
||||
const target = page;
|
||||
try {
|
||||
const res = await getNotifications(userId, (target - 1) * pageSize, pageSize);
|
||||
const batch = res.notifications || [];
|
||||
// 按 id 去重后追加:翻页期间若来了新消息,分页边界会错位导致重复项
|
||||
const seen = new Set(notifications.value.map((n) => n.id));
|
||||
notifications.value = notifications.value.concat(batch.filter((n) => !seen.has(n.id)));
|
||||
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
|
||||
hasMore.value = res.total != null
|
||||
? notifications.value.length < res.total
|
||||
: batch.length >= pageSize;
|
||||
if (hasMore.value) page = target + 1;
|
||||
} catch (e) {
|
||||
console.error("[notify] 拉取消息失败:", e);
|
||||
if (mode === "initial") notifications.value = [];
|
||||
else uni.showToast({
|
||||
title: mode === "more" ? "加载更多失败,请重试" : "刷新失败,请稍后重试",
|
||||
icon: "none",
|
||||
duration: 2500,
|
||||
});
|
||||
} finally {
|
||||
if (mode === "initial") loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,4 +228,7 @@ async function handleCardTap(item) {
|
||||
.card-content { font-size: 13px; color: #6b7280; line-height: 1.5; display: block; word-break: break-all; }
|
||||
|
||||
.card-arrow { font-size: 20px; color: #d1d5db; margin-top: 6px; flex-shrink: 0; }
|
||||
|
||||
/* 触底加载状态 */
|
||||
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
|
||||
</style>
|
||||
|
||||
233
track-uniapp/src/pages/profile/feedback.vue
Normal file
233
track-uniapp/src/pages/profile/feedback.vue
Normal file
@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 问题类型 -->
|
||||
<view class="section">
|
||||
<text class="section-title">问题类型</text>
|
||||
<view class="type-row">
|
||||
<view
|
||||
v-for="t in FEEDBACK_TYPES"
|
||||
:key="t.value"
|
||||
:class="['type-chip', form.type === t.value ? 'type-chip-active' : '']"
|
||||
@tap="form.type = t.value"
|
||||
>{{ t.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问题描述 -->
|
||||
<view class="section">
|
||||
<text class="section-title">详细描述</text>
|
||||
<view class="textarea-wrap">
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
class="textarea"
|
||||
placeholder="请描述遇到的问题:在哪个页面、做了什么操作、出现什么结果。描述越清楚,我们修得越快。"
|
||||
placeholder-class="ta-placeholder"
|
||||
:maxlength="MAX_LEN"
|
||||
:show-confirm-bar="false"
|
||||
auto-height
|
||||
/>
|
||||
<text class="counter">{{ form.content.length }}/{{ MAX_LEN }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 图片上传(复用 utils/upload.js 的有界并发上传;上限 4 张) -->
|
||||
<view class="section">
|
||||
<text class="section-title">问题截图<text class="section-sub">(选填,最多 {{ MAX_IMAGES }} 张)</text></text>
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in form.images" :key="img + '-' + i" class="img-cell">
|
||||
<view class="success-badge-wrapper img-frame">
|
||||
<image :src="imageUrl(img)" mode="aspectFill" class="img" @tap="previewImage(i)" />
|
||||
<view v-if="isUploaded(img)" class="success-badge" />
|
||||
</view>
|
||||
<text class="img-del" @tap.stop="removeImage(i)">✕</text>
|
||||
</view>
|
||||
<!-- ⏳ 占位符:每张图成功/失败都会恰好回收一个,避免"以为传好了其实没有" -->
|
||||
<view v-for="n in pendingCount" :key="'p' + n" class="img-cell img-loading">
|
||||
<text class="img-loading-text">⏳</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="form.images.length + pendingCount < MAX_IMAGES"
|
||||
class="img-cell img-add"
|
||||
@tap="chooseImages"
|
||||
>
|
||||
<text class="img-add-icon">📷</text>
|
||||
<text class="img-add-text">添加图片</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="submit-btn"
|
||||
:disabled="!canSubmit"
|
||||
@tap="handleSubmit"
|
||||
>{{ submitting ? '提交中...' : '提交反馈' }}</button>
|
||||
|
||||
<view class="hint">提交后我们会尽快处理。若设备故障影响生产,请同时通知当班主管。</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from "vue";
|
||||
import { FEEDBACK_TYPES, submitFeedback } from "../../api/feedback";
|
||||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||||
import { getBaseUrl } from "../../utils/request";
|
||||
|
||||
const MAX_IMAGES = 4;
|
||||
const MAX_LEN = 500;
|
||||
|
||||
const form = reactive({ type: "system", content: "", images: [] });
|
||||
const pendingCount = ref(0);
|
||||
const uploading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const canSubmit = computed(
|
||||
() => form.content.trim().length > 0 && !uploading.value && !submitting.value
|
||||
);
|
||||
|
||||
function imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
const domain = getBaseUrl().replace(/\/api.*$/, "");
|
||||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||||
}
|
||||
|
||||
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
|
||||
function isUploaded(img) {
|
||||
return isUploadedUrl(img);
|
||||
}
|
||||
|
||||
function previewImage(index) {
|
||||
uni.previewImage({ urls: form.images.map(imageUrl), current: index });
|
||||
}
|
||||
|
||||
function removeImage(index) {
|
||||
form.images.splice(index, 1);
|
||||
}
|
||||
|
||||
async function chooseImages() {
|
||||
const maxSlots = MAX_IMAGES - (form.images.length + pendingCount.value);
|
||||
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) {
|
||||
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
pendingCount.value += compressedPaths.length;
|
||||
// 🚀 复用 utils/upload.js 的 3 并发上传池(与追加记录 / 驳回弹窗同一份实现)
|
||||
const { failed, total } = await uploadImages(compressedPaths, (url) => {
|
||||
if (url) form.images.push(url);
|
||||
pendingCount.value--;
|
||||
});
|
||||
uploading.value = false;
|
||||
if (failed > 0) {
|
||||
uni.showToast({
|
||||
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readUserId() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
if (typeof user === "string" && user) user = JSON.parse(user);
|
||||
return (user && (user.username || user.id)) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit.value) return;
|
||||
// 上传还没结束就提交,会把没传完的图片漏掉 —— 拦住并说明原因
|
||||
if (pendingCount.value > 0) {
|
||||
uni.showToast({ title: "图片还在上传,请稍候", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
await submitFeedback({
|
||||
type: form.type,
|
||||
content: form.content.trim(),
|
||||
images: form.images,
|
||||
user_id: readUserId(),
|
||||
});
|
||||
uni.showToast({ title: "感谢反馈", icon: "success" });
|
||||
// 让 toast 露个面再返回;被直接打开(无上级页)时退回个人中心,避免卡死
|
||||
setTimeout(() => {
|
||||
if (getCurrentPages().length > 1) uni.navigateBack();
|
||||
else uni.reLaunch({ url: "/pages/profile/index" });
|
||||
}, 1200);
|
||||
} catch (e) {
|
||||
// 校验失败 / 断网等真实错误照常提示;request.js 已弹过通用 toast 就不再重复弹
|
||||
console.error("[feedback] 提交失败:", e);
|
||||
if (e && e.isNetworkError) {
|
||||
uni.showToast({ title: "网络连接失败,请稍后重试", icon: "none", duration: 3000 });
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||||
|
||||
.section { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.section-title { font-size: 14px; font-weight: 700; color: #1f2937; display: block; margin-bottom: 12px; }
|
||||
.section-sub { font-size: 12px; font-weight: 400; color: #9ca3af; }
|
||||
|
||||
/* 问题类型 */
|
||||
.type-row { display: flex; gap: 10px; }
|
||||
.type-chip { flex: 1; height: 40px; line-height: 40px; text-align: center; border-radius: 10px; background: #f3f4f6; color: #6b7280; font-size: 13px; font-weight: 600; }
|
||||
.type-chip-active { background: #2563eb; color: #fff; }
|
||||
|
||||
/* 描述 */
|
||||
.textarea-wrap { position: relative; }
|
||||
.textarea { width: 100%; min-height: 110px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 10px 26px; font-size: 14px; line-height: 1.5; box-sizing: border-box; background: #fafbfc; }
|
||||
.ta-placeholder { color: #b0b6bf; font-size: 13px; }
|
||||
.counter { position: absolute; right: 12px; bottom: 8px; font-size: 11px; color: #b0b6bf; }
|
||||
|
||||
/* 图片 */
|
||||
.img-grid { display: flex; flex-wrap: wrap; }
|
||||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 0 16rpx 16rpx 0; }
|
||||
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
|
||||
.img-frame { width: 160rpx; height: 160rpx; }
|
||||
.img { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; background: #e5e7eb; }
|
||||
.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; }
|
||||
.img-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-loading-text { font-size: 36rpx; }
|
||||
.img-add { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f9fafb; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-add-icon { font-size: 36rpx; }
|
||||
.img-add-text { font-size: 20rpx; color: #9ca3af; margin-top: 4rpx; }
|
||||
|
||||
/* 提交 */
|
||||
.submit-btn { width: 100%; height: 46px; line-height: 46px; background: #2563eb; color: #fff; border-radius: 12px; font-size: 15px; font-weight: 600; margin-top: 6px; padding: 0; }
|
||||
.submit-btn::after { border: none; }
|
||||
.submit-btn[disabled] { opacity: 0.45; }
|
||||
.hint { margin-top: 14px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
</style>
|
||||
@ -15,7 +15,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
<view class="version">{{ appVersion }}</view>
|
||||
</view>
|
||||
</template>
|
||||
@ -36,28 +35,26 @@ onMounted(() => {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
function handleMenuClick(item) {
|
||||
switch (item) {
|
||||
case "关于":
|
||||
uni.showModal({
|
||||
title: "关于 Track",
|
||||
content: "Track 生产流转管理系统\n当前版本:" + appVersion.value + "\n核心架构:FastAPI + Vue3 + uni-app",
|
||||
showCancel: false,
|
||||
});
|
||||
break;
|
||||
case "帮助与反馈":
|
||||
uni.showToast({ title: "反馈通道搭建中,敬请期待...", icon: "none" });
|
||||
break;
|
||||
// 工作统计、设置 暂不处理
|
||||
}
|
||||
}
|
||||
// 菜单项 → 子页面路由。退出登录已迁到「设置」页,此处不再保留按钮
|
||||
const MENU_ROUTES = {
|
||||
"工作统计": "/pages/profile/statistics",
|
||||
"设置": "/pages/profile/settings",
|
||||
"帮助与反馈": "/pages/profile/feedback",
|
||||
};
|
||||
|
||||
function handleLogout() {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("user");
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
function handleMenuClick(item) {
|
||||
const url = MENU_ROUTES[item];
|
||||
if (url) {
|
||||
uni.navigateTo({ url });
|
||||
return;
|
||||
}
|
||||
if (item === "关于") {
|
||||
uni.showModal({
|
||||
title: "关于 Track",
|
||||
content: "Track 生产流转管理系统\n当前版本:" + appVersion.value + "\n核心架构:FastAPI + Vue3 + uni-app",
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -72,6 +69,5 @@ function handleLogout() {
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.menu-arrow { font-size: 18px; color: #d1d5db; }
|
||||
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 24px; }
|
||||
</style>
|
||||
|
||||
214
track-uniapp/src/pages/profile/settings.vue
Normal file
214
track-uniapp/src/pages/profile/settings.vue
Normal file
@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="menu-card">
|
||||
<view class="menu-item" @tap="handleClearCache">
|
||||
<view class="menu-labels">
|
||||
<text class="menu-text">清理缓存</text>
|
||||
<text class="menu-sub">{{ cacheSummary }}</text>
|
||||
</view>
|
||||
<view class="menu-right">
|
||||
<text class="menu-value">{{ cacheSize }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @tap="handleCheckUpdate">
|
||||
<view class="menu-labels">
|
||||
<text class="menu-text">检查更新</text>
|
||||
<text class="menu-sub">热更新资源包,无需重装 App</text>
|
||||
</view>
|
||||
<view class="menu-right">
|
||||
<text class="menu-value">{{ appVersion }}</text>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 明确告诉工人「清的是什么、不动什么」—— 否则点了没变化会以为没生效 -->
|
||||
<view class="hint">
|
||||
清理仅删除留言已读记录等临时数据,不会影响登录状态、后端地址配置,也不会删除已上传的照片。
|
||||
</view>
|
||||
|
||||
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||
|
||||
<view class="version">Track 生产流转 {{ appVersion }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { checkAppUpdate } from "../../utils/ota";
|
||||
|
||||
// ============================================================
|
||||
// 缓存清理策略 —— 黑名单式:只删「明确登记过的业务缓存」
|
||||
// ============================================================
|
||||
//
|
||||
// 不用白名单(遍历时跳过白名单、其余全删):那样每引入一个新的插件 / SDK,
|
||||
// 只要它在本地存了配置,就会被无声清掉,属于「默认伤害」。
|
||||
// 黑名单反过来 —— **没被匹配到的 key 一律保留**,新增插件天然免疫。
|
||||
//
|
||||
// ⚠️ 新增业务缓存时,请顺手到下面登记前缀,不要在这里写零散判断。
|
||||
|
||||
/** 按前缀匹配的可清理缓存 */
|
||||
const CACHE_KEY_PREFIXES = [
|
||||
// ── 当前真实在用 ──────────────────────────────────────────
|
||||
// 留言抽屉的「已读位点」,scan/detail.vue 里每打开一台设备的留言就写一个
|
||||
// msg_seen_<product_id>,随工人扫过的设备数无上限增长。这是目前全 App 唯一
|
||||
// 真正会无限膨胀的业务缓存。清掉只影响未读小红点会重新出现,无业务损失。
|
||||
"msg_seen_",
|
||||
|
||||
// ── 预留:以下前缀当前无数据,供新功能落缓存时对齐命名 ──────
|
||||
"draft_", // 各页面临时保存的草稿
|
||||
"cache_", // 通用业务缓存
|
||||
"search_", // 历史搜索
|
||||
"history_", // 操作历史
|
||||
"scan_", // 扫码记录
|
||||
"recent_", // 最近访问
|
||||
"tmp_", // 临时数据
|
||||
"upload_", // 未完成上传的图片本地记录
|
||||
];
|
||||
|
||||
/** 无前缀可循、需整键精确匹配的(当前为空,留作扩展位) */
|
||||
const CACHE_KEYS_EXACT = new Set();
|
||||
|
||||
/**
|
||||
* 无论如何都不允许删除的 key —— 最后一道保险。
|
||||
* 即使将来有人加了与它们撞车的前缀规则(比如为「user_viewed_tips」加了
|
||||
* `user_`),也不至于把登录凭证或环境配置连带清掉。
|
||||
*/
|
||||
const NEVER_DELETE = new Set(["access_token", "refresh_token", "token", "user", "env_base_url"]);
|
||||
|
||||
function isCacheKey(key) {
|
||||
if (NEVER_DELETE.has(key)) return false;
|
||||
if (CACHE_KEYS_EXACT.has(key)) return true;
|
||||
return CACHE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
const appVersion = ref("T1.0.2");
|
||||
const cacheSize = ref("—");
|
||||
const cacheCount = ref(0); // 当前可清理的临时数据条数
|
||||
|
||||
/**
|
||||
* 「清理缓存」的副标题 —— 直接回答「到底清的是什么」。
|
||||
* 原先只有一个按钮,工人点完看不到任何变化,会怀疑没生效。
|
||||
*/
|
||||
const cacheSummary = computed(() => {
|
||||
if (cacheCount.value > 0) return `${cacheCount.value} 项临时数据(留言已读记录等)`;
|
||||
return "暂无临时数据可清理";
|
||||
});
|
||||
|
||||
function readAppVersion() {
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
return sysInfo.appWgtVersion || sysInfo.appVersion || "T1.0.2";
|
||||
} catch {
|
||||
return "T1.0.2";
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新缓存信息:占用体积 + 可清理条数 —— 让「清理缓存」有个看得见的效果,而不是盲点一下 */
|
||||
function refreshCacheSize() {
|
||||
try {
|
||||
const info = uni.getStorageInfoSync() || {};
|
||||
const kb = typeof info.currentSize === "number" ? info.currentSize : null;
|
||||
cacheSize.value = kb == null ? "—" : kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
||||
cacheCount.value = (info.keys || []).filter(isCacheKey).length;
|
||||
} catch {
|
||||
cacheSize.value = "—";
|
||||
cacheCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存 —— 逐键判定:只清 isCacheKey() 认得出来的业务缓存。
|
||||
*
|
||||
* 其余一律保留,包括登录态、环境配置,以及任何未登记的 key。
|
||||
* 逐项 try/catch,单项失败不影响其余项继续清理。
|
||||
*/
|
||||
function handleClearCache() {
|
||||
let keys = [];
|
||||
try {
|
||||
keys = (uni.getStorageInfoSync() || {}).keys || [];
|
||||
} catch (e) {
|
||||
console.error("[settings] 读取缓存列表失败:", e);
|
||||
uni.showToast({ title: "清理失败,请重试", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
let removed = 0;
|
||||
let failed = 0;
|
||||
for (const key of keys) {
|
||||
if (!isCacheKey(key)) continue; // 未登记的一律保留(登录态、环境配置、未来插件配置)
|
||||
try {
|
||||
uni.removeStorageSync(key);
|
||||
removed++;
|
||||
} catch (e) {
|
||||
// 单项失败不影响其余项继续清理
|
||||
failed++;
|
||||
console.error("[settings] 清除缓存项失败:", key, e);
|
||||
}
|
||||
}
|
||||
console.log(`[settings] 缓存清理完成:已清除 ${removed} 项,失败 ${failed} 项,保留 ${keys.length - removed} 项`);
|
||||
|
||||
refreshCacheSize();
|
||||
if (failed > 0) {
|
||||
uni.showToast({ title: `清理完成,${failed} 项未能清除`, icon: "none" });
|
||||
} else if (removed === 0) {
|
||||
// 一项都没删就别报「清理成功」—— 那是在骗用户
|
||||
uni.showToast({ title: "暂无可清理的缓存", icon: "none" });
|
||||
} else {
|
||||
uni.showToast({ title: "清理成功", icon: "success" });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckUpdate() {
|
||||
// manual = true:跳过节流并给出「已是最新 / 检查失败」的明确反馈 ——
|
||||
// 用户主动点的按钮必须有个回音(自动检查才静默收场)
|
||||
await checkAppUpdate({ manual: true });
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// 退出是不可逆的(要重新输账号密码),按车间使用场景加一道确认防误触
|
||||
uni.showModal({
|
||||
title: "退出登录",
|
||||
content: "退出后需要重新输入账号密码,确定退出吗?",
|
||||
confirmText: "退出",
|
||||
cancelText: "取消",
|
||||
success: (res) => {
|
||||
if (!res.confirm) return;
|
||||
try {
|
||||
uni.removeStorageSync("token");
|
||||
uni.removeStorageSync("access_token");
|
||||
uni.removeStorageSync("refresh_token");
|
||||
uni.removeStorageSync("user");
|
||||
} catch {}
|
||||
uni.reLaunch({ url: "/pages/login/login" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
appVersion.value = readAppVersion();
|
||||
refreshCacheSize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||||
|
||||
.menu-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); overflow: hidden; }
|
||||
.menu-item { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f3f4f6; }
|
||||
.menu-item:last-child { border-bottom: none; }
|
||||
.menu-labels { display: flex; flex-direction: column; gap: 3px; }
|
||||
.menu-text { font-size: 14px; color: #374151; }
|
||||
.menu-sub { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.hint { margin: 14px 4px 0; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
.menu-right { display: flex; align-items: center; gap: 6px; }
|
||||
.menu-value { font-size: 12px; color: #9ca3af; }
|
||||
.menu-arrow { font-size: 18px; color: #d1d5db; }
|
||||
|
||||
.logout-btn { width: 100%; height: 44px; background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 10px; font-size: 14px; margin-top: 24px; line-height: 44px; }
|
||||
.logout-btn::after { border: none; }
|
||||
.version { text-align: center; font-size: 11px; color: #d1d5db; margin-top: 16px; }
|
||||
</style>
|
||||
290
track-uniapp/src/pages/profile/statistics.vue
Normal file
290
track-uniapp/src/pages/profile/statistics.vue
Normal file
@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 时段选择 -->
|
||||
<view class="range-bar">
|
||||
<view v-for="r in RANGES" :key="r.key"
|
||||
:class="['range-chip', range === r.key ? 'range-chip-active' : '']"
|
||||
@tap="selectRange(r.key)">{{ r.label }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义时段:起止日期 -->
|
||||
<view v-if="range === 'custom'" class="custom-row">
|
||||
<picker mode="date" :value="customStart || todayStr" :end="todayStr" @change="onCustomStart">
|
||||
<view class="date-pill">{{ customStart || '开始日期' }}</view>
|
||||
</picker>
|
||||
<text class="date-sep">至</text>
|
||||
<picker mode="date" :value="customEnd || todayStr" :start="customStart" :end="todayStr" @change="onCustomEnd">
|
||||
<view class="date-pill">{{ customEnd || todayStr }}</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="!rangeInvalid" class="range-label">{{ rangeLabel }}</view>
|
||||
|
||||
<view v-if="loading" class="center">
|
||||
<text class="center-icon">⏳</text>
|
||||
<text class="center-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 失败也要给出口:工人不会去看控制台,只有「重试」按钮能救他 -->
|
||||
<view v-else-if="error" class="error-box">
|
||||
<text class="error-text">{{ error }}</text>
|
||||
<button class="retry-btn" @tap="load">重新加载</button>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- 生产战绩 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">生产战绩</text>
|
||||
<text class="card-date">{{ rangeLabel }}</text>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<view class="stat">
|
||||
<text class="stat-num num-done">{{ stats.tasks_completed }}</text>
|
||||
<text class="stat-label">完成</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num" :class="stats.tasks_rejected > 0 ? 'num-reject' : 'num-zero'">
|
||||
{{ stats.tasks_rejected }}
|
||||
</text>
|
||||
<text class="stat-label">被驳回</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-product">{{ stats.products_touched }}</text>
|
||||
<text class="stat-label">参与产品</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-foot">{{ recordComment }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作统计 —— 与 PC「人员操作统计」同一口径,方便和主管核对 -->
|
||||
<view class="card">
|
||||
<view class="card-head">
|
||||
<text class="card-title">操作统计</text>
|
||||
<text class="card-date">与管理系统同口径</text>
|
||||
</view>
|
||||
<view class="stat-row">
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.receive_count }}</text>
|
||||
<text class="stat-label">接收</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.transfer_count }}</text>
|
||||
<text class="stat-label">转交</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-num num-op">{{ stats.record_count }}</text>
|
||||
<text class="stat-label">上传备注</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-foot">操作总计 {{ stats.op_total }} 次</view>
|
||||
</view>
|
||||
|
||||
<view class="hint">
|
||||
统计口径:完成 / 被驳回按本人名下任务计;接收、转交按实际操作人计,上传备注归到任务负责人名下。时间以北京时间为准。
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { onShow, onPullDownRefresh } from "@dcloudio/uni-app";
|
||||
import { getMyStats } from "../../api/stats";
|
||||
|
||||
const RANGES = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "week", label: "本周" },
|
||||
{ key: "month", label: "本月" },
|
||||
{ key: "custom", label: "自定义" },
|
||||
];
|
||||
|
||||
const range = ref("today");
|
||||
const customStart = ref("");
|
||||
const customEnd = ref("");
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const stats = ref({
|
||||
tasks_completed: 0, tasks_rejected: 0, products_touched: 0,
|
||||
receive_count: 0, transfer_count: 0, record_count: 0, op_total: 0,
|
||||
});
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
function ymd(d) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
const todayStr = ymd(new Date());
|
||||
|
||||
/**
|
||||
* 把当前选择换算成时间段。
|
||||
*
|
||||
* 边界一律显式拼 "+08:00" —— 车间按北京时间作息,统计口径也定义在北京时间上。
|
||||
* 若图省事发不带偏移的裸字符串,后端会按服务器本地时间解释,边界整体偏 8 小时,
|
||||
* 出现「选了今日,昨晚下午的活也算进来」这种错位。
|
||||
*/
|
||||
function buildRange() {
|
||||
const now = new Date();
|
||||
let startYmd = todayStr;
|
||||
let endYmd = todayStr;
|
||||
|
||||
if (range.value === "week") {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // 周一为一周之始
|
||||
startYmd = ymd(d);
|
||||
} else if (range.value === "month") {
|
||||
startYmd = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
} else if (range.value === "custom") {
|
||||
startYmd = customStart.value || todayStr;
|
||||
endYmd = customEnd.value || todayStr;
|
||||
}
|
||||
|
||||
return {
|
||||
since: `${startYmd}T00:00:00+08:00`,
|
||||
until: `${endYmd}T23:59:59+08:00`,
|
||||
label: startYmd === endYmd ? startYmd : `${startYmd} ~ ${endYmd}`,
|
||||
};
|
||||
}
|
||||
|
||||
const rangeInvalid = computed(
|
||||
() => range.value === "custom" && !!customStart.value && !!customEnd.value && customStart.value > customEnd.value
|
||||
);
|
||||
const rangeLabel = computed(() => buildRange().label);
|
||||
|
||||
const recordComment = computed(() => {
|
||||
const { tasks_completed, tasks_rejected, products_touched } = stats.value;
|
||||
if (tasks_completed === 0 && tasks_rejected === 0) return "这段时间还没有完工记录";
|
||||
if (tasks_rejected > 0) return `有 ${tasks_rejected} 单被驳回,记得跟进返工`;
|
||||
return `完成 ${tasks_completed} 单,涉及 ${products_touched} 台设备 👍`;
|
||||
});
|
||||
|
||||
function readUsername() {
|
||||
try {
|
||||
let user = uni.getStorageSync("user");
|
||||
if (typeof user === "string" && user) user = JSON.parse(user);
|
||||
return (user && user.username) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (rangeInvalid.value) {
|
||||
loading.value = false;
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
const username = readUsername();
|
||||
if (!username) {
|
||||
loading.value = false;
|
||||
error.value = "未获取到登录信息,请重新登录";
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const { since, until } = buildRange();
|
||||
const res = await getMyStats(username, since, until);
|
||||
stats.value = {
|
||||
tasks_completed: res?.tasks_completed || 0,
|
||||
tasks_rejected: res?.tasks_rejected || 0,
|
||||
products_touched: res?.products_touched || 0,
|
||||
receive_count: res?.receive_count || 0,
|
||||
transfer_count: res?.transfer_count || 0,
|
||||
record_count: res?.record_count || 0,
|
||||
op_total: res?.op_total || 0,
|
||||
};
|
||||
} catch (e) {
|
||||
// request.js 已经弹过一次 toast,这里只在页面上留一个可操作的出口
|
||||
error.value = e && e.isNetworkError ? "网络连接失败,请检查网络后重试" : "统计数据加载失败";
|
||||
console.error("[statistics] 加载个人统计失败:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectRange(key) {
|
||||
if (range.value === key) return;
|
||||
range.value = key;
|
||||
// 切到自定义时先给个默认区间(本月至今),避免空值直接发请求
|
||||
if (key === "custom" && !customStart.value) {
|
||||
const now = new Date();
|
||||
customStart.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`;
|
||||
customEnd.value = todayStr;
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
function onCustomStart(e) {
|
||||
customStart.value = e.detail.value;
|
||||
// 开始日期越过结束日期时顺势把结束日跟过去,避免出现无效区间
|
||||
if (customEnd.value && customStart.value > customEnd.value) customEnd.value = customStart.value;
|
||||
load();
|
||||
}
|
||||
function onCustomEnd(e) {
|
||||
customEnd.value = e.detail.value;
|
||||
load();
|
||||
}
|
||||
|
||||
// 统计是「每次进来都要新鲜」的数据,用 onShow 保证从详情页返回后也刷新
|
||||
onShow(load);
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
uni.stopPullDownRefresh(); // 无论成败都要收起动画
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { min-height: 100vh; padding: 16px; background: #f3f4f6; }
|
||||
|
||||
/* 时段选择 */
|
||||
.range-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.range-chip { flex: 1; height: 34px; line-height: 34px; text-align: center; border-radius: 10px; background: #fff; color: #6b7280; font-size: 13px; font-weight: 600; }
|
||||
.range-chip-active { background: #2563eb; color: #fff; }
|
||||
|
||||
/* 自定义起止日期 */
|
||||
.custom-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.date-pill { flex: 1; height: 36px; line-height: 36px; text-align: center; border-radius: 10px; background: #fff; color: #374151; font-size: 13px; border: 1px solid #e5e7eb; }
|
||||
.date-sep { font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.range-label { font-size: 11px; color: #9ca3af; margin: 0 2px 10px; }
|
||||
|
||||
.center { display: flex; flex-direction: column; align-items: center; padding-top: 60px; }
|
||||
.center-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.center-text { font-size: 14px; color: #9ca3af; }
|
||||
|
||||
.error-box { background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; padding: 20px 16px; display: flex; flex-direction: column; align-items: center; }
|
||||
.error-text { font-size: 14px; color: #dc2626; margin-bottom: 14px; text-align: center; }
|
||||
.retry-btn { width: 160px; height: 40px; line-height: 40px; background: #2563eb; color: #fff; border-radius: 10px; font-size: 14px; font-weight: 600; padding: 0; margin: 0; }
|
||||
.retry-btn::after { border: none; }
|
||||
|
||||
.card { background: #fff; border-radius: 14px; padding: 16px; margin-bottom: 14px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2937; }
|
||||
.card-date { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.stat-row { display: flex; align-items: center; }
|
||||
.stat { flex: 1; display: flex; flex-direction: column; align-items: center; }
|
||||
.stat-divider { width: 1px; height: 40px; background: #f3f4f6; }
|
||||
.stat-num { font-size: 30px; font-weight: 800; line-height: 1.15; }
|
||||
.stat-label { font-size: 12px; color: #6b7280; margin-top: 4px; }
|
||||
.num-done { color: #16a34a; }
|
||||
.num-reject { color: #dc2626; }
|
||||
.num-zero { color: #d1d5db; }
|
||||
.num-product { color: #2563eb; }
|
||||
.num-op { color: #4f46e5; }
|
||||
|
||||
.card-foot { margin-top: 14px; padding-top: 12px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; }
|
||||
|
||||
.hint { margin-top: 4px; padding: 0 4px; font-size: 11px; color: #b0b6bf; line-height: 1.6; }
|
||||
</style>
|
||||
@ -16,6 +16,8 @@
|
||||
</view>
|
||||
<!-- 时间独占一行 -->
|
||||
<text class="fc-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
|
||||
<!-- 驳回/返工原因:原 REJECTED 取 reject_reason;返工节点取含“驳回/返工”的备注 -->
|
||||
<text v-if="reasonText" style="display:block;margin:8rpx 0 0;font-size:22rpx;line-height:1.4;color:#dc2626;font-weight:600;">❌ {{ reasonText }}</text>
|
||||
<!-- 卡片底部:全宽蓝色日志条(对齐 Web 端) -->
|
||||
<view v-if="node.records && node.records.length" class="ft-log-bar" @tap.stop="$emit('viewRecords', node)">
|
||||
查看操作日志 ({{ node.records.length }}条) ›
|
||||
@ -34,6 +36,13 @@ export default {
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
// 驳回/返工原因:原 REJECTED 有 reject_reason;返工节点原因在 remark(形如“返工任务(驳回自…原因…)”)
|
||||
reasonText() {
|
||||
const n = this.node || {};
|
||||
if (n.reject_reason) return `驳回: ${n.reject_reason}`;
|
||||
const rm = n.remark;
|
||||
return rm && (rm.indexOf("驳回") >= 0 || rm.indexOf("返工") >= 0) ? rm : "";
|
||||
},
|
||||
isMainNode() {
|
||||
return !this.node.parent_task_id
|
||||
|| this.node.task_type === "TRANSFER"
|
||||
|
||||
@ -1,188 +0,0 @@
|
||||
<template>
|
||||
<view class="ft-node">
|
||||
<!-- 节点卡片 -->
|
||||
<view class="ft-card" :class="statusColorClass(node.status)">
|
||||
<view class="ft-head">
|
||||
<text class="ft-badge" :class="isMainNode ? 'ft-badge-main' : 'ft-badge-sub'">{{ isMainNode ? '主线' : '分支' }}</text>
|
||||
<text :class="['ft-status', statusColorClass(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
<text v-if="node.is_rework" class="ft-rework">⚠返工</text>
|
||||
</view>
|
||||
<text class="ft-name">{{ node.task_name }}</text>
|
||||
<view class="ft-meta">
|
||||
<!-- 在库任务显示"转入在库",其余显示负责人 -->
|
||||
<text v-if="isWarehouseTask" class="ft-warehouse-in">📥 转入在库: {{ formatUserName(node.assignee_id) }}</text>
|
||||
<text v-else class="ft-assignee">👤 {{ formatUserName(node.assignee_id) || '未分配' }}</text>
|
||||
</view>
|
||||
<view class="ft-bottom">
|
||||
<text class="ft-time">⏰ {{ fmtDate(node.created_at) }}{{ node.completed_at ? ' → ' + fmtDate(node.completed_at) : ' → 至今' }}</text>
|
||||
<text v-if="node.records && node.records.length" class="ft-records" @tap.stop="$emit('viewRecords', node)">📋 {{ node.records.length }}条 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 子任务:先渲染分支(紧贴父节点、缩进),再渲染主干道(继续向下延伸) -->
|
||||
<view v-if="node.child_tasks && node.child_tasks.length">
|
||||
<!-- 1. 先渲染分支:紧贴父节点下方,缩进 + 左侧虚线框,视觉上被父节点"兜住" -->
|
||||
<view v-if="branchChildren.length" class="ft-branch-children">
|
||||
<view class="ft-branch-indicator">↳ 分支延伸</view>
|
||||
<FlowTree
|
||||
v-for="child in branchChildren"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:is-branch-context="true"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</view>
|
||||
<!-- 2. 再渲染主干道:每个主线子节点前都画向下连线箭头(含父→长子) -->
|
||||
<template v-for="(child, i) in mainChildren" :key="'mc-' + child.id">
|
||||
<view v-if="i === 0 || i > 0" class="ft-flow-arrow">↓</view>
|
||||
<FlowTree
|
||||
:node="child"
|
||||
:current-user="currentUser"
|
||||
:current-user-id="currentUserId"
|
||||
:current-username="currentUsername"
|
||||
@view-records="$emit('viewRecords', $event)"
|
||||
/>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { formatUserName as fmtUserName } from "../../../utils/format";
|
||||
|
||||
export default {
|
||||
name: "FlowTree",
|
||||
props: {
|
||||
node: { type: Object, required: true },
|
||||
currentUser: { type: Object, default: null },
|
||||
currentUserId: { type: [String, Number], default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
// 分支上下文:一旦进入分支,其子子孙孙都强制缩进,不再回主干道
|
||||
isBranchContext: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["viewRecords"],
|
||||
computed: {
|
||||
isMainNode() {
|
||||
return !this.node.parent_task_id
|
||||
|| this.node.task_type === "TRANSFER"
|
||||
|| this.node.task_type === "RECOVERY";
|
||||
},
|
||||
// 主线子任务:TRANSFER/RECOVERY,继续主干道(左对齐)
|
||||
// 分支上下文拦截:一旦处于分支,所有子任务强制归入分支,主线返回空
|
||||
mainChildren() {
|
||||
if (this.isBranchContext) return [];
|
||||
return (this.node.child_tasks || []).filter((c) =>
|
||||
!c.parent_task_id || c.task_type === "TRANSFER" || c.task_type === "RECOVERY"
|
||||
);
|
||||
},
|
||||
// 分支子任务:其余(SPAWN 协助等),缩进显示
|
||||
// 分支上下文拦截:处于分支时,全部子任务都作为分支继续缩进
|
||||
branchChildren() {
|
||||
if (this.isBranchContext) return this.node.child_tasks || [];
|
||||
return (this.node.child_tasks || []).filter((c) =>
|
||||
c.parent_task_id && c.task_type !== "TRANSFER" && c.task_type !== "RECOVERY"
|
||||
);
|
||||
},
|
||||
isWarehouseTask() {
|
||||
const name = String(this.node.task_name || "");
|
||||
return (name.includes("在库") || name.includes("入库")) || this.node.assignee_id === "virtual_warehouse";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Vue2 Options API 中 import 的函数需挂到 methods 才能被模板访问
|
||||
formatUserName(userId) {
|
||||
return fmtUserName(userId);
|
||||
},
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库" };
|
||||
return map[s] || s;
|
||||
},
|
||||
statusColorClass(s) {
|
||||
switch (s) {
|
||||
case "PENDING": return "s-yellow";
|
||||
case "WIP": return "s-blue";
|
||||
case "COMPLETED": return "s-green";
|
||||
case "REJECTED": return "s-red";
|
||||
case "ARCHIVED": return "s-archived";
|
||||
case "OUTBOUND": return "s-outbound";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
fmtDate(t) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ft-node { margin-bottom: 10px; }
|
||||
.ft-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-left-width: 4px;
|
||||
padding: 10px 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
/* 状态左色条 */
|
||||
.ft-card.s-yellow { border-left-color: #f59e0b; }
|
||||
.ft-card.s-blue { border-left-color: #3b82f6; }
|
||||
.ft-card.s-green { border-left-color: #22c55e; }
|
||||
.ft-card.s-red { border-left-color: #ef4444; }
|
||||
.ft-card.s-archived { border-left-color: #8b5cf6; }
|
||||
.ft-card.s-outbound { border-left-color: #4f46e5; }
|
||||
.ft-card.s-gray { border-left-color: #9ca3af; }
|
||||
|
||||
.ft-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.ft-badge { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 4px; color: #fff; }
|
||||
.ft-badge-main { background: #2563eb; }
|
||||
.ft-badge-sub { background: #7c3aed; }
|
||||
.ft-status { font-size: 11px; font-weight: 700; padding: 1px 8px; border-radius: 10px; }
|
||||
.s-yellow .ft-status { background: #fef3c7; color: #b45309; }
|
||||
.s-blue .ft-status { background: #dbeafe; color: #1d4ed8; }
|
||||
.s-green .ft-status { background: #dcfce7; color: #15803d; }
|
||||
.s-red .ft-status { background: #fce4ec; color: #be123c; }
|
||||
.s-archived .ft-status { background: #ede9fe; color: #7c3aed; }
|
||||
.s-outbound .ft-status { background: #e0e7ff; color: #4338ca; }
|
||||
.s-gray .ft-status { background: #f3f4f6; color: #6b7280; }
|
||||
.ft-rework { font-size: 10px; background: #ef4444; color: #fff; padding: 1px 5px; border-radius: 4px; font-weight: 700; }
|
||||
|
||||
.ft-name { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.ft-meta { margin-top: 4px; font-size: 12px; }
|
||||
.ft-assignee { color: #4b5563; }
|
||||
.ft-warehouse-in { color: #059669; font-weight: 700; }
|
||||
.ft-bottom { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
|
||||
.ft-time { font-size: 11px; color: #9ca3af; }
|
||||
.ft-records { font-size: 11px; color: #2563eb; font-weight: 600; }
|
||||
|
||||
/* 主线子任务:无缩进(左边缘对齐成笔直主干道),之间用 ↓ 连线 */
|
||||
.ft-flow-arrow {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin: 2px 0;
|
||||
}
|
||||
/* 分支子任务:缩进 + 左侧虚线框,视觉上被父节点"兜住" */
|
||||
.ft-branch-children {
|
||||
margin-left: 12px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px dashed #cbd5e1;
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* 分支指示符:轻量级连接锚点 */
|
||||
.ft-branch-indicator {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
</style>
|
||||
@ -41,6 +41,9 @@
|
||||
|
||||
<text class="tc-name">{{ card.task_name }}</text>
|
||||
|
||||
<!-- 被驳回原任务:显示驳回原因(返工节点原因由下方“备注”框承载) -->
|
||||
<text v-if="card.reject_reason" style="display:block;margin-top:6rpx;font-size:22rpx;line-height:1.4;color:#dc2626;font-weight:600;">❌ 驳回: {{ card.reject_reason }}</text>
|
||||
|
||||
<view class="tc-meta">
|
||||
<view class="tc-meta-row">
|
||||
<!-- 仓储任务:assignee 为 null,直接显示系统名,不取用户头像 -->
|
||||
|
||||
@ -1,246 +0,0 @@
|
||||
<template>
|
||||
<view class="tnode">
|
||||
<view v-if="hasChildren" class="tree-line-vertical" />
|
||||
|
||||
<!-- 卡片 -->
|
||||
<view :class="['tnode-card', statusColor(task.status), { 'is-rework': task.is_rework }]">
|
||||
<!-- 第一层:任务名称、标签、状态 Badge、负责人 -->
|
||||
<view class="tnode-body">
|
||||
<view class="tnode-name-row">
|
||||
<text v-if="task.is_rework" class="tag tag-rework">⚠返工</text>
|
||||
<text v-if="task.child_tasks && task.child_tasks.length > 1" class="tag tag-fission">裂变×{{ task.child_tasks.length }}</text>
|
||||
<text class="tnode-name">{{ task.task_name }}</text>
|
||||
</view>
|
||||
<view class="tnode-meta">
|
||||
<!-- 在库任务不单独显示"负责人",由下方"转入在库"统一展示实际操作人 -->
|
||||
<text v-if="task.assignee_id && !isWarehouseTask">负责人: {{ formatUserName(task.assignee_id) }}</text>
|
||||
<text v-if="task.reject_reason" class="reject-reason">驳回: {{ task.reject_reason }}</text>
|
||||
</view>
|
||||
<!-- 📥 在库任务:显示实际执行"转入在库"的操作人(任务负责人) -->
|
||||
<text v-if="isWarehouseTask && task.assignee_id" class="warehouse-in">📥 转入在库: {{ formatUserName(task.assignee_id) }}</text>
|
||||
|
||||
<!-- 任务初始描述 / 交接备注(常驻显示,区别于动态日志 records) -->
|
||||
<view v-if="task.remark || task.description" class="task-initial-remark">
|
||||
<text class="remark-label">📌 初始说明:</text>
|
||||
<text class="remark-text">{{ task.remark || task.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 状态标签 + 分支闭环标记 -->
|
||||
<view style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;margin-left:8px;">
|
||||
<text :class="['badge', statusColor(task.status)]">{{ statusLabel(task.status) }}</text>
|
||||
<text v-if="task.status === 'COMPLETED' && (!task.child_tasks || !task.child_tasks.length)" class="end-marker">🏁 已终止</text>
|
||||
<text v-if="task.status === 'ARCHIVED'" class="end-marker end-warehouse">📦 已入库</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 第二层:递归子任务树 -->
|
||||
<view v-if="task.child_tasks && task.child_tasks.length" class="tnode-children">
|
||||
<TaskTreeNode
|
||||
v-for="(child, idx) in task.child_tasks"
|
||||
:key="child.id"
|
||||
:task="child"
|
||||
:currentUser="currentUser"
|
||||
:currentUserId="currentUserId"
|
||||
:currentUsername="currentUsername"
|
||||
:readonly="readonly"
|
||||
:has-children="!!(child.child_tasks && child.child_tasks.length)"
|
||||
:isLast="idx === task.child_tasks.length - 1"
|
||||
@action="(e) => $emit('action', e)"
|
||||
@editRecord="(e) => $emit('editRecord', e)"
|
||||
@viewRecords="(t) => $emit('viewRecords', t)"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 第三层(最底部):进度记录 — 最新一条 + 查看全部入口 -->
|
||||
<view v-if="task.records && task.records.length" class="records-area">
|
||||
<!-- 查看全部记录按钮 -->
|
||||
<view class="records-bar" @tap.stop="$emit('viewRecords', task)">
|
||||
<text class="records-bar-icon">📋</text>
|
||||
<text class="records-bar-text">查看全部记录 ({{ task.records.length }})</text>
|
||||
<text class="records-bar-arrow">›</text>
|
||||
</view>
|
||||
<!-- 最新一条预览 -->
|
||||
<view v-if="latestRecord" class="record-item">
|
||||
<view class="record-top">
|
||||
<text class="record-time">{{ formatTime(latestRecord.created_at) }}</text>
|
||||
<view v-if="canEditRecord" class="record-actions">
|
||||
<text class="rec-act" @tap.stop="$emit('editRecord', { task, record: latestRecord })">✏️</text>
|
||||
<text class="rec-act" @tap.stop="confirmDelete(latestRecord)">🗑️</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="latestRecord.remark" class="record-remark">{{ latestRecord.remark }}</text>
|
||||
<view v-if="latestRecord.images && latestRecord.images.length" class="record-images">
|
||||
<image
|
||||
v-for="(img, i) in latestRecord.images"
|
||||
:key="i"
|
||||
:src="imageUrl(img)"
|
||||
class="record-thumb"
|
||||
mode="aspectFill"
|
||||
@tap.stop="previewImage(latestRecord.images, i)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getBaseUrl } from "../../../utils/request";
|
||||
import { formatUserName as fmtUserName } from "../../../utils/format";
|
||||
|
||||
export default {
|
||||
name: "TaskTreeNode",
|
||||
props: {
|
||||
task: { type: Object, required: true },
|
||||
isLast: { type: Boolean, default: false },
|
||||
hasChildren: { type: Boolean, default: false },
|
||||
currentUser: { type: Object, default: null },
|
||||
currentUserId: { type: [String, Number], default: "" },
|
||||
currentUsername: { type: String, default: "" },
|
||||
readonly: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ["action", "editRecord", "viewRecords"],
|
||||
mounted() {
|
||||
console.log("TaskTreeNode task keys:", Object.keys(this.task));
|
||||
console.log("task.remark =", this.task.remark);
|
||||
console.log("task.description =", this.task.description);
|
||||
},
|
||||
computed: {
|
||||
isWarehouseTask() {
|
||||
return !!(this.task && (
|
||||
String(this.task.task_name || '').includes('在库')
|
||||
|| String(this.task.task_name || '').includes('入库')
|
||||
|| this.task.assignee_id === 'virtual_warehouse'
|
||||
));
|
||||
},
|
||||
canEditRecord() {
|
||||
if (this.readonly) return false;
|
||||
if (this.task.assignee_id == this.currentUserId) return true;
|
||||
if (this.task.assignee_id == this.currentUsername) return true;
|
||||
if (this.currentUser && this.currentUser.id == this.task.assignee_id) return true;
|
||||
if (this.currentUser && this.currentUser.username == this.task.assignee_id) return true;
|
||||
return false;
|
||||
},
|
||||
latestRecord() {
|
||||
const recs = this.task.records;
|
||||
if (!recs || !recs.length) return null;
|
||||
return [...recs].sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
// 后端返回 /api/v1/upload/files/xxx.jpg,需要补全域名
|
||||
const domain = getBaseUrl().replace(/\/api.*$/, '');
|
||||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||||
},
|
||||
previewImage(urls, index) {
|
||||
const fullUrls = (urls || []).map(img => this.imageUrl(img));
|
||||
uni.previewImage({ urls: fullUrls, current: index });
|
||||
},
|
||||
confirmDelete(rec) {
|
||||
// 🚀 交给父页面统一做「双重确认倒计时」,避免原生 showModal 取消行为异常
|
||||
this.$emit("action", { task: this.task, type: "deleteRecord", record: rec });
|
||||
},
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
return map[s] || s;
|
||||
},
|
||||
// Vue2 Options API 中 import 的函数需挂到 methods 才能被模板访问
|
||||
formatUserName(userId) {
|
||||
return fmtUserName(userId);
|
||||
},
|
||||
statusColor(s) {
|
||||
switch (s) {
|
||||
case "PENDING": return "s-yellow";
|
||||
case "WIP": return "s-blue";
|
||||
case "COMPLETED": return "s-green";
|
||||
case "REJECTED": return "s-red";
|
||||
case "OUTBOUND": return "s-outbound";
|
||||
default: return "s-gray";
|
||||
}
|
||||
},
|
||||
formatTime(t) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tnode { position: relative; padding-left: 12px; margin-bottom: 6px; }
|
||||
.tree-line-vertical { position: absolute; left: 8px; top: 28px; bottom: 0; width: 2px; background: #e5e7eb; }
|
||||
|
||||
.tnode-card {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
padding: 10px 12px; border-radius: 10px 10px 0 0;
|
||||
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.is-rework { border-left-color: #ef4444 !important; }
|
||||
.s-yellow { border-left-color: #f59e0b; }
|
||||
.s-blue { border-left-color: #3b82f6; }
|
||||
.s-green { border-left-color: #22c55e; }
|
||||
.s-red { border-left-color: #ef4444; }
|
||||
.s-outbound { border-left-color: #4f46e5; }
|
||||
|
||||
.tnode-body { flex: 1; min-width: 0; }
|
||||
.tnode-name-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.tnode-name { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.tag { font-size: 10px; padding: 1px 5px; border-radius: 6px; font-weight: 700; color: #fff; }
|
||||
.tag-rework { background: #ef4444; }
|
||||
.tag-fission { background: #7c3aed; }
|
||||
.tnode-meta { margin-top: 2px; font-size: 11px; color: #9ca3af; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.reject-reason { color: #ef4444; }
|
||||
.warehouse-in { display: block; margin-top: 4px; font-size: 11px; color: #059669; font-weight: 700; }
|
||||
|
||||
/* 任务初始描述(常驻,区别于动态记录) */
|
||||
.task-initial-remark {
|
||||
margin-top: 10rpx; padding: 12rpx 16rpx;
|
||||
background: linear-gradient(135deg, #fefce8, #fef9c3);
|
||||
border-left: 6rpx solid #eab308; border-radius: 8rpx;
|
||||
}
|
||||
.remark-label { font-size: 22rpx; font-weight: 700; color: #a16207; }
|
||||
.remark-text { font-size: 26rpx; color: #713f12; line-height: 1.5; word-break: break-all; }
|
||||
|
||||
/* 进度记录 */
|
||||
.records-area { background-color: #f9f9f9; padding: 0; border-radius: 10rpx; margin-top: 16rpx; margin-bottom: 6rpx; overflow: hidden; }
|
||||
.records-bar {
|
||||
display: flex; align-items: center; gap: 8rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||||
border-bottom: 1px solid #bfdbfe;
|
||||
}
|
||||
.records-bar-icon { font-size: 28rpx; }
|
||||
.records-bar-text { flex: 1; font-size: 26rpx; font-weight: 700; color: #2563eb; }
|
||||
.records-bar-arrow { font-size: 32rpx; color: #93c5fd; font-weight: 700; }
|
||||
.record-item { padding: 12rpx 16rpx; }
|
||||
.record-item { padding: 8rpx 0; border-bottom: 1px dashed #e5e7eb; }
|
||||
.record-item:last-child { border-bottom: none; }
|
||||
.record-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.record-actions { display: flex; gap: 12rpx; }
|
||||
.rec-act { font-size: 28rpx; padding: 4rpx; }
|
||||
.record-remark { font-size: 26rpx; color: #333; display: block; margin: 6rpx 0; line-height: 1.5; word-break: break-all; }
|
||||
.record-images { display: flex; gap: 8rpx; margin-top: 8rpx; flex-wrap: wrap; }
|
||||
.record-thumb { width: 100rpx; height: 100rpx; border-radius: 8rpx; border: 1px solid #e5e7eb; background: #f3f4f6; }
|
||||
.record-time { font-size: 22rpx; color: #9ca3af; }
|
||||
|
||||
.badge {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 20px; font-weight: 600;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
background: #f3f4f6; color: #6b7280;
|
||||
}
|
||||
.end-marker { font-size: 9px; padding: 1px 6px; border-radius: 8px; font-weight: 600; white-space: nowrap; background: #dcfce7; color: #16a34a; }
|
||||
.end-warehouse { background: #ede9fe; color: #7c3aed; }
|
||||
.s-yellow .badge { background: #fef3c7; color: #b45309; }
|
||||
.s-blue .badge { background: #dbeafe; color: #1d4ed8; }
|
||||
.s-green .badge { background: #dcfce7; color: #15803d; }
|
||||
.s-red .badge { background: #fce4ec; color: #be123c; }
|
||||
.s-outbound .badge { background: #e0e7ff; color: #4338ca; }
|
||||
|
||||
.tnode-children { position: relative; }
|
||||
</style>
|
||||
@ -248,7 +248,11 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.workspace-area { height: auto; display: flex; flex-direction: column; }
|
||||
.workspace-area.is-locked { height: 100vh; overflow: hidden; }
|
||||
/* 沉浸工作区(is-locked)不再锁死 height:100vh + overflow:hidden ——
|
||||
那会造出「卡片内部滚动 + 原生页面滚动」两个滚动容器并存的局面:卡片滚到顶
|
||||
之后再往下拉,手势会穿透到页面层,误触发下拉刷新,工人根本没法往上翻内容。
|
||||
高度交还给内容自然撑开,滚动统一由原生页面负责。 */
|
||||
.workspace-area.is-locked { height: auto; }
|
||||
.list-header { display: flex; align-items: baseline; justify-content: space-between; padding: 8px 4px; flex-shrink: 0; }
|
||||
.list-title { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.list-hint { font-size: 11px; color: #9ca3af; }
|
||||
@ -271,7 +275,9 @@ export default {
|
||||
.lock-title { font-size: 14px; font-weight: 700; color: #1f2937; }
|
||||
.branch-done { font-size: 11px; font-weight: 600; color: #16a34a; }
|
||||
.task-count { font-size: 12px; color: #9ca3af; display: block; margin-top: 4px; }
|
||||
.focus-card { flex: 1; min-height: 0; margin: 0 4px; padding: 20px 16px calc(160rpx + env(safe-area-inset-bottom)); border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto; border-top: 5px solid #3b82f6; }
|
||||
/* 去掉 overflow-y: auto —— 锁定任务详情不再自己滚,随页面一起滚。
|
||||
padding-bottom 仍保留 160rpx,用来让内容避开底部固定操作栏。 */
|
||||
.focus-card { min-height: 0; margin: 0 4px; padding: 20px 16px calc(160rpx + env(safe-area-inset-bottom)); border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); border-top: 5px solid #3b82f6; }
|
||||
.focus-card.s-yellow { border-top-color: #f59e0b; }
|
||||
.focus-card.s-blue { border-top-color: #3b82f6; }
|
||||
.focus-card.s-green { border-top-color: #22c55e; }
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
<view class="overall-bar" @tap="handleOverallBarClick">
|
||||
<text class="overall-label">宏观状态</text>
|
||||
<text :class="['overall-val', overallStatusClass(product.overall_status)]">{{ product.overall_status || '未激活 — 点击发起首道工序' }}</text>
|
||||
<!-- 🔧 生命周期标识:生产阶段「发货测试」 vs 出库回流后「售后维修」 -->
|
||||
<text v-if="lifeBadge" :class="['life-badge', lifeBadge.cls]">{{ lifeBadge.label }}</text>
|
||||
<text v-if="product.task_tree && product.task_tree.length && canEditOverallStatus" class="overall-arrow">▾</text>
|
||||
</view>
|
||||
|
||||
@ -36,10 +38,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="product && product.current_location_id === 'virtual_warehouse' && !hasMyActiveTask"
|
||||
class="warehouse-transfer-banner" @tap="openWarehouseTransfer">
|
||||
<text class="wt-icon">📤</text>
|
||||
<text class="wt-text">该产品在仓库中 — 点击此处转出并派发给指定人员</text>
|
||||
<view v-if="showDispatchBanner"
|
||||
class="warehouse-transfer-banner" @tap="openCreateFirstTask">
|
||||
<text class="wt-icon">{{ dispatchBannerIcon }}</text>
|
||||
<text class="wt-text">{{ dispatchBannerText }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@ -70,7 +72,7 @@
|
||||
<text class="sheet-title">{{ product && product.overall_status ? '修改宏观状态' : '🔔 请设定产品宏观状态' }}</text>
|
||||
<text class="sheet-hint">首次扫码,请选择一个状态以开启流转</text>
|
||||
<view class="sheet-options">
|
||||
<view v-for="opt in OVERALL_OPTIONS" :key="opt" :class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']" @tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
|
||||
<view v-for="opt in availableOverallOptions" :key="opt" :class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']" @tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
|
||||
</view>
|
||||
<button v-if="product && product.overall_status" class="sheet-close" @tap="showStatusPicker = false">关闭</button>
|
||||
</view>
|
||||
@ -108,7 +110,7 @@
|
||||
<text class="popup-task">{{ recordPopup.task && recordPopup.task.task_name }}</text>
|
||||
<textarea v-model="recordForm.remark" class="popup-textarea" placeholder="填写备注说明" :maxlength="2000" />
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell"><image :src="img" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" /><text v-if="canDeleteImage" class="img-del" @tap.stop="removeRecordImage(i)">✕</text></view>
|
||||
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell"><view class="success-badge-wrapper img-frame"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" /><view v-if="isUploaded(img)" class="success-badge" /></view><text v-if="canDeleteRecordImage(i)" class="img-del" @tap.stop="removeRecordImage(i)">✕</text></view>
|
||||
<view v-for="n in recordForm.pendingCount" :key="'p'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="recordForm.images.length + recordForm.pendingCount < 9" class="btn-upload" @tap="handleChooseImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${recordForm.images.length + recordForm.pendingCount}/9)` }}</button>
|
||||
@ -124,7 +126,7 @@
|
||||
<text class="popup-hint">状态: {{ statusLabel(actionPopup.task && actionPopup.task.status) }} → 进行中</text>
|
||||
<view class="field-label">选择工序 <text class="required">*</text></view>
|
||||
<view class="user-grid">
|
||||
<view v-for="opt in TASK_NAME_OPTIONS" :key="opt"
|
||||
<view v-for="opt in availableTaskOptions" :key="opt"
|
||||
:class="['user-grid-item', receiveTaskName === opt ? 'user-grid-active' : '']"
|
||||
@tap="receiveTaskName = opt">{{ opt }}</view>
|
||||
</view>
|
||||
@ -134,12 +136,19 @@
|
||||
<template v-if="actionPopup.type === 'reject'">
|
||||
<text class="popup-title">品质驳回</text>
|
||||
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
|
||||
<!-- 📷 异常图片为选填:编号错误、选错工序等场景可不拍照 -->
|
||||
<view class="field-label">异常图片 <text class="optional">(选填,最多 9 张)</text></view>
|
||||
<view class="img-grid">
|
||||
<view v-for="(img, i) in rejectForm.images" :key="i" class="img-cell"><view class="success-badge-wrapper img-frame"><image :src="imageUrl(img)" mode="aspectFill" class="img-thumb" @tap="previewRejectImage(i)" /><view v-if="isUploaded(img)" class="success-badge" /></view><text class="img-del" @tap.stop="removeRejectImage(i)">✕</text></view>
|
||||
<view v-for="n in rejectForm.pendingCount" :key="'rp'+n" class="img-cell img-cell-loading"><text class="img-loading-text">⏳</text></view>
|
||||
</view>
|
||||
<button v-if="rejectForm.images.length + rejectForm.pendingCount < 9" class="btn-upload" @tap="handleChooseRejectImage" :disabled="isUploading">{{ isUploading ? '上传中...' : `📷 拍照/选图 (${rejectForm.images.length + rejectForm.pendingCount}/9)` }}</button>
|
||||
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
|
||||
<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>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-danger" :class="{ 'btn-counting': confirming === 'reject' && confirmCount > 0 }" :disabled="actionLoading || isUploading || !rejectReason.trim() || (confirming === 'reject' && confirmCount > 0)" @tap="confirmBtn('reject', doReject)">{{ isUploading ? '上传中...' : confirmLabel('reject', '确认驳回') }}</button></view>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'transfer'">
|
||||
<text class="popup-title">完工转交</text>
|
||||
<view class="field-label">接收人 <text class="required">*</text></view>
|
||||
<view class="field-label">接收人 / 处理方式 <text class="required">*</text></view>
|
||||
<view class="user-grid">
|
||||
<view v-for="u in userGridOptions" :key="u.id"
|
||||
:class="['user-grid-item', transferForm.selectedUserId === u.id ? 'user-grid-active' : '']"
|
||||
@ -147,10 +156,14 @@
|
||||
</view>
|
||||
<view class="field-label" style="margin-top:12px;">或</view>
|
||||
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
|
||||
<template v-if="showFinishDirect">
|
||||
<view :class="['user-grid-item', transferForm.isFinishDirect ? 'user-grid-active' : '']" style="width:100%;margin-top:8px;" @tap="toggleFinishDirect">🏁 直接完结 (不入库)</view>
|
||||
<text class="popup-hint">🏁 直接完结:结束本任务且不创建下游任务,不改动产品状态(已出库的设备完结后依然是「已出库」)。用于售后返厂直接发走、半成品被直接提走等无需入库的场景</text>
|
||||
</template>
|
||||
<view class="field-label" style="margin-top:12px;">交接备注 <text class="required">*</text></view>
|
||||
<textarea v-model="transferForm.note" class="popup-textarea" placeholder="请填写交接备注(必填)" :maxlength="500" />
|
||||
<view v-if="transferForm.isWarehouse || transferForm.selectedUserId" class="preview-hint">{{ transferForm.isWarehouse ? '产品将入库并从个人待办中移除' : '将创建新任务指派给 ' + (transferUserName || '—') }}</view>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || (!transferForm.isWarehouse && !transferForm.selectedUserId) || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : (transferForm.isWarehouse ? '📦 确认入库' : '确认转交') }}</button></view>
|
||||
<view v-if="transferMode" class="preview-hint">{{ transferPreview }}</view>
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary" :disabled="actionLoading || !transferMode || !transferForm.note.trim()" @tap="doTransfer">{{ actionLoading ? '提交中...' : transferSubmitLabel }}</button></view>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'spawn'">
|
||||
<text class="popup-title">➕ 派发协助分支</text>
|
||||
@ -216,28 +229,32 @@
|
||||
|
||||
<script>
|
||||
import request, { get, post, patch, put, getBaseUrl } from "../../utils/request";
|
||||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||||
import { setUserNameMap, formatUserName, formatUserAvatar } from "../../utils/format";
|
||||
import { taskOptionsFor, overallOptionsFor, lifecycleBadge } from "../../utils/lifecycle";
|
||||
import WorkspaceArea from "./components/WorkspaceArea.vue";
|
||||
import TreeCanvas from "./components/TreeCanvas.vue";
|
||||
import TaskSwipeCards from "./components/TaskSwipeCards.vue";
|
||||
|
||||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库", "已入库", "已出库"];
|
||||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
// 🔧 工序可选项不再写死 —— 由 computed availableOverallOptions / availableTaskOptions
|
||||
// 按生命周期阶段(生产制造 / 售后回流)动态收窄,词表见 utils/lifecycle.js
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", OUTBOUND: "已出库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
components: { WorkspaceArea, TreeCanvas, TaskSwipeCards },
|
||||
data() {
|
||||
return {
|
||||
OVERALL_OPTIONS, loading: true, error: "", product: null,
|
||||
loading: true, error: "", product: null,
|
||||
showStatusPicker: false, editProductVisible: false, editForm: { order_no: "", external_serial: "" }, editSaving: false,
|
||||
users: [], TASK_NAME_OPTIONS,
|
||||
users: [],
|
||||
createFirstVisible: false, isWarehouseTransfer: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "" }, firstSaving: false,
|
||||
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
|
||||
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0, savedCount: 0 }, recordSaving: false, isUploading: false,
|
||||
currentUser: null, currentUserId: "", currentUsername: "", currentUserRole: "", currentMode: "workspace", autoLockTaskId: "",
|
||||
processOptions: [], userOptions: [],
|
||||
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "", receiveTaskName: "",
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, note: "" },
|
||||
// 📷 驳回异常图片(选填):与追加记录共用同一套选图/上传流程
|
||||
rejectForm: { images: [], pendingCount: 0 },
|
||||
transferForm: { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" },
|
||||
spawnForm: { assignee_id: "", remark: "" },
|
||||
// 🛡️ 双重确认倒计时:避免误触
|
||||
confirming: "", // 当前倒计时中的操作 key('' = 无)
|
||||
@ -258,6 +275,64 @@ export default {
|
||||
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; 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 : ""; },
|
||||
// 🔒 直接完结入口仅超管/主管【可见】——普通人看不到,而不是点了才被后端 403。
|
||||
// ⚠️ 必须两个角色都判:本文件既有的 canEditOverallStatus 只判了 SUPER_ADMIN、
|
||||
// 漏了 SUPERVISOR,导致主管被前端误挡。此处与后端 ADMIN_ROLES 对齐。
|
||||
// 🔒 管理角色判定(超管 / 主管)—— 与后端 ADMIN_ROLES 对齐,作为各处权限判断的唯一入口
|
||||
isAdminUser() { const r = (this.currentUser && this.currentUser.role) || this.currentUserRole || ''; return r === 'SUPER_ADMIN' || r === 'SUPERVISOR'; },
|
||||
canFinishDirectly() { return this.isAdminUser; },
|
||||
// 📤 产品是否空闲:没有任何活跃的【主线】任务(WIP/PENDING)。
|
||||
// 只算主干(无父任务 或 TRANSFER/RECOVERY):协助分支(SPAWN)不阻塞派发,
|
||||
// 否则一个挂着的协助分支会把产品永久锁死。
|
||||
hasActiveMainTask() {
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return false;
|
||||
for (const t of tasks) {
|
||||
const isMain = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY';
|
||||
if (isMain && (t.status === 'WIP' || t.status === 'PENDING')) return true;
|
||||
if (walk(t.child_tasks)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return this.product ? walk(this.product.task_tree) : false;
|
||||
},
|
||||
// 📤 派发新任务入口的显示条件 —— 2026-09-17 解绑「派发权限」与「仓库位置」。
|
||||
//
|
||||
// 原实现硬性要求 current_location_id === 'virtual_warehouse',于是
|
||||
// 【直接完结】后的设备(位置停在最后经手人名下、又不在仓库池)派发入口
|
||||
// 彻底消失,产品变成再也无法流转的孤儿数据。
|
||||
// 但物理上除非设备报废,永远存在派发新任务的需求,故增加豁免:
|
||||
// · 在仓库且我本人没有待办 → 转出派发(原有行为,保持不动)
|
||||
// · 产品空闲(无活跃主线任务)且我是超管/主管 → 直接派发新任务
|
||||
// (已出库设备尤其如此:位置在工人名下也不该挡住派发)
|
||||
showDispatchBanner() {
|
||||
if (!this.product) return false;
|
||||
if (this.product.current_location_id === 'virtual_warehouse' && !this.hasMyActiveTask) return true;
|
||||
return this.isAdminUser && !this.hasActiveMainTask;
|
||||
},
|
||||
dispatchBannerText() {
|
||||
return this.product && this.product.current_location_id === 'virtual_warehouse'
|
||||
? '该产品在仓库中 — 点击此处转出并派发给指定人员'
|
||||
: '当前任务已完结 — 点击此处直接派发新任务';
|
||||
},
|
||||
dispatchBannerIcon() {
|
||||
return this.product && this.product.current_location_id === 'virtual_warehouse' ? '📤' : '🚀';
|
||||
},
|
||||
// 🛡️ 直接完结的【场景】门槛:角色够 + 产品确实是「已出库」。
|
||||
// 为什么必须卡状态:普通生产中的设备若被直接完结,产品既无下游任务、
|
||||
// 又不在仓库池中,会变成卡在工人名下的**孤儿数据**;而且售后往往要多步
|
||||
// 流转(发货测试 → 维修 → 入库 → …),提前完结会把任务流彻底切断。
|
||||
// 生产中的设备只能走「入库」或「转交个人」,回归正常流转。
|
||||
showFinishDirect() {
|
||||
return this.canFinishDirectly
|
||||
&& !!this.product
|
||||
&& this.product.overall_status === '已出库';
|
||||
},
|
||||
// 🏁 转交弹窗的三选一模式:'' = 未选 | 'user' 转交个人 | 'warehouse' 入库 | 'direct' 直接完结
|
||||
// direct 分支额外校验 角色 + 场景 双门槛,防御残留选中态把 finish_directly 发出去
|
||||
transferMode() { const f = this.transferForm; if (f.isFinishDirect && this.showFinishDirect) return 'direct'; if (f.isWarehouse) return 'warehouse'; return f.selectedUserId ? 'user' : ''; },
|
||||
transferSubmitLabel() { return { direct: '🏁 确认直接完结', warehouse: '📦 确认入库', user: '确认转交' }[this.transferMode] || '确认转交'; },
|
||||
transferPreview() { return { direct: '任务将直接完结,不创建下游任务;不改动产品状态(已出库的设备完结后依然是「已出库」)', warehouse: '产品将入库并从个人待办中移除', user: '将创建新任务指派给 ' + (this.transferUserName || '—') }[this.transferMode] || ''; },
|
||||
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 })); },
|
||||
modeToggleLabel() { if (this.currentMode === 'workspace') return '📇 流转卡片'; if (this.currentMode === 'swipe') return '🌳 流转树'; return '🛠️ 工作区'; },
|
||||
@ -287,12 +362,60 @@ export default {
|
||||
checkTask(this.product.task_tree);
|
||||
return hasPermission;
|
||||
},
|
||||
// 🔧 生命周期标签:只在售后回流环节(发货测试 / 售后维修)打红色标签
|
||||
lifeBadge() {
|
||||
return lifecycleBadge(
|
||||
this.product && this.product.overall_status,
|
||||
this.product && this.product.lifecycle_phase,
|
||||
);
|
||||
},
|
||||
// ── 🔧 选项隔离:按生命周期阶段收窄可选工序 ──
|
||||
// 生产制造设备只能排「备货/生产/测试/维修/在库」;
|
||||
// 售后回流设备只能选「发货测试/售后维修/在库」,排不回前期环节。
|
||||
isAfterSales() {
|
||||
return !!(this.product && this.product.lifecycle_phase === "AFTER_SALES");
|
||||
},
|
||||
// 是否已有"真实工序"历史 —— 仅判断 task_tree 非空是不够的:
|
||||
// 老设备首次「发起首道工序」建出来的任务名是占位符「待确认」,
|
||||
// 此时产品已有一条任务,但接收人还没机会声明工序。若按 task_tree 非空
|
||||
// 就判定"有历史",接收下拉将只给生产工序,老设备永远选不到「售后维修」,
|
||||
// 售后通路直接断掉。故这里必须排除占位符。
|
||||
hasHistory() {
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return false;
|
||||
for (const t of tasks) {
|
||||
const name = String(t.task_name || "").trim();
|
||||
if (name && name !== "待确认" && !name.includes("virtual_warehouse")) return true;
|
||||
if (walk(t.child_tasks)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return walk(this.product && this.product.task_tree);
|
||||
},
|
||||
availableOverallOptions() {
|
||||
return overallOptionsFor(this.product && this.product.lifecycle_phase, this.hasHistory);
|
||||
},
|
||||
availableTaskOptions() {
|
||||
// 🔧 第三个参数 overall_status:已出库设备同样要走售后词表(发货测试 / 售后维修),
|
||||
// 否则出库后补做质检的工人永远选不到对应工序(鸡生蛋死锁)。
|
||||
return taskOptionsFor(
|
||||
this.product && this.product.lifecycle_phase,
|
||||
this.hasHistory,
|
||||
this.product && this.product.overall_status,
|
||||
);
|
||||
},
|
||||
},
|
||||
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(); },
|
||||
// ⚠️ 本页【刻意不开启】下拉刷新(pages.json 中已移除 enablePullDownRefresh)。
|
||||
// 原因:本页有三种沉浸式模式 —— 锁定的工作区卡片、全屏流转卡片(swiper)、
|
||||
// 全屏流转树(scroll-view),它们各自持有滚动/滑动手势,且都是 position:fixed
|
||||
// 或原生 scroll-view,无法改由页面滚动接管。一旦开启页面下拉刷新,这些区域
|
||||
// 滑到顶后再下拉就会误触发刷新,工人没法正常往上翻内容。
|
||||
// 状态纠偏不依赖下拉刷新:handleNetworkFailure 会自动静默拉取真实状态。
|
||||
methods: {
|
||||
formatUserName, formatUserAvatar,
|
||||
formatName(name) { if (!name) return ""; return name.length === 2 ? name[0] + " " + name[1] : name; },
|
||||
@ -335,40 +458,162 @@ export default {
|
||||
async loadUsers() { try { const res = await get("/users/", { limit: 200, dept: "IRIS" }); this.users = (res || []).filter(u => u.department === "IRIS"); this.userOptions = this.users.map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); setUserNameMap(this.users); this.dictVersion += 1; } catch (e) { console.error('[loadUsers] 获取用户列表失败:', e); } },
|
||||
loadCurrentUser() { try { let user = uni.getStorageSync("user"); if (typeof user === "string" && user) { try { user = JSON.parse(user); } catch (e) { user = null; } } if (user && typeof user === "object") { this.currentUser = user; this.currentUserId = String(user.id || ""); this.currentUsername = user.username || ""; this.currentUserRole = user.role || ""; } } catch {} },
|
||||
|
||||
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
|
||||
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = this.availableTaskOptions[idx]; },
|
||||
onAssigneeChange(e) { const idx = e.detail.value; const u = this.users[idx]; if (u) { this.firstForm.assigneeIdx = idx; this.firstForm.assignee_id = u.username; this.firstForm.assigneeLabel = `${u.full_name} (${u.username})`; } },
|
||||
openCreateFirstTask() { this.isWarehouseTransfer = false; if (this.currentMode === 'tree') this.currentMode = 'workspace'; this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" }; this.createFirstVisible = true; },
|
||||
// 🚀 打开「派发新任务」弹窗。标题由 isWarehouseTransfer 决定,
|
||||
// 而该标志【必须在此处按产品实际位置重新推导】——
|
||||
// 原先 openWarehouseTransfer 先置 true、再调用本方法被立刻重置为 false,
|
||||
// 导致弹窗标题永远显示「发起首道工序」,仓库转出场景的文案从未生效。
|
||||
openCreateFirstTask() {
|
||||
this.isWarehouseTransfer = !!(this.product && this.product.current_location_id === 'virtual_warehouse');
|
||||
if (this.currentMode === 'tree') this.currentMode = 'workspace';
|
||||
this.firstForm = { assignee_id: "", assigneeLabel: "", note: "" };
|
||||
this.createFirstVisible = true;
|
||||
},
|
||||
handleOverallBarClick() { if (!this.product.task_tree || !this.product.task_tree.length) { this.openCreateFirstTask(); } else if (this.canEditOverallStatus) { this.showStatusPicker = true; } else { uni.showToast({ title: '仅超级管理员或当前主线负责人可修改状态', icon: 'none', duration: 2500 }); } },
|
||||
openWarehouseTransfer() { this.isWarehouseTransfer = true; this.openCreateFirstTask(); },
|
||||
// 注:原 openWarehouseTransfer() 已移除 —— 它的 isWarehouseTransfer=true 会被
|
||||
// openCreateFirstTask 立刻覆盖(死代码)。现在 banner 直接调用 openCreateFirstTask,
|
||||
// 由后者按产品实际位置推导标题。
|
||||
async doCreateFirstTask() { this.firstSaving = true; try { await post("/tasks/", { product_id: this.product.id, task_name: "待确认", assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.product.current_location_id === 'virtual_warehouse') { try { await patch(`/products/${this.product.id}`, { current_location_id: this.firstForm.assignee_id }); } catch {} } uni.showToast({ title: "任务已派发,待接收", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
|
||||
|
||||
openRecordPopup(task) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: null, remark: "", images: [], pendingCount: 0 }; this.isUploading = false; },
|
||||
openEditRecord({ task, record }) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: record.id, remark: record.remark || "", images: record.images || [], pendingCount: 0 }; this.isUploading = false; },
|
||||
// savedCount = 打开弹窗时已落库的图片张数。列表里 [0, savedCount) 是服务端已有的,
|
||||
// 之后的都是本次会话新选的(尚未提交),删除判定据此区分,见 canDeleteRecordImage。
|
||||
openRecordPopup(task) { this.recordPopup = { visible: true, task }; this.recordForm = { recordId: null, remark: "", images: [], pendingCount: 0, savedCount: 0 }; this.isUploading = false; },
|
||||
openEditRecord({ task, record }) { const saved = record.images || []; this.recordPopup = { visible: true, task }; this.recordForm = { recordId: record.id, remark: record.remark || "", images: saved.slice(), pendingCount: 0, savedCount: saved.length }; this.isUploading = false; },
|
||||
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.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; } },
|
||||
// 🖼️ 后端返回的是相对路径(如 /api/v1/upload/files/xxx.jpg),直接丢给 <image> 会破图,
|
||||
// 这里补全域名 → 完整可访问 URL。逻辑与 records.vue / TaskTreeNode.vue 保持一致。
|
||||
imageUrl(url) {
|
||||
if (!url) return "";
|
||||
if (url.startsWith("http")) return url;
|
||||
const domain = getBaseUrl().replace(/\/api.*$/, '');
|
||||
return domain + (url.startsWith("/") ? url : "/" + url);
|
||||
},
|
||||
// 📷 通用选图上传:压缩后逐个上传,URL 累积进 target.images,
|
||||
// 过程中用 target.pendingCount 显示 ⏳ 占位(追加记录 / 驳回共用)。
|
||||
// ⚠️ 失败必须显式提示 + 确保图片不进 images 数组,绝不静默吞掉:
|
||||
// 工人会以为图传好了,提交上去才发现缺图,而任务已流转出去。
|
||||
async pickAndUploadImages(target) {
|
||||
const maxSlots = 9 - (target.images.length + target.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) {
|
||||
// 压缩全军覆没(内存不足/格式异常)也必须说一声,不能悄悄什么都没发生
|
||||
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
target.pendingCount += compressedPaths.length;
|
||||
// 🚀 有界并发上传(utils/upload.js):9 张图并行补位,不再串行干等。
|
||||
// onEachDone 对每张图恰好回调一次 —— 成功的推入 images 供预览,
|
||||
// 失败的保持不推入(不给"图片已传好"的错觉),两者都回收一个 ⏳ 占位。
|
||||
const { failed, total } = await uploadImages(compressedPaths, (url) => {
|
||||
if (url) target.images.push(url);
|
||||
target.pendingCount--;
|
||||
});
|
||||
this.isUploading = false;
|
||||
if (failed > 0) {
|
||||
uni.showToast({
|
||||
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
handleChooseImage() { return this.pickAndUploadImages(this.recordForm); },
|
||||
handleChooseRejectImage() { return this.pickAndUploadImages(this.rejectForm); },
|
||||
/**
|
||||
* 删除一张记录图。
|
||||
*
|
||||
* 已落库的图(编辑历史记录)删掉是不可逆的,保留 5 秒倒计时防误触;
|
||||
* 本次刚选、尚未提交的图只存在内存里,删错了重新拍一张即可,没必要卡 5 秒。
|
||||
*/
|
||||
removeRecordImage(i) {
|
||||
const isUnsaved = i >= (this.recordForm.savedCount || 0);
|
||||
this.openConfirmDlg({
|
||||
title: "删除图片",
|
||||
content: "确定删除这张图片吗?",
|
||||
countdown: !isUnsaved,
|
||||
action: () => { this.recordForm.images.splice(i, 1); },
|
||||
});
|
||||
},
|
||||
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images.map((u) => this.imageUrl(u)), current: i }); },
|
||||
removeRejectImage(i) { this.rejectForm.images.splice(i, 1); },
|
||||
previewRejectImage(i) { uni.previewImage({ urls: this.rejectForm.images.map((u) => this.imageUrl(u)), current: i }); },
|
||||
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
|
||||
isUploaded(url) { return isUploadedUrl(url); },
|
||||
/**
|
||||
* 某张记录图能否删除。
|
||||
*
|
||||
* 本次会话新选、尚未提交的图(下标 >= savedCount)**永远可删** —— 它只存在于
|
||||
* 内存里,删掉不产生任何服务端影响。canDeleteImage 那道「任务已定稿 / 不是我的活」
|
||||
* 的闸门本意是保护已落库的历史记录,不该连带把工人刚选错、想撤掉的候选图一起锁死。
|
||||
* (此前这里直接写 v-if="canDeleteImage",导致在已完成任务上打开「记录/拍照」,
|
||||
* 选完图后根本没有删除入口。)
|
||||
*/
|
||||
canDeleteRecordImage(i) { return this.canDeleteImage || i >= (this.recordForm.savedCount || 0); },
|
||||
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 (e) { this.handleNetworkFailure(e, this.recordForm.recordId ? "更新记录" : "保存记录"); } finally { this.recordSaving = false; } },
|
||||
|
||||
async handleTaskAction({ task, type, record }) {
|
||||
if (type === "record") { this.openRecordPopup(task); return; }
|
||||
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
|
||||
if (type === "end") { this.confirmEndBranch(task); return; }
|
||||
if (type === "recall") { this.confirmRecall(task); return; }
|
||||
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); if (!this.processOptions.length) this.processOptions = ["🏭 入库 (virtual_warehouse)", ...TASK_NAME_OPTIONS]; } finally { uni.hideLoading(); } }
|
||||
if (type === "transfer" || type === "spawn") { uni.showLoading({ title: "加载数据..." }); try { if (!this.userOptions.length) await this.loadUsers(); this.processOptions = ["🏭 入库 (virtual_warehouse)", ...this.availableTaskOptions]; } finally { uni.hideLoading(); } }
|
||||
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
|
||||
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
|
||||
this.rejectForm = { images: [], pendingCount: 0 }; this.isUploading = false;
|
||||
this.transferForm = { selectedUserId: "", isWarehouse: false, isFinishDirect: false, note: "" };
|
||||
this.spawnForm = { assignee_id: "", remark: "" };
|
||||
},
|
||||
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
|
||||
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 {} },
|
||||
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 (e) { this.handleNetworkFailure(e, "撤回转交"); } },
|
||||
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 (e) { this.handleNetworkFailure(e, "结束分支"); } },
|
||||
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
|
||||
|
||||
// ═══ 网络级失败的识别与统一处置(防"超时后连点"造成重复流转) ═══
|
||||
// 🌐 判定「网络级失败」:uni.request 的 fail 回调 = 压根没拿到后端响应
|
||||
// (超时 / 断网 / DNS 失败),与 4xx/5xx 有本质区别 —— 后者后端明确应答过,
|
||||
// 而前者后端很可能已经把动作执行成功了,只是响应没回来。
|
||||
isNetworkFailure(e) {
|
||||
if (!e) return false;
|
||||
if (e.isNetworkError === true) return true;
|
||||
if (e.statusCode) return false; // 有 HTTP 状态码 = 后端应答过,不是网络级
|
||||
const msg = String(e.errMsg || e.message || "");
|
||||
return /timeout|network|request:fail/i.test(msg);
|
||||
},
|
||||
// 🛡️ 网络级失败的统一处置:
|
||||
// 1) 关闭弹窗 —— 强行打断工人的连点,否则第二次点下去就是重复驳回/重复转交;
|
||||
// 2) 强提示(模态,必须手动确认)—— 告诉他"可能已生效",别再点;
|
||||
// 3) 静默拉取真实状态 —— 防止继续基于过期数据产生脏操作。
|
||||
// 返回 true 表示已按网络级失败处理,调用方无需再兜底。
|
||||
handleNetworkFailure(e, actionLabel) {
|
||||
if (!this.isNetworkFailure(e)) return false;
|
||||
this.closeActionPopup();
|
||||
this.closeRecordPopup();
|
||||
uni.showModal({
|
||||
title: "网络超时",
|
||||
content: `未收到服务器响应,「${actionLabel}」可能已生效。已为你刷新最新状态,请确认后再操作。`,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
this.refreshProductSilently();
|
||||
return true;
|
||||
},
|
||||
// 静默刷新产品详情:不置 loading,避免打断视线(网络故障后自动纠偏用)
|
||||
async refreshProductSilently() {
|
||||
const sn = this.product && this.product.serial_number;
|
||||
if (!sn) return;
|
||||
try { this.product = await get(`/products/scan/${sn}`); } catch (e) { console.error("[refresh] 静默刷新失败:", e); }
|
||||
},
|
||||
|
||||
// ═══ 双重确认倒计时(防误触) ═══
|
||||
// 首次点击进入 5 秒倒计时:期间确认按钮虚化禁用(点不了),只有「取消」可用;
|
||||
// 5 秒结束后确认按钮解锁,点击才真正执行。
|
||||
@ -428,14 +673,34 @@ export default {
|
||||
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; } },
|
||||
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 (e) { this.handleNetworkFailure(e, "接收任务"); } finally { this.actionLoading = false; } },
|
||||
// 驳回:reason 必填,images 选填(编号错误等场景允许空数组)
|
||||
async doReject() { if (this.isUploading) return; 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(), images: this.rejectForm.images }); uni.showToast({ title: "已驳回", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch (e) { this.handleNetworkFailure(e, "驳回任务"); } finally { this.actionLoading = false; } },
|
||||
// 转交 — 互斥选择
|
||||
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; },
|
||||
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) this.transferForm.selectedUserId = ""; },
|
||||
async doTransfer() { this.actionLoading = true; try { const assignees = this.transferForm.isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId]; const taskName = this.transferForm.isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认"; await post(`/tasks/${this.actionPopup.task.id}/transfer`, { next_tasks: [{ task_name: taskName, assignees }], note: this.transferForm.note.trim() || undefined }); uni.showToast({ title: this.transferForm.isWarehouse ? "已入库" : "转交成功", icon: "success" }); this.closeActionPopup(); this.doQuery(this.product.serial_number); } catch {} finally { this.actionLoading = false; } },
|
||||
selectTransferUser(userId) { this.transferForm.selectedUserId = userId; this.transferForm.isWarehouse = false; this.transferForm.isFinishDirect = false; },
|
||||
toggleWarehouse() { this.transferForm.isWarehouse = !this.transferForm.isWarehouse; if (this.transferForm.isWarehouse) { this.transferForm.selectedUserId = ""; this.transferForm.isFinishDirect = false; } },
|
||||
// 🏁 直接完结:与前两者互斥。不发往仓库 → 后端落入"无下家"分支,overall_status 原样保留
|
||||
toggleFinishDirect() { this.transferForm.isFinishDirect = !this.transferForm.isFinishDirect; if (this.transferForm.isFinishDirect) { this.transferForm.selectedUserId = ""; this.transferForm.isWarehouse = false; } },
|
||||
async doTransfer() {
|
||||
this.actionLoading = true;
|
||||
const { isWarehouse, isFinishDirect } = this.transferForm;
|
||||
try {
|
||||
const note = this.transferForm.note.trim() || undefined;
|
||||
// 🏁 直接完结:next_tasks 留空 + finish_directly=true,
|
||||
// 后端据此跳过分支解析,只闭环任务、不改产品宏观状态(不会变成"待仓库收货")
|
||||
const payload = isFinishDirect
|
||||
? { next_tasks: [], finish_directly: true, note }
|
||||
: { next_tasks: [{ task_name: isWarehouse ? "🏭 入库 (virtual_warehouse)" : "待确认", assignees: isWarehouse ? ["virtual_warehouse"] : [this.transferForm.selectedUserId] }], note };
|
||||
await post(`/tasks/${this.actionPopup.task.id}/transfer`, payload);
|
||||
uni.showToast({ title: isFinishDirect ? "已直接完结" : (isWarehouse ? "已入库" : "转交成功"), icon: "success" });
|
||||
this.closeActionPopup();
|
||||
this.doQuery(this.product.serial_number);
|
||||
} catch (e) {
|
||||
this.handleNetworkFailure(e, isFinishDirect ? "直接完结" : (isWarehouse ? "入库" : "转交"));
|
||||
} finally { this.actionLoading = false; }
|
||||
},
|
||||
// 派发协助分支
|
||||
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 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 (e) { this.handleNetworkFailure(e, "派发协助分支"); } finally { this.actionLoading = false; } },
|
||||
// 💬 留言板
|
||||
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); } },
|
||||
@ -479,7 +744,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { min-height: 100vh; display: block; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 24px; overflow-y: auto; }
|
||||
/* 根容器只负责背景与内边距,不设固定高度、不做内部滚动 —— 内容自然撑开,
|
||||
整页滚动完全交给原生 Page 层,避免与页面下拉刷新手势打架。 */
|
||||
.page-container { min-height: 100vh; display: block; background-color: #f3f4f6; box-sizing: border-box; padding: 16px; padding-bottom: 24px; }
|
||||
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
|
||||
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
|
||||
.overall-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
@ -490,6 +757,10 @@ export default {
|
||||
.overall-outbound { color: #4f46e5; } /* 已出库:靛蓝 */
|
||||
.overall-warehouse { color: #ea580c; } /* 待仓库收货:橙 */
|
||||
.overall-arrow { font-size: 12px; color: #9ca3af; }
|
||||
/* 🔧 售后回流标识:紫底白字(生产阶段不打标)
|
||||
不用红色——红色在本系统是「驳回/危险」语义,售后只是另一条流转支线。 */
|
||||
.life-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
|
||||
.life-badge-after { background: #9333ea; color: #ffffff; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); flex-shrink: 0; min-height: 120px; }
|
||||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; gap: 6px; }
|
||||
.card-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
@ -562,10 +833,13 @@ export default {
|
||||
.btn-disabled { background: #9ca3af; opacity: 0.5; }
|
||||
.mb-bottom-anchor { height: 1px; }
|
||||
.required { color: #ef4444; }
|
||||
.optional { color: #9ca3af; font-weight: 400; font-size: 12px; }
|
||||
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
|
||||
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
|
||||
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
|
||||
.img-thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
|
||||
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
|
||||
.img-frame { width: 160rpx; height: 160rpx; }
|
||||
.img-thumb { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; }
|
||||
.img-cell-loading { display: flex; align-items: center; justify-content: center; background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
|
||||
.img-loading-text { font-size: 36rpx; }
|
||||
.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; }
|
||||
|
||||
@ -75,13 +75,44 @@ export default {
|
||||
},
|
||||
handleSearch() { this.doQuery(this.serialNumber.trim()); },
|
||||
|
||||
// 📷 从扫码原始文本中提取设备身份证(SN)
|
||||
// 二维码内容有两种可能,必须都兼容:
|
||||
// ① 裸 SN → ABC1234567890DEF
|
||||
// ② 带域名的完整 URL → https://track.iris-rs.cn/sn/ABC1234567890DEF?from=label
|
||||
// 旧逻辑只是「去掉非字母数字后截前 16 位」,扫到 ② 时会把 "https"、域名
|
||||
// 一起当成 SN 的前半段,截出来是个完全不存在的号,永远提示"未找到该产品"。
|
||||
extractSerial(raw) {
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) return "";
|
||||
|
||||
// 关键一步:先把 "协议://主机名" 整段剥掉,否则域名会被当成候选 SN
|
||||
// (如 trackbackirisrscn 这种 17 位串,恰好能通过长度校验)。
|
||||
let body = text;
|
||||
const origin = text.match(/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+/i);
|
||||
if (origin) body = text.slice(origin[0].length);
|
||||
|
||||
// 在剩余内容里找长度 >= 8 的连续字母数字串(身份证至少 8 位)。
|
||||
// 取最长的一段;等长时取靠后的 —— URL 里 SN 通常在路径末尾。
|
||||
const runs = body.match(/[a-zA-Z0-9]{8,}/g) || [];
|
||||
if (!runs.length) return "";
|
||||
let best = runs[0];
|
||||
for (const run of runs) {
|
||||
if (run.length >= best.length) best = run;
|
||||
}
|
||||
return best.slice(0, 16);
|
||||
},
|
||||
|
||||
// 📷 全屏相机扫码
|
||||
handleScanCamera() {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ["qrCode", "barCode"],
|
||||
success: (res) => {
|
||||
const sn = (res.result || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
|
||||
const sn = this.extractSerial(res.result);
|
||||
if (!sn) {
|
||||
uni.showToast({ title: "无法识别二维码内容,请确认扫的是设备标签", icon: "none", duration: 2500 });
|
||||
return;
|
||||
}
|
||||
this.doQuery(sn);
|
||||
},
|
||||
fail: (err) => {
|
||||
|
||||
@ -69,7 +69,10 @@
|
||||
<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)" />
|
||||
<view class="success-badge-wrapper edit-img-frame">
|
||||
<image :src="imageUrl(img)" mode="aspectFill" class="edit-img" @tap="previewEditImage(i)" />
|
||||
<view v-if="isUploaded(img)" class="success-badge" />
|
||||
</view>
|
||||
<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>
|
||||
@ -86,6 +89,7 @@
|
||||
|
||||
<script>
|
||||
import request, { get, put, getBaseUrl } from "../../utils/request";
|
||||
import { uploadImages, isUploadedUrl } from "../../utils/upload";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@ -170,6 +174,11 @@ export default {
|
||||
uni.previewImage({ urls: fullUrls, current: index });
|
||||
},
|
||||
|
||||
/** 绿勾角标只认「已上传成功」的项(判定逻辑统一收口在 utils/upload.js) */
|
||||
isUploaded(url) {
|
||||
return isUploadedUrl(url);
|
||||
},
|
||||
|
||||
formatTime(t) {
|
||||
if (!t) return "";
|
||||
const d = new Date(t);
|
||||
@ -212,24 +221,28 @@ export default {
|
||||
compressedPaths.push(compressed.tempFilePath);
|
||||
} catch {}
|
||||
}
|
||||
if (!compressedPaths.length) return;
|
||||
if (!compressedPaths.length) {
|
||||
// 压缩全军覆没(内存不足/格式异常)也必须说一声,不能悄悄什么都没发生
|
||||
uni.showToast({ title: "图片处理失败,请重试", icon: "none", duration: 3000 });
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
this.editForm.pendingCount += compressedPaths.length;
|
||||
for (const path of compressedPaths) {
|
||||
const url = await this.uploadFile(path);
|
||||
// 🚀 有界并发上传(utils/upload.js):9 张图并行补位,不再串行干等。
|
||||
// onEachDone 对每张图恰好回调一次 —— 成功的推入 images 供预览,
|
||||
// 失败的保持不推入(不给"图片已传好"的错觉),两者都回收一个 ⏳ 占位。
|
||||
const { failed, total } = await uploadImages(compressedPaths, (url) => {
|
||||
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),
|
||||
});
|
||||
});
|
||||
this.isUploading = false;
|
||||
if (failed > 0) {
|
||||
uni.showToast({
|
||||
title: failed === total ? "图片上传失败,请重试" : "部分图片上传失败,请重试",
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
},
|
||||
async doSaveEdit() {
|
||||
if (this.isUploading) return;
|
||||
@ -348,7 +361,9 @@ export default {
|
||||
.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; }
|
||||
/* 圆角与裁剪交给 .success-badge-wrapper,图片只负责填满 */
|
||||
.edit-img-frame { width: 160rpx; height: 160rpx; }
|
||||
.edit-img { width: 100%; height: 100%; display: block; border: 1px solid #e5e7eb; box-sizing: border-box; }
|
||||
.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; }
|
||||
|
||||
@ -32,6 +32,11 @@
|
||||
</view>
|
||||
<view class="card-time">创建: {{ formatTime(task.created_at) }}</view>
|
||||
</view>
|
||||
|
||||
<!-- ⬇️ 触底加载状态:让工人知道"到底了"还是"还在拉" -->
|
||||
<view v-if="loadingMore" class="load-more">加载中...</view>
|
||||
<view v-else-if="!hasMore && tasks.length > 0" class="load-more">— 没有更多了 —</view>
|
||||
<view v-else-if="hasMore && tasks.length > 0" class="load-more">上拉加载更多</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
@ -54,6 +59,11 @@ export default {
|
||||
tasks: [],
|
||||
loading: true,
|
||||
currentUser: null,
|
||||
// 📄 分页状态:此前硬编码 limit:100 一次性拉全量,任务多了会拖慢首屏
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
loadingMore: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -64,7 +74,21 @@ export default {
|
||||
},
|
||||
onShow() {
|
||||
this.loadCurrentUser();
|
||||
this.fetchTasks();
|
||||
this.fetchTasks({ reset: true });
|
||||
},
|
||||
// 🚀 下拉刷新:回到第 1 页并清空现有列表重载(分页后必须重置,否则新旧页会错位)
|
||||
async onPullDownRefresh() {
|
||||
try {
|
||||
this.loadCurrentUser();
|
||||
await this.fetchTasks({ reset: true });
|
||||
} finally {
|
||||
uni.stopPullDownRefresh(); // 无论成败都要收起动画,否则会一直挂着
|
||||
}
|
||||
},
|
||||
// 🚀 触底加载下一页
|
||||
onReachBottom() {
|
||||
if (this.loading || this.loadingMore || !this.hasMore) return;
|
||||
this.loadMore();
|
||||
},
|
||||
methods: {
|
||||
statusLabel(s) {
|
||||
@ -95,20 +119,51 @@ export default {
|
||||
if (u) this.currentUser = typeof u === "string" ? JSON.parse(u) : u;
|
||||
} catch {}
|
||||
},
|
||||
async fetchTasks() {
|
||||
this.loading = true;
|
||||
// 排序:主干任务在前,协助分支在后;同组内按创建时间升序
|
||||
compareTask(a, b) {
|
||||
const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
|
||||
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
},
|
||||
|
||||
/** 拉取一页任务;reset=true 表示回到第 1 页并清空重载(下拉刷新 / 首次进入) */
|
||||
async fetchTasks({ reset = false } = {}) {
|
||||
if (reset) {
|
||||
this.page = 1;
|
||||
this.hasMore = true;
|
||||
this.tasks = [];
|
||||
}
|
||||
if (reset) this.loading = true;
|
||||
const target = this.page;
|
||||
try {
|
||||
const username = this.currentUser?.username || "";
|
||||
const res = await get("/tasks/", { assignee_id: username, limit: 100 });
|
||||
this.tasks = (res.tasks || []).sort((a, b) => {
|
||||
const aIsMain = !a.parent_task_id || a.task_type !== 'SPAWN';
|
||||
const bIsMain = !b.parent_task_id || b.task_type !== 'SPAWN';
|
||||
if (aIsMain && !bIsMain) return -1;
|
||||
if (!aIsMain && bIsMain) return 1;
|
||||
return new Date(a.created_at) - new Date(b.created_at);
|
||||
const res = await get("/tasks/", {
|
||||
assignee_id: username,
|
||||
skip: (target - 1) * this.pageSize,
|
||||
limit: this.pageSize,
|
||||
});
|
||||
} catch { this.tasks = []; }
|
||||
finally { this.loading = false; }
|
||||
const batch = res.tasks || [];
|
||||
// 按 id 去重后追加:翻页期间若有新任务插入,分页边界会错位导致重复项
|
||||
const seen = new Set(this.tasks.map((t) => t.id));
|
||||
this.tasks = this.tasks.concat(batch.filter((t) => !seen.has(t.id))).sort(this.compareTask);
|
||||
// 后端给了 total 就以它为准;否则退回「本页是否满员」判断
|
||||
this.hasMore = res.total != null
|
||||
? this.tasks.length < res.total
|
||||
: batch.length >= this.pageSize;
|
||||
if (this.hasMore) this.page = target + 1;
|
||||
} catch (e) {
|
||||
console.error("[tasks] 拉取任务失败:", e);
|
||||
if (reset) this.tasks = [];
|
||||
this.hasMore = false;
|
||||
} finally {
|
||||
if (reset) this.loading = false;
|
||||
}
|
||||
},
|
||||
async loadMore() {
|
||||
this.loadingMore = true;
|
||||
try { await this.fetchTasks(); } finally { this.loadingMore = false; }
|
||||
},
|
||||
goDetail(task) {
|
||||
const sn = task.product_sn || task.product_id;
|
||||
@ -155,4 +210,7 @@ export default {
|
||||
.s-gray { background: #f3f4f6; color: #6b7280; }
|
||||
.card-meta { font-size: 12px; color: #6b7280; display: flex; gap: 12px; }
|
||||
.card-time { font-size: 11px; color: #9ca3af; margin-top: 4px; }
|
||||
|
||||
/* 触底加载状态 */
|
||||
.load-more { text-align: center; font-size: 12px; color: #9ca3af; padding: 16px 0 4px; }
|
||||
</style>
|
||||
|
||||
24
track-uniapp/src/utils/config.js
Normal file
24
track-uniapp/src/utils/config.js
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 全局环境配置 — 后端域名/接口地址的唯一来源。
|
||||
*
|
||||
* 此前 request.js 与 App.vue 各自硬编码了一份生产域名,改域名要改两处,
|
||||
* 漏一处就会出现「接口通了但 OTA 检查更新接不上」这类极难排查的偏差,
|
||||
* 故统一收口到本文件。新增环境地址一律加在这里,不要在业务代码里写死。
|
||||
*/
|
||||
|
||||
// 生产环境 API 地址
|
||||
//
|
||||
// ⚠️ HTTPS 说明(2026-09 实测):生产域名 track_back.iris-rs.cn 的 443 端口
|
||||
// 是通的、也确实配有证书,但证书【已过期】—— 现在切 https 会让 App 的
|
||||
// 全部请求直接失败。待运维完成证书续期后,只需把下面 PROD_URL 与
|
||||
// LOCAL_URL 的协议头改成 https:// 即可,业务代码无需任何改动。
|
||||
// 续期前请勿改动,保持 http:// 以保证可用性。
|
||||
export const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
|
||||
|
||||
// 本地开发地址 — 真机调试时指向开发机局域网 IP
|
||||
export const LOCAL_URL = "http://192.168.9.80:8011/api/v1";
|
||||
|
||||
// 生产基础地址:供只认「生产环境」的场景直接引用
|
||||
export const BASE_URL = PROD_URL;
|
||||
|
||||
export default { PROD_URL, LOCAL_URL, BASE_URL };
|
||||
91
track-uniapp/src/utils/lifecycle.js
Normal file
91
track-uniapp/src/utils/lifecycle.js
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 生命周期阶段(lifecycle_phase)与工序选项隔离 — 移动端口径
|
||||
*
|
||||
* ⚠️ 与后端 backend/app/core/lifecycle.py、PC 端 frontend/src/constants/task.ts
|
||||
* 是同一份词表,改动请三处同步。
|
||||
*
|
||||
* 售后回流阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的
|
||||
* 「测试 / 维修」,选中售后专属工序即代表设备进入售后生命周期。
|
||||
*
|
||||
* 本模块只负责"该给操作员看哪些选项"(体验层);
|
||||
* 真正的拦截在后端 task_service._enforce_step_isolation(防伪造传参)。
|
||||
*/
|
||||
|
||||
export const LIFECYCLE_PHASE = {
|
||||
PRODUCTION: "PRODUCTION",
|
||||
AFTER_SALES: "AFTER_SALES",
|
||||
};
|
||||
|
||||
/** 售后专属工序名 — 选中即代表设备进入售后生命周期 */
|
||||
export const STEP_SHIP_TEST = "发货测试";
|
||||
export const STEP_AFTER_SALES_REPAIR = "售后维修";
|
||||
|
||||
/**
|
||||
* 绝对物理终态之一:设备已发货出库。
|
||||
*
|
||||
* 🔧 出库设备也要按售后环节给选项:设备出库后仍可能被叫回来补做
|
||||
* 「发货测试 / 售后维修」,但后端已不再因「正常已出库做质检」而把
|
||||
* lifecycle_phase 翻成 AFTER_SALES(避免不可回退的售后烙印)。
|
||||
* 若选项渲染仍只认 phase,工人就永远选不到那两个工序 —— 鸡生蛋死锁。
|
||||
*/
|
||||
export const OUTBOUND_OVERALL = "已出库";
|
||||
|
||||
const AFTER_SALES_ONLY_STEPS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR];
|
||||
|
||||
// ── 生产制造阶段合法工序(原有词表,一字未改)──
|
||||
export const PRODUCTION_TASK_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
export const PRODUCTION_OVERALL_OPTIONS = [
|
||||
"备货", "生产", "测试", "维修", "在库", "已入库", "已出库",
|
||||
];
|
||||
|
||||
// ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」──
|
||||
export const AFTER_SALES_TASK_OPTIONS = [STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库"];
|
||||
export const AFTER_SALES_OVERALL_OPTIONS = [
|
||||
STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "已入库", "已出库",
|
||||
];
|
||||
|
||||
/**
|
||||
* 接收任务时可选工序。
|
||||
*
|
||||
* hasHistory = false(无任何历史任务的首次激活 / 老设备)时返回**并集**:
|
||||
* 这类设备既可能是新投产,也可能是直接返厂售后的老设备,让操作员自己声明。
|
||||
* 一旦选定售后专属工序,后端即把设备判为 AFTER_SALES,之后下拉自动收窄。
|
||||
*
|
||||
* @param phase lifecycle_phase
|
||||
* @param hasHistory 是否有历史任务
|
||||
* @param overallStatus 产品当前宏观状态 — 【已出库】同样走售后词表,见下方说明
|
||||
*/
|
||||
export function taskOptionsFor(phase, hasHistory = true, overallStatus = null) {
|
||||
// 🔧 出库设备与售后生命周期同等待遇(2026-09-17 修复「鸡生蛋」死锁):
|
||||
// 后端为消除不可回退的售后烙印,已不再把「正常已出库做质检」的设备
|
||||
// 翻成 AFTER_SALES。若这里仍只认 phase,工人就永远选不到
|
||||
// 「发货测试 / 售后维修」—— 而选不到就无法进入售后,phase 也就永远不变。
|
||||
if (
|
||||
phase === LIFECYCLE_PHASE.AFTER_SALES ||
|
||||
String(overallStatus || "").trim() === OUTBOUND_OVERALL
|
||||
) {
|
||||
return AFTER_SALES_TASK_OPTIONS;
|
||||
}
|
||||
if (!hasHistory) return [...PRODUCTION_TASK_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
|
||||
return PRODUCTION_TASK_OPTIONS;
|
||||
}
|
||||
|
||||
/** 宏观状态可选值 — 同上,含已入库/已出库等终态 */
|
||||
export function overallOptionsFor(phase, hasHistory = true) {
|
||||
if (phase === LIFECYCLE_PHASE.AFTER_SALES) return AFTER_SALES_OVERALL_OPTIONS;
|
||||
if (!hasHistory) return [...PRODUCTION_OVERALL_OPTIONS, ...AFTER_SALES_ONLY_STEPS];
|
||||
return PRODUCTION_OVERALL_OPTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生命周期标签 — 只在设备确实处于售后回流环节时返回:
|
||||
* 生命周期为 AFTER_SALES 且当前工序是「发货测试」或「售后维修」
|
||||
* → 红底白字,文案即工序名。
|
||||
* 生产制造阶段一律返回 null(不打标),避免与售后设备混淆。
|
||||
*/
|
||||
export function lifecycleBadge(overallStatus, phase) {
|
||||
if (phase !== LIFECYCLE_PHASE.AFTER_SALES) return null;
|
||||
const step = String(overallStatus || "").trim();
|
||||
if (step !== STEP_SHIP_TEST && step !== STEP_AFTER_SALES_REPAIR) return null;
|
||||
return { label: step, phase: LIFECYCLE_PHASE.AFTER_SALES, cls: "life-badge-after" };
|
||||
}
|
||||
400
track-uniapp/src/utils/ota.js
Normal file
400
track-uniapp/src/utils/ota.js
Normal file
@ -0,0 +1,400 @@
|
||||
/**
|
||||
* OTA 热更新 — 版本检测 + WGT 下载 + 安装。App 端专属能力的唯一来源。
|
||||
*
|
||||
* 原先这套逻辑整个长在 App.vue 的 methods 里(checkUpdate / parseVersionCode /
|
||||
* downloadAndInstall / downloadWgt / installWgt / otaFailed),个人中心的
|
||||
*「设置 → 检查更新」想手动触发一次就得把 App.vue 的方法捞出来,做不到。
|
||||
* 抽到这里后两处共用同一份实现,改版本比对规则或下载策略只需改这里。
|
||||
*
|
||||
* 调用方:
|
||||
* - App.vue onLaunch / onShow 静默自动检查(受 5 分钟节流)
|
||||
* - settings.vue 「检查更新」手动检查(manual = true,跳过节流并给出明确反馈)
|
||||
*/
|
||||
|
||||
import { getBaseUrl } from "./request";
|
||||
|
||||
/** 自动检查的节流窗口:5 分钟内不重复检查,避免 onShow 频繁触发弹窗骚扰 */
|
||||
const THROTTLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** 上次检查时间戳(模块级单例,等价于原 App.vue 的 this._lastUpdateCheck) */
|
||||
let lastCheckAt = 0;
|
||||
|
||||
/** 版本号→数字: "T1.0.10" → 1*100+10 = 110 */
|
||||
export function parseVersionCode(v) {
|
||||
if (!v) return 0;
|
||||
const nums = v.match(/\d+/g);
|
||||
if (!nums || nums.length < 2) return 0;
|
||||
return parseInt(nums[0], 10) * 100 + parseInt(nums[nums.length - 1], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有新版本。
|
||||
*
|
||||
* @param {{manual?: boolean}} [opts]
|
||||
* manual = true 时(设置页手动触发):跳过节流、显示「检查中」、
|
||||
* 并在「已是最新 / 检查失败」时给出明确提示 —— 用户主动点的按钮
|
||||
* 必须有个回音,不能像自动检查那样静默收场。
|
||||
* @returns {Promise<"latest"|"available"|"skipped"|"unsupported"|"error">}
|
||||
*/
|
||||
export async function checkAppUpdate({ manual = false } = {}) {
|
||||
// #ifndef APP-PLUS
|
||||
// 非 App 环境(H5 / 小程序)没有 plus.runtime,热更新无从谈起
|
||||
if (manual) {
|
||||
uni.showToast({ title: "当前环境不支持热更新", icon: "none", duration: 2500 });
|
||||
}
|
||||
return "unsupported";
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
const now = Date.now();
|
||||
if (!manual && lastCheckAt && now - lastCheckAt < THROTTLE_MS) {
|
||||
console.log("[OTA] 距上次检查不足5分钟,跳过");
|
||||
return "skipped";
|
||||
}
|
||||
lastCheckAt = now;
|
||||
|
||||
// 🔧 appWgtVersion 跟随 WGT 更新,解析算法: major*100 + lastNum
|
||||
// "T1.0.1"→101, "T1.0.10"→110, "T1.0.99"→199
|
||||
//
|
||||
// ⚠️ 这两个字段必须分开看,别只看合并后的值:
|
||||
// appWgtVersion = 已安装的 wgt 资源版本(热更新成功后会变)
|
||||
// appVersion = APK 基座版本(重装多少次热更新都不会变)
|
||||
// 若 appWgtVersion 为空,说明热更新从未装成功过,此时取到的是 APK 的
|
||||
// 静态版本号,表现就是"更新提示反复弹、版本号永远不动"。
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
const wgtVersion = sysInfo.appWgtVersion || "";
|
||||
const apkVersion = sysInfo.appVersion || "";
|
||||
const wgtVer = wgtVersion || apkVersion || "0";
|
||||
const currentVersionCode = parseVersionCode(wgtVer);
|
||||
|
||||
// 🔧 复用 getBaseUrl():与业务请求永远指向同一个后端(含 App 内动态切换的环境)
|
||||
const baseUrl = getBaseUrl();
|
||||
|
||||
console.log(
|
||||
"[OTA] wgt版本:", wgtVersion || "(空,说明热更新从未装成功)",
|
||||
"| APK版本:", apkVersion,
|
||||
"→ 取用:", wgtVer, "=", currentVersionCode
|
||||
);
|
||||
|
||||
if (manual) uni.showLoading({ title: "检查更新中...", mask: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: `${baseUrl}/app/check-update`,
|
||||
method: "GET",
|
||||
timeout: 8000,
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: `检查更新失败 (${res.statusCode})`, icon: "none" });
|
||||
}
|
||||
resolve("error");
|
||||
return;
|
||||
}
|
||||
const data = res.data;
|
||||
if (!data || !data.wgt_url) {
|
||||
console.log("[OTA] 服务端无可用更新包");
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: "当前已是最新版本", icon: "none" });
|
||||
}
|
||||
resolve("latest");
|
||||
return;
|
||||
}
|
||||
|
||||
const serverVersionCode = data.version_code || 0;
|
||||
console.log("[OTA] 服务端版本:", data.version, "| 数字版本:", serverVersionCode);
|
||||
|
||||
// 客户端自行对比:服务端 > 本地 = 需要更新
|
||||
if (serverVersionCode <= currentVersionCode) {
|
||||
console.log("[OTA] 已是最新版本,无需更新");
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({ title: "当前已是最新版本", icon: "none" });
|
||||
}
|
||||
resolve("latest");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OTA] 发现新版本:", data.version);
|
||||
if (manual) uni.hideLoading();
|
||||
downloadAndInstall(data.wgt_url, data.version, data.description);
|
||||
resolve("available");
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log("[OTA] 版本检测网络失败,跳过");
|
||||
if (manual) {
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: err && err.errMsg && /timeout/i.test(err.errMsg) ? "检查更新超时" : "网络连接失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
resolve("error");
|
||||
},
|
||||
});
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
|
||||
function downloadAndInstall(wgtUrl, newVersion, description) {
|
||||
if (!wgtUrl) {
|
||||
console.log("[OTA] 无 WGT 下载地址");
|
||||
return;
|
||||
}
|
||||
if (typeof plus === "undefined") {
|
||||
console.log("[OTA] 非 App 环境,跳过安装");
|
||||
return;
|
||||
}
|
||||
|
||||
const content = description
|
||||
? `发现新版本 ${newVersion}\n\n${description}\n\n是否立即更新?`
|
||||
: `发现新版本 ${newVersion},是否立即更新?`;
|
||||
|
||||
uni.showModal({
|
||||
title: "版本更新",
|
||||
content,
|
||||
confirmText: "立即更新",
|
||||
cancelText: "稍后再说",
|
||||
success: (modalRes) => {
|
||||
if (!modalRes.confirm) return;
|
||||
downloadWgt(wgtUrl, newVersion);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 WGT 更新包。
|
||||
*
|
||||
* 为什么不用 uni.downloadFile(旧实现的坑):
|
||||
* plus.runtime.install 是**靠文件扩展名**区分 wgt / apk 的,而
|
||||
* uni.downloadFile 返回的 tempFilePath 不保证带 `.wgt` 后缀,路径不对时
|
||||
* 安装会被直接拒绝 —— 且旧代码失败只打 console.error,工人只看到
|
||||
* 「点了立即更新没反应」。这里改用 plus.downloader 并显式指定带后缀的
|
||||
* 落盘路径;写到 _doc/(应用私有目录)也顺带避开 Android 10+ 分区存储限制。
|
||||
* (注意:本函数走的是 plus.downloader,不是 uni.downloadFile —— 后者没有
|
||||
* 可用的落盘路径控制,正是当初被换掉的原因。)
|
||||
*
|
||||
* ⚠️ 两道内容校验的由来("invalid LOC header" 事故):
|
||||
* plus.runtime.install 拿到文件就按 ZIP 解压,文件不是合法 wgt 时抛的是
|
||||
* "invalid LOC header (bad signature)" 这种底层报错,工人只看到一句看不懂的
|
||||
* 英文。而拿到非 wgt 内容的情形并不罕见:静态服务器把 404 配成返回首页、
|
||||
* 反向代理吐 JSON 错误体、SSO 把请求 302 到登录页 —— 这些**都可能带着
|
||||
* HTTP 200 回来**,只判断 status === 200 根本拦不住。
|
||||
* 故:下载前探测响应头,下载后校验 ZIP 魔数,两道都过了才交给 install。
|
||||
*/
|
||||
async function downloadWgt(wgtUrl, newVersion) {
|
||||
// ── 第一道:下载前探测(状态码 / Content-Type / 体积)──
|
||||
// 目的是在浪费流量之前就拦下明显的错误页,并给出人话错误
|
||||
const probeError = await probeWgtUrl(wgtUrl);
|
||||
if (probeError) {
|
||||
otaFailed(probeError);
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件名必须带 .wgt 后缀 —— 这是 install 能识别为 wgt 资源包的前提。
|
||||
// 从下载地址取文件名(去掉 query),拿不到合法后缀时退回默认名。
|
||||
const rawName = String(wgtUrl).split("?")[0].split("/").pop() || "";
|
||||
const fileName = /\.wgt$/i.test(rawName) ? rawName : "__UNI__B572616.wgt";
|
||||
const savePath = "_doc/" + fileName;
|
||||
console.log("[OTA] 开始下载:", wgtUrl, "→", savePath);
|
||||
|
||||
let lastPct = -1;
|
||||
let task = null;
|
||||
|
||||
const onFinished = async (d, status) => {
|
||||
uni.hideLoading();
|
||||
console.log("[OTA] 下载结束, status =", status, "| 文件:", d && d.filename);
|
||||
|
||||
// 非 2xx 一律当失败:404=包没传上去,401/403=下载地址需要鉴权
|
||||
if (status !== 200) {
|
||||
const hint =
|
||||
status === 404 ? "更新包不存在,请联系管理员确认是否已上传"
|
||||
: status === 401 || status === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
|
||||
: "请稍后重试";
|
||||
otaFailed(`更新包下载失败(HTTP ${status}):${hint}`);
|
||||
return;
|
||||
}
|
||||
// 没拿到落盘路径就没法安装 —— 明确报出来,别让它以"没反应"收场
|
||||
if (!d || !d.filename) {
|
||||
otaFailed("下载已完成但未生成本地文件,请重试");
|
||||
return;
|
||||
}
|
||||
// 截断检测:服务端中途断开时 status 也可能是 200,但文件是不完整的
|
||||
if (d.totalSize > 0 && d.downloadedSize > 0 && d.downloadedSize < d.totalSize) {
|
||||
otaFailed(`更新包下载不完整(${d.downloadedSize}/${d.totalSize} 字节),请重试`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 第二道:ZIP 魔数校验(拦下所有「HTTP 200 但内容不是 wgt」的情况)──
|
||||
const contentError = await verifyWgtFile(d.filename);
|
||||
if (contentError) {
|
||||
otaFailed(contentError);
|
||||
return;
|
||||
}
|
||||
|
||||
installWgt(d.filename, newVersion);
|
||||
};
|
||||
|
||||
try {
|
||||
task = plus.downloader.createDownload(wgtUrl, { filename: savePath }, onFinished);
|
||||
// 进度反馈:每跨 10% 刷新一次提示(过于频繁会卡顿)
|
||||
task.addEventListener("statechanged", (d) => {
|
||||
if (!d || d.state !== 3) return; // 3 = 下载中
|
||||
if (!d.totalSize || d.totalSize <= 0) return;
|
||||
const pct = Math.floor((d.downloadedSize / d.totalSize) * 100);
|
||||
if (pct >= lastPct + 10) {
|
||||
lastPct = pct - (pct % 10);
|
||||
uni.showLoading({ title: `下载中 ${pct}%`, mask: true });
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[OTA] 创建下载任务异常:", e);
|
||||
otaFailed("无法创建下载任务:" + ((e && e.message) || "未知原因"));
|
||||
return;
|
||||
}
|
||||
|
||||
uni.showLoading({ title: "下载中 0%", mask: true });
|
||||
task.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载前探测更新包地址 —— 在消耗流量之前拦下「错误页」。
|
||||
*
|
||||
* 用 HEAD 只取响应头,不下载正文。核对三件事:
|
||||
* 1. statusCode 必须是 200(404 / 401 / 403 直接判失败)
|
||||
* 2. Content-Type 不能是 JSON / HTML —— 那说明拿回来的是错误页而不是安装包
|
||||
* 3. Content-Length 不能小得离谱(合法 wgt 不可能只有几百字节)
|
||||
*
|
||||
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
|
||||
*/
|
||||
function probeWgtUrl(wgtUrl) {
|
||||
return new Promise((resolve) => {
|
||||
uni.request({
|
||||
url: wgtUrl,
|
||||
method: "HEAD",
|
||||
timeout: 8000,
|
||||
success: (res) => {
|
||||
const header = res.header || {};
|
||||
const contentType = String(header["Content-Type"] || header["content-type"] || "");
|
||||
const contentLength = Number(header["Content-Length"] || header["content-length"] || 0);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(
|
||||
res.statusCode === 404 ? "更新包不存在,请联系管理员确认是否已上传"
|
||||
: res.statusCode === 401 || res.statusCode === 403 ? "更新包下载被拒绝(无权限),请联系管理员"
|
||||
: `更新包地址不可访问(HTTP ${res.statusCode})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (/json|html|text\/plain/i.test(contentType)) {
|
||||
resolve(`更新包地址返回的是 ${contentType},不是安装包 —— 请检查服务端配置`);
|
||||
return;
|
||||
}
|
||||
if (contentLength > 0 && contentLength < 1024) {
|
||||
resolve(`更新包体积异常(仅 ${contentLength} 字节),疑似错误页面`);
|
||||
return;
|
||||
}
|
||||
// Content-Type / Content-Length 都可能被服务端省略,此处只做「明显不对」的拦截
|
||||
console.log("[OTA] 预检通过 | Content-Type:", contentType || "(未返回)", "| 长度:", contentLength || "(未返回)");
|
||||
resolve("");
|
||||
},
|
||||
fail: (err) => {
|
||||
// HEAD 不被支持(部分静态服务器返回 405)或探测请求本身超时:
|
||||
// 不阻断下载 —— 还有第二道魔数校验兜底,没必要因此卡死更新
|
||||
console.warn("[OTA] 预检请求失败,跳过预检,改由下载后校验兜底:", err && err.errMsg);
|
||||
resolve("");
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验已下载的文件确实是 WGT —— 读取文件头 4 字节比对 ZIP 魔数。
|
||||
*
|
||||
* WGT 就是 ZIP 包,合法文件必定以 "50 4B 03 04" 开头(ASCII 即 "PK\x03\x04")。
|
||||
* 这正是 install 报 "invalid LOC header (bad signature)" 时会去校验的东西,
|
||||
* 我们提前自己验一遍,就能把底层报错换成一句人话。
|
||||
*
|
||||
* 实现上用 readAsDataURL 而非读二进制:base64 后 4 字节是 "UEsD",比对前缀
|
||||
* 即可,避开了 plus.io.FileReader 对二进制读取支持不一致的问题。
|
||||
*
|
||||
* @returns {Promise<string>} 空串 = 通过;非空 = 给用户看的失败原因
|
||||
*/
|
||||
function verifyWgtFile(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
plus.io.resolveLocalFileSystemURL(
|
||||
filePath,
|
||||
(entry) => {
|
||||
entry.file(
|
||||
(file) => {
|
||||
// slice 不可用的老机型上放弃校验(不阻断更新),交给 install 自己报错
|
||||
if (typeof file.slice !== "function") {
|
||||
console.warn("[OTA] 当前环境不支持文件切片,跳过魔数校验");
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
const reader = new plus.io.FileReader();
|
||||
reader.onloadend = (e) => {
|
||||
const result = String((e.target && e.target.result) || "");
|
||||
if (/;base64,UEsD/.test(result)) {
|
||||
console.log("[OTA] 更新包文件头校验通过 (ZIP signature OK)");
|
||||
resolve("");
|
||||
} else {
|
||||
resolve("下载到的文件不是有效的更新包(文件头异常),请联系管理员重新打包上传");
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.warn("[OTA] 读取更新包文件头失败,跳过校验");
|
||||
resolve("");
|
||||
};
|
||||
reader.readAsDataURL(file.slice(0, 4));
|
||||
},
|
||||
() => resolve("")
|
||||
);
|
||||
},
|
||||
() => resolve("")
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[OTA] 魔数校验异常,跳过:", e);
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 安装已下载的 WGT —— 成败都要在界面上说清楚 */
|
||||
function installWgt(filePath, newVersion) {
|
||||
console.log("[OTA] 开始安装:", filePath);
|
||||
uni.showLoading({ title: "安装中...", mask: true });
|
||||
plus.runtime.install(
|
||||
filePath,
|
||||
{ force: true },
|
||||
() => {
|
||||
uni.hideLoading();
|
||||
console.log("[OTA] 安装成功:", newVersion, "→ 即将重启");
|
||||
uni.showToast({ title: "更新完成,即将重启", icon: "none", duration: 2500 });
|
||||
setTimeout(() => {
|
||||
plus.runtime.restart();
|
||||
}, 2500);
|
||||
},
|
||||
(err) => {
|
||||
uni.hideLoading();
|
||||
console.error("[OTA] 安装失败:", JSON.stringify(err));
|
||||
otaFailed(
|
||||
`更新包安装失败:${(err && (err.message || err.code)) || "未知原因"}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新失败必须在界面上说出来 —— 只打日志等于让工人对着「点了没反应」干瞪眼 */
|
||||
function otaFailed(msg) {
|
||||
uni.showModal({
|
||||
title: "更新失败",
|
||||
content: `${msg}\n\n请确认网络正常后重试;若反复失败请联系管理员。`,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
}
|
||||
@ -7,8 +7,8 @@
|
||||
* 3. 生产默认 PROD_URL
|
||||
*/
|
||||
|
||||
const PROD_URL = "http://track_back.iris-rs.cn/api/v1";
|
||||
const LOCAL_URL = "http://192.168.9.80:8011/api/v1";
|
||||
// 域名/环境地址统一来自 utils/config.js,勿在此处硬编码
|
||||
import { PROD_URL, LOCAL_URL } from "./config";
|
||||
|
||||
// ============================================================
|
||||
// 环境 URL(自动感知:缓存配置 > NODE_ENV > 生产默认)
|
||||
@ -82,6 +82,39 @@ async function refreshAccessToken() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 错误文案提取 — 把后端 detail 统一转成能读的中文,杜绝 [object Object]
|
||||
// ============================================================
|
||||
//
|
||||
// FastAPI 的 detail 有三种形态,直接丢给 showToast 会变成 [object Object]:
|
||||
// 1. 字符串: {"detail": "任务状态为 COMPLETED,无法驳回"}
|
||||
// 2. 校验错误数组(422): {"detail": [{"loc": ["body","images"], "msg": "Value error, 驳回必须...", "type": "value_error"}]}
|
||||
// 3. 对象: {"detail": {"msg": "..."}}
|
||||
export function extractErrorDetail(data, fallback = "") {
|
||||
const detail = data && data.detail;
|
||||
if (!detail) return (data && data.msg) || fallback;
|
||||
if (typeof detail === "string") return detail || fallback;
|
||||
|
||||
if (Array.isArray(detail)) {
|
||||
const msgs = detail.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (!item || typeof item !== "object") return "";
|
||||
// Pydantic v2 自定义校验器抛的错带 "Value error, " 前缀,读起来噪音大,去掉
|
||||
const msg = String(item.msg || item.message || "").replace(/^Value error,\s*/, "");
|
||||
const field = Array.isArray(item.loc)
|
||||
? item.loc.filter((p) => !["body", "query", "path"].includes(p)).join(".")
|
||||
: "";
|
||||
return field && msg ? `${field}: ${msg}` : msg;
|
||||
}).filter(Boolean);
|
||||
return msgs.length ? msgs.join(";") : fallback;
|
||||
}
|
||||
|
||||
if (typeof detail === "object") {
|
||||
return detail.msg || detail.message || JSON.stringify(detail);
|
||||
}
|
||||
return String(detail) || fallback;
|
||||
}
|
||||
|
||||
export default function request(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = options.url.startsWith("http") ? options.url : getBaseUrl() + options.url;
|
||||
@ -137,13 +170,35 @@ export default function request(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 403) uni.showToast({ title: res.data?.detail || "无权操作", icon: "none", duration: 3000 });
|
||||
else if (code === 400) uni.showToast({ title: res.data?.detail || "请求参数有误", icon: "none", duration: 2500 });
|
||||
else if (code === 409) uni.showToast({ title: res.data?.detail || "操作冲突", icon: "none", duration: 3000 });
|
||||
else { const d = res.data?.detail || ""; uni.showToast({ title: d || `请求失败 (${code})`, icon: "none", duration: 3000 }); }
|
||||
// silent:调用方自行兜底提示时(如 feedback 的 404 回退 mock)跳过通用 toast,
|
||||
// 否则用户会先看到一句「请求失败 (404)」,再看到「感谢反馈」,自相矛盾。
|
||||
if (!options.silent) {
|
||||
if (code === 403) uni.showToast({ title: extractErrorDetail(res.data, "无权操作"), icon: "none", duration: 3000 });
|
||||
else if (code === 400) uni.showToast({ title: extractErrorDetail(res.data, "请求参数有误"), icon: "none", duration: 2500 });
|
||||
else if (code === 409) uni.showToast({ title: extractErrorDetail(res.data, "操作冲突"), icon: "none", duration: 3000 });
|
||||
else if (code === 422) {
|
||||
// 表单/参数校验失败(Pydantic)—— detail 是校验项数组,必须解析出 msg 再提示
|
||||
uni.showToast({ title: extractErrorDetail(res.data, "提交内容不符合要求"), icon: "none", duration: 3000 });
|
||||
}
|
||||
else uni.showToast({ title: extractErrorDetail(res.data, `请求失败 (${code})`), icon: "none", duration: 3000 });
|
||||
}
|
||||
reject(res);
|
||||
},
|
||||
fail() { uni.showToast({ title: "网络连接失败", icon: "none" }); reject(new Error("network")); },
|
||||
fail(err) {
|
||||
// ⚠️ fail = 压根没拿到后端响应(超时/断网),与 4xx/5xx 有本质区别:
|
||||
// 后端可能已经执行成功,只是响应没回来。故保留原始 errMsg 并打上标记,
|
||||
// 让上层能识别出「网络级失败」从而打断用户的连点重试。
|
||||
const errMsg = (err && (err.errMsg || err.message)) || "";
|
||||
const isTimeout = /timeout/i.test(errMsg);
|
||||
if (!options.silent) {
|
||||
uni.showToast({ title: isTimeout ? "网络超时,请稍后重试" : "网络连接失败", icon: "none" });
|
||||
}
|
||||
const e = new Error(errMsg || "network");
|
||||
e.errMsg = errMsg;
|
||||
e.isNetworkError = true;
|
||||
e.isTimeout = isTimeout;
|
||||
reject(e);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
101
track-uniapp/src/utils/upload.js
Normal file
101
track-uniapp/src/utils/upload.js
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 图片上传工具 — 超时与并发策略的唯一来源(追加记录 / 驳回弹窗共用)。
|
||||
*
|
||||
* 此前两个页面各写一份 uni.uploadFile 封装,行为容易漂移;且都是串行上传,
|
||||
* 9 张图要排队一张张传完,弱网下工人要干等半分钟以上。
|
||||
*/
|
||||
import { getBaseUrl, extractErrorDetail } from "./request";
|
||||
|
||||
/** 单张上传超时(毫秒)—— 弱网下防止请求永久挂起,卡住整个上传流程 */
|
||||
export const UPLOAD_TIMEOUT = 15000;
|
||||
|
||||
/**
|
||||
* 上传并发数。
|
||||
* 不用「9 张一起上」的无限并发:弱网带宽本就紧张,同时打满会互相挤占、
|
||||
* 整体反而更慢,还容易触发服务端限流。3 是等待时间与成功率的折中点。
|
||||
*/
|
||||
export const UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
/**
|
||||
* 上传单个文件。
|
||||
* @param {string} filePath 本地临时文件路径
|
||||
* @returns {Promise<string>} 成功时 resolve 后端返回的可访问 URL
|
||||
* @throws 失败时 reject —— 不 resolve(null) 静默丢弃,调用方必须显式处理
|
||||
*/
|
||||
export function uploadImage(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: getBaseUrl() + "/upload/",
|
||||
filePath,
|
||||
name: "file",
|
||||
timeout: UPLOAD_TIMEOUT,
|
||||
success(res) {
|
||||
let data = null;
|
||||
try { data = JSON.parse(res.data); } catch { data = null; }
|
||||
const url = data && data.url;
|
||||
// HTTP 非 2xx 时(类型不支持 400 / 超过 50MB 413 等)后端返回的是
|
||||
// {detail: ...} 而非 {url},必须当失败处理,否则图片会被静默丢弃
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && url) resolve(url);
|
||||
else reject(new Error(extractErrorDetail(data, `图片上传失败 (${res.statusCode || "无响应"})`)));
|
||||
},
|
||||
fail(err) {
|
||||
const errMsg = (err && err.errMsg) || "";
|
||||
reject(new Error(/timeout/i.test(errMsg) ? "图片上传超时" : errMsg || "图片上传失败"));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个图片项是否「已真正上传成功」。
|
||||
*
|
||||
* 依据来自本模块的契约:uploadImage() 成功时 resolve 的是后端返回的 URL
|
||||
* (相对路径 /api/v1/upload/files/xxx,或带域名的 http(s)://…);
|
||||
* 而未上传完 / 上传失败的项,拿到的是本地临时地址(blob:、file://、
|
||||
* _doc/、wxfile:// 等)。
|
||||
*
|
||||
* 各页面的「成功绿勾」角标只应依据本函数显示 —— 绝不能因为「它出现在
|
||||
* images 数组里」就当作成功,否则将来若有人把失败项也塞进数组(比如为了
|
||||
* 支持重试而保留占位),角标就会撒谎。
|
||||
*
|
||||
* @param {*} url 图片项
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUploadedUrl(url) {
|
||||
if (typeof url !== "string" || !url) return false;
|
||||
return url.startsWith("/") || /^https?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发上传一组图片(有界并发池,逐个补位,不会一次性打满)。
|
||||
*
|
||||
* @param {string[]} filePaths 本地临时文件路径列表
|
||||
* @param {(url: string|null, index: number) => void} [onEachDone]
|
||||
* 每张图片「有结果」时回调一次(成功传 url,失败传 null)——
|
||||
* 调用方据此回收 ⏳ 占位符,保证成功与失败都恰好回收一次。
|
||||
* @returns {Promise<{failed: number, total: number}>}
|
||||
*/
|
||||
export async function uploadImages(filePaths, onEachDone) {
|
||||
const queue = filePaths.map((filePath, index) => ({ filePath, index }));
|
||||
let cursor = 0;
|
||||
let failed = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (cursor < queue.length) {
|
||||
// 单线程 JS 里 cursor++ 在两次 await 之间是原子的,不会重复取到同一项
|
||||
const { filePath, index } = queue[cursor++];
|
||||
let url = null;
|
||||
try {
|
||||
url = await uploadImage(filePath);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error("[upload] 图片上传失败:", e);
|
||||
}
|
||||
if (onEachDone) onEachDone(url, index);
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.min(UPLOAD_CONCURRENCY, queue.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, worker));
|
||||
return { failed, total: filePaths.length };
|
||||
}
|
||||
@ -1,29 +1,117 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# uni-app 双向同步脚本
|
||||
# WSL ←→ Windows G 盘,两边改代码互相跟随
|
||||
# 用法: bash sync-watch.sh 停止: Ctrl+C
|
||||
# track-uniapp 源码 → HBuilderX 项目 同步
|
||||
#
|
||||
# 方向:单向 WSL track-uniapp/src/ → G:\Track\track-app\track
|
||||
#
|
||||
# ⚠️ 为什么是「src/ → 项目根」且单向(2026-09-15 修正):
|
||||
# HBuilderX 是以 G:\Track\track-app\track 为项目根打开的(经典布局,
|
||||
# pages.json / App.vue / manifest.json 都在根目录),该目录就是
|
||||
# track-uniapp/src/ 的**扁平镜像** —— 证据:test_wgt_local.bat 里写着
|
||||
# "Please build WGT in HBuilderX first",且两侧文件时间戳逐项吻合。
|
||||
#
|
||||
# 旧版脚本按 track-uniapp/ ↔ track-app/track/ 双向对拷,方向完全错误:
|
||||
# · 正向会把 src/ 整个塞成 track/src/,并用 WSL 的 index.html 覆盖
|
||||
# HBuilderX 的入口文件;
|
||||
# · 反向会把 Windows 项目根的文件搬回 WSL 根目录,让已清理的
|
||||
# 「幽灵文件」复活。
|
||||
# 故改为单向,且显式排除所有「只在 Windows 侧存在」的项。
|
||||
#
|
||||
# 用法:
|
||||
# bash sync-watch.sh # 同步一次,然后持续监听(推荐)
|
||||
# bash sync-watch.sh once # 只同步一次
|
||||
# bash sync-watch.sh dry # 只预览差异,不落盘
|
||||
# 停止: Ctrl+C
|
||||
# ============================================================
|
||||
|
||||
SRC="/home/yueli/track/track-uniapp"
|
||||
SRC="/home/yueli/track/track-uniapp/src"
|
||||
DST="/mnt/g/Track/track-app/track"
|
||||
|
||||
EXCLUDES="--exclude node_modules --exclude .git --exclude dist --exclude unpackage --exclude .hbuilderx --exclude package.json --exclude package-lock.json --exclude vite.config.ts --exclude uni.scss --exclude uni.promisify.adaptor.js"
|
||||
# ── 只在 Windows 侧存在、绝对不能被覆盖或删除的项 ──
|
||||
EXCLUDES=(
|
||||
# manifest.json 的版本号(T1.0.44)与存储权限只在 HBuilderX 里维护。
|
||||
# WSL 那份停留在 T1.0.8,是过期副本 —— 同步过去会回退版本号并抹掉权限。
|
||||
--exclude 'manifest.json'
|
||||
# HBuilderX 经典布局特有,src/ 里没有对应物
|
||||
--exclude 'index.html'
|
||||
--exclude 'static/'
|
||||
--exclude 'uni.scss'
|
||||
--exclude 'uni.promisify.adaptor.js'
|
||||
--exclude '.editorconfig'
|
||||
--exclude '.gitignore'
|
||||
--exclude '.hbuilderx/'
|
||||
# HBuilderX 的 WGT 构建产物(约 216M)
|
||||
--exclude 'unpackage/'
|
||||
--exclude 'node_modules/'
|
||||
--exclude '.git/'
|
||||
# 手工备份(如 pages/scan/detail.vue.bak-20260914)
|
||||
--exclude '*.bak-*'
|
||||
--exclude '*.wgt'
|
||||
)
|
||||
|
||||
# --delete 让「在 WSL 删掉的文件」在 Windows 侧同步消失(如已废弃的
|
||||
# FlowTree.vue / TaskTreeNode.vue)。被 --exclude 命中的项不受 --delete
|
||||
# 影响,所以上面的白名单不会被误删。
|
||||
#
|
||||
# 不用 -a:目标 /mnt/g 是 drvfs,POSIX 权限/属主都是合成的(恒为 777),
|
||||
# -a 里的 -p/-o/-g 每次都会判定为「有差异」→ 无谓 chmod + 刷屏的 p 标记。
|
||||
# 保留 -r(递归)+ -t(时间戳,两侧时间戳本就是同步依据)。
|
||||
RSYNC_OPTS=(-rlt --delete "${EXCLUDES[@]}" --no-perms --no-owner --no-group)
|
||||
|
||||
do_sync() {
|
||||
rsync -av $EXCLUDES "$SRC/" "$DST/" 2>/dev/null
|
||||
sleep 0.5
|
||||
rsync -av $EXCLUDES "$DST/" "$SRC/" 2>/dev/null
|
||||
case "$1" in
|
||||
dry)
|
||||
echo "🔍 预览模式(不落盘):"
|
||||
rsync "${RSYNC_OPTS[@]}" --dry-run --itemize-changes "$SRC/" "$DST/"
|
||||
;;
|
||||
*)
|
||||
rsync "${RSYNC_OPTS[@]}" --itemize-changes "$SRC/" "$DST/"
|
||||
local rc=$?
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo " ↻ $(date +%H:%M:%S) 已同步"
|
||||
else
|
||||
# 不吞错误:旧脚本 2>/dev/null 把 rsync 的报错全丢了,出了问题只能干瞪眼
|
||||
echo " ❌ $(date +%H:%M:%S) rsync 退出码 $rc —— 同步可能不完整,请检查"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "🔁 双向同步 $SRC ↔ $DST"
|
||||
echo " 按 Ctrl+C 停止"
|
||||
case "$1" in
|
||||
dry)
|
||||
do_sync dry
|
||||
exit 0
|
||||
;;
|
||||
once)
|
||||
do_sync
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "🔁 单向同步 $SRC → $DST"
|
||||
do_sync
|
||||
echo "✅ 首次同步完成,开始监听..."
|
||||
|
||||
inotifywait -m -r -e modify,create,delete,move \
|
||||
--exclude 'node_modules|.git|dist|unpackage|.hbuilderx' \
|
||||
"$SRC" 2>/dev/null | while read -r dir action file; do
|
||||
do_sync
|
||||
echo " ↻ $(date +%H:%M:%S) 已同步"
|
||||
done
|
||||
if command -v inotifywait >/dev/null 2>&1; then
|
||||
echo "👀 inotifywait 实时监听中(Ctrl+C 停止)"
|
||||
inotifywait -m -r -e modify,create,delete,move \
|
||||
--exclude 'node_modules|\.git|dist|unpackage|\.hbuilderx' \
|
||||
"$SRC" | while read -r _dir _action _file; do
|
||||
do_sync
|
||||
done
|
||||
else
|
||||
# 旧脚本在这里静默失败过:inotifywait 没装,2>/dev/null 又把报错吞了,
|
||||
# 表现为「脚本像是跑着,但改了代码 G 盘一直不更新」。改为明确提示 + 轮询兜底。
|
||||
echo "⚠️ inotifywait 未安装,退回轮询模式(每 3 秒比对一次)"
|
||||
echo " 想要实时监听: sudo apt install -y inotify-tools"
|
||||
echo " Ctrl+C 停止"
|
||||
last=""
|
||||
while true; do
|
||||
cur=$(find "$SRC" -type f -printf '%T@ %p\n' 2>/dev/null | sort | md5sum)
|
||||
if [ "$cur" != "$last" ]; then
|
||||
[ -n "$last" ] && do_sync
|
||||
last="$cur"
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user