Compare commits
32 Commits
da8db95141
...
6a79e21302
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a79e21302 | |||
| 661e77416a | |||
| 0d323e8181 | |||
| e5e2a395e7 | |||
| 616638a6c8 | |||
| eb97c12bed | |||
| 58cb9a23ef | |||
| 0287003e93 | |||
| df42250725 | |||
| 7c7b72cadd | |||
| aad26b3d9e | |||
| c92e7bd931 | |||
| 8f0f725e26 | |||
| 67755c7847 | |||
| 57884d7baa | |||
| 8be06bcd43 | |||
| b2cfa3871e | |||
| e5bcde667b | |||
| ef9e5bbae5 | |||
| 9846dd6c8f | |||
| 3176bcde6f | |||
| 49c8707955 | |||
| 5f7b8e9394 | |||
| 1ba44d4b0a | |||
| 81e41bc38b | |||
| f4ff4b5ecb | |||
| bdc861cf8e | |||
| bcf1038f8a | |||
| 11f9f64dc6 | |||
| ff3de80c96 | |||
| 999f8c2f3e | |||
| eccf79b8d1 |
24
backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py
Normal file
24
backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""add_task_type
|
||||
|
||||
Revision ID: a7b8c9d0e1f2
|
||||
Revises: f6a7b8c9d0e1
|
||||
Create Date: 2026-08-06
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "a7b8c9d0e1f2"
|
||||
down_revision: Union[str, None] = "f6a7b8c9d0e1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("tasks", sa.Column("task_type", sa.String(20), nullable=True, comment="派生类型: TRANSFER/SPAWN/RECOVERY"))
|
||||
# 刷老数据:parent_task_id 非空的默认为 TRANSFER
|
||||
op.execute("UPDATE tasks SET task_type = 'TRANSFER' WHERE parent_task_id IS NOT NULL AND task_type IS NULL")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("tasks", "task_type")
|
||||
@ -121,6 +121,23 @@ async def end_task_endpoint(
|
||||
return await task_service.end_task(db, uuid.UUID(task_id), operator_id)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 0.3:撤回转交 (PENDING → 删除 + 恢复父任务)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/recall", response_model=TaskResponse)
|
||||
async def recall_task_endpoint(
|
||||
task_id: str,
|
||||
operator_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
**撤回转交:删除 PENDING 子任务,恢复父任务为 WIP。**
|
||||
适用场景:转交后发现选错人,在对方接收前撤回。
|
||||
"""
|
||||
return await task_service.recall_task(db, uuid.UUID(task_id), operator_id)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 0.5:并发派发协助分支 (WIP → 不改变状态,创建子任务)
|
||||
# ============================================================
|
||||
|
||||
@ -11,6 +11,7 @@ class UserOption(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
full_name: str
|
||||
department: str = ""
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@ -19,39 +20,49 @@ class UserOption(BaseModel):
|
||||
def list_users(
|
||||
keyword: str = Query("", description="按用户名/姓名模糊搜索"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
dept: str = Query("IRIS", description="部门过滤"),
|
||||
):
|
||||
"""获取 MOM 系统用户列表,供前端选人使用"""
|
||||
"""获取 MOM 系统用户列表,默认只返回 IRIS 部门"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
if keyword.strip():
|
||||
sql = text(
|
||||
"""
|
||||
# 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询
|
||||
try:
|
||||
base_sql = """
|
||||
SELECT id, username,
|
||||
SPLIT_PART(username, '/', 1) AS full_name
|
||||
SPLIT_PART(username, '/', 1) AS full_name,
|
||||
COALESCE(department, '') AS department
|
||||
FROM sys_user
|
||||
WHERE username ILIKE :kw
|
||||
ORDER BY username
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
rows = db.execute(sql, {"kw": f"%{keyword.strip()}%", "lim": limit}).fetchall()
|
||||
else:
|
||||
sql = text(
|
||||
"""
|
||||
WHERE department = :dept
|
||||
"""
|
||||
params = {"dept": dept, "lim": limit}
|
||||
if keyword.strip():
|
||||
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
|
||||
params["kw"] = f"%{keyword.strip()}%"
|
||||
else:
|
||||
sql = text(base_sql + " ORDER BY username LIMIT :lim")
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
except Exception:
|
||||
# 降级:不使用 department 列过滤
|
||||
fallback_sql = """
|
||||
SELECT id, username,
|
||||
SPLIT_PART(username, '/', 1) AS full_name
|
||||
SPLIT_PART(username, '/', 1) AS full_name,
|
||||
'' AS department
|
||||
FROM sys_user
|
||||
ORDER BY username
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
rows = db.execute(sql, {"lim": limit}).fetchall()
|
||||
"""
|
||||
params = {"lim": limit}
|
||||
if keyword.strip():
|
||||
sql = text(fallback_sql + " WHERE username ILIKE :kw ORDER BY username LIMIT :lim")
|
||||
params["kw"] = f"%{keyword.strip()}%"
|
||||
else:
|
||||
sql = text(fallback_sql + " ORDER BY username LIMIT :lim")
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
|
||||
return [
|
||||
UserOption(
|
||||
id=str(row.id),
|
||||
username=row.username.split("/")[-1] if "/" in row.username else row.username,
|
||||
full_name=row.full_name,
|
||||
department=row.department or "",
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@ -14,6 +14,7 @@ TASK_STATUS_WIP = "WIP" # 进行中
|
||||
TASK_STATUS_COMPLETED = "COMPLETED" # 已完成
|
||||
TASK_STATUS_REJECTED = "REJECTED" # 已驳回
|
||||
TASK_STATUS_ARCHIVED = "ARCHIVED" # 已入库
|
||||
TASK_STATUS_CANCELED = "CANCELED" # 已撤回/已作废
|
||||
|
||||
|
||||
class Task(Base):
|
||||
@ -69,6 +70,9 @@ class Task(Base):
|
||||
is_rework: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="是否为返工任务",
|
||||
)
|
||||
task_type: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, comment="任务派生类型: TRANSFER/SPAWN/RECOVERY/null=历史数据",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
String(2000), nullable=True, comment="任务初始描述/交接备注",
|
||||
)
|
||||
|
||||
@ -131,6 +131,7 @@ class TaskResponse(BaseModel):
|
||||
status: str
|
||||
notify_parent_on_complete: bool
|
||||
is_rework: bool = False
|
||||
task_type: str | None = None
|
||||
remark: str | None = None
|
||||
reject_reason: str | None = None
|
||||
received_at: datetime | None = None
|
||||
|
||||
@ -15,11 +15,19 @@ from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordRespon
|
||||
|
||||
def _task_to_response(task: Task) -> TaskResponse:
|
||||
"""将 Task ORM 对象递归转为 TaskResponse(含子任务树)"""
|
||||
product_sn = ""
|
||||
product_material = ""
|
||||
try:
|
||||
if task.product:
|
||||
product_sn = task.product.serial_number or ""
|
||||
product_material = (task.product.material_name or task.product.material_id or "")
|
||||
except Exception:
|
||||
pass
|
||||
return TaskResponse(
|
||||
id=task.id,
|
||||
product_id=task.product_id,
|
||||
product_sn=task.product.serial_number if task.product else "",
|
||||
product_material=task.product.material_name or task.product.material_id or "" if task.product else "",
|
||||
product_sn=product_sn,
|
||||
product_material=product_material,
|
||||
parent_task_id=task.parent_task_id,
|
||||
task_name=task.task_name,
|
||||
assignee_id=task.assignee_id,
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED
|
||||
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.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task_log import TaskLog
|
||||
@ -91,19 +91,60 @@ async def _get_task_with_children_recursive(db: AsyncSession, task_id: uuid.UUID
|
||||
return task
|
||||
|
||||
|
||||
def _to_response(task: Task) -> TaskResponse:
|
||||
"""将 Task ORM 对象转为递归 TaskResponse"""
|
||||
def _to_flat_response(task: Task) -> TaskResponse:
|
||||
"""扁平序列化,不递归 children(避免 MissingGreenlet)"""
|
||||
product_sn = ""
|
||||
product_material = ""
|
||||
try:
|
||||
if task.product:
|
||||
product_sn = task.product.serial_number or ""
|
||||
product_material = (task.product.material_name or task.product.material_id or "")
|
||||
except Exception:
|
||||
pass
|
||||
return TaskResponse(
|
||||
id=task.id,
|
||||
product_id=task.product_id,
|
||||
product_sn=task.product.serial_number if task.product else "",
|
||||
product_material=task.product.material_name or task.product.material_id or "" if task.product else "",
|
||||
product_sn=product_sn,
|
||||
product_material=product_material,
|
||||
parent_task_id=task.parent_task_id,
|
||||
task_name=task.task_name,
|
||||
assignee_id=task.assignee_id,
|
||||
status=task.status,
|
||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||
is_rework=task.is_rework,
|
||||
task_type=task.task_type,
|
||||
remark=task.remark,
|
||||
reject_reason=task.reject_reason,
|
||||
received_at=task.received_at,
|
||||
completed_at=task.completed_at,
|
||||
created_at=task.created_at,
|
||||
child_tasks=[],
|
||||
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
|
||||
)
|
||||
|
||||
|
||||
def _to_response(task: Task) -> TaskResponse:
|
||||
"""将 Task ORM 对象转为递归 TaskResponse"""
|
||||
product_sn = ""
|
||||
product_material = ""
|
||||
try:
|
||||
if task.product:
|
||||
product_sn = task.product.serial_number or ""
|
||||
product_material = (task.product.material_name or task.product.material_id or "")
|
||||
except Exception:
|
||||
pass
|
||||
return TaskResponse(
|
||||
id=task.id,
|
||||
product_id=task.product_id,
|
||||
product_sn=product_sn,
|
||||
product_material=product_material,
|
||||
parent_task_id=task.parent_task_id,
|
||||
task_name=task.task_name,
|
||||
assignee_id=task.assignee_id,
|
||||
status=task.status,
|
||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||
is_rework=task.is_rework,
|
||||
task_type=task.task_type,
|
||||
remark=task.remark,
|
||||
reject_reason=task.reject_reason,
|
||||
received_at=task.received_at,
|
||||
@ -229,9 +270,9 @@ async def get_all_tasks(
|
||||
result = await db.execute(stmt)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
# 返回所有匹配的任务(含子任务),不再过滤 parent_task_id
|
||||
all_tasks = [_to_response(t) for t in tasks]
|
||||
return TaskListResponse(tasks=all_tasks, total=len(all_tasks))
|
||||
# 返回扁平列表(不递归 children,避免 MissingGreenlet)
|
||||
flat_tasks = [_to_flat_response(t) for t in tasks]
|
||||
return TaskListResponse(tasks=flat_tasks, total=len(flat_tasks))
|
||||
|
||||
|
||||
# ============================================================
|
||||
@ -267,6 +308,57 @@ async def end_task(
|
||||
return _to_response(task)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 0.3:撤回转交
|
||||
# ============================================================
|
||||
|
||||
async def recall_task(
|
||||
db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None
|
||||
) -> TaskResponse:
|
||||
"""撤回 PENDING 转交:标记为 CANCELED,以被撤回节点为父生成接力新任务给操作人。"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
if task.status != TASK_STATUS_PENDING:
|
||||
raise HTTPException(status_code=409, detail="只有待接收(PENDING)的任务可以撤回")
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
# 1. 废掉当前待接收任务
|
||||
task.status = TASK_STATUS_CANCELED
|
||||
task.completed_at = now
|
||||
await _create_task_log(db, task_id, action_type="recall", operator_id=operator_id,
|
||||
remark=f"撤回转交「{task.task_name}」→ {task.assignee_id}")
|
||||
db.add(TaskRecord(task_id=task.id, remark=f"[撤回] 转交至 {task.assignee_id} 已撤回", images="[]"))
|
||||
|
||||
# 2. 生成接力新任务(以撤回节点为父,还给操作人)
|
||||
recovery = Task(
|
||||
product_id=task.product_id,
|
||||
parent_task_id=task.id,
|
||||
task_name=task.task_name,
|
||||
assignee_id=operator_id,
|
||||
status=TASK_STATUS_WIP,
|
||||
task_type="RECOVERY",
|
||||
notify_parent_on_complete=False,
|
||||
is_rework=False,
|
||||
remark=f"撤回「{task.task_name}」后重新接手",
|
||||
)
|
||||
db.add(recovery)
|
||||
await db.flush()
|
||||
await _create_task_log(db, recovery.id, action_type="create", operator_id=operator_id,
|
||||
remark=f"撤回接力:撤回「{task.task_name}」→ {task.assignee_id} 后重新指派给 {operator_id}")
|
||||
db.add(TaskRecord(task_id=recovery.id, remark=f"[重新接手] 撤回转交后系统自动生成接力节点", images="[]"))
|
||||
|
||||
# 3. 更新产品位置
|
||||
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product and operator_id:
|
||||
product.current_location_id = operator_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(recovery)
|
||||
return _to_response(recovery)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 0.5:派发协助分支(不改变父任务状态)
|
||||
# ============================================================
|
||||
@ -288,6 +380,7 @@ async def spawn_subtask(
|
||||
task_name=data.task_name,
|
||||
assignee_id=data.assignee_id,
|
||||
status=TASK_STATUS_PENDING,
|
||||
task_type="SPAWN",
|
||||
notify_parent_on_complete=False,
|
||||
is_rework=False,
|
||||
remark=data.remark or None,
|
||||
@ -295,11 +388,8 @@ async def spawn_subtask(
|
||||
db.add(child)
|
||||
await db.flush()
|
||||
|
||||
# 同步产品宏观状态
|
||||
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
||||
product = product_result.scalar_one_or_none()
|
||||
if product and data.task_name:
|
||||
product.overall_status = "在库" if "virtual_warehouse" in data.task_name else data.task_name
|
||||
# 协助分支不改变产品宏观状态(只有主分支影响 overall_status)
|
||||
db.add(TaskRecord(task_id=task.id, remark=data.remark or f"[派发协助] 分配给 {data.assignee_id}", images="[]"))
|
||||
|
||||
await _create_task_log(db, child.id, action_type="create", operator_id=operator_id,
|
||||
remark=f"协助分支(由「{task.task_name}」派发,分配给 {data.assignee_id})")
|
||||
@ -315,7 +405,7 @@ async def _check_children_done(db: AsyncSession, task_id: uuid.UUID):
|
||||
)
|
||||
children = result.scalars().all()
|
||||
incomplete = [c for c in children if c.status not in (
|
||||
TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, ARCHIVED_STATUS
|
||||
TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, ARCHIVED_STATUS
|
||||
)]
|
||||
if incomplete:
|
||||
names = "、".join(c.task_name for c in incomplete)
|
||||
@ -373,6 +463,7 @@ async def receive_task(
|
||||
operator_id=operator_id,
|
||||
remark=remark or f"操作员确认接收任务「{task.task_name}」",
|
||||
)
|
||||
db.add(TaskRecord(task_id=task.id, remark=remark or f"[接收] 操作员已确认接收", images="[]"))
|
||||
|
||||
# 接收时同步产品位置到接收人 + 宏观状态同步
|
||||
product_result = await db.execute(select(Product).where(Product.id == task.product_id))
|
||||
@ -471,6 +562,7 @@ async def reject_task(
|
||||
task_name=task.task_name,
|
||||
assignee_id=rework_assignee_id,
|
||||
status=TASK_STATUS_PENDING,
|
||||
task_type="TRANSFER",
|
||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||
is_rework=True,
|
||||
)
|
||||
@ -516,9 +608,6 @@ async def transfer_task(
|
||||
"""
|
||||
task = await _get_task_or_404(db, task_id)
|
||||
|
||||
# 校验:必须等待所有协助分支完成
|
||||
await _check_children_done(db, task_id)
|
||||
|
||||
# 校验:不能重复完成
|
||||
if task.status == TASK_STATUS_COMPLETED:
|
||||
raise HTTPException(
|
||||
@ -552,6 +641,7 @@ async def transfer_task(
|
||||
operator_id=operator_id,
|
||||
remark=request.note or f"完成任务「{task.task_name}」,转交至下一道工序",
|
||||
)
|
||||
db.add(TaskRecord(task_id=task.id, remark=request.note or f"[完工转交] 移交下一工序", images="[]"))
|
||||
|
||||
# --- 动作 2:解析下家 & 裂变 ---
|
||||
# 兼容新旧格式
|
||||
@ -581,7 +671,7 @@ async def transfer_task(
|
||||
elif is_child_task:
|
||||
new_parent_task_id = task.parent_task_id
|
||||
else:
|
||||
new_parent_task_id = None
|
||||
new_parent_task_id = task.id
|
||||
|
||||
new_task = Task(
|
||||
product_id=task.product_id,
|
||||
@ -589,6 +679,7 @@ async def transfer_task(
|
||||
task_name=task_name,
|
||||
assignee_id=assignee_id,
|
||||
status=TASK_STATUS_PENDING,
|
||||
task_type="TRANSFER",
|
||||
notify_parent_on_complete=False,
|
||||
is_rework=False,
|
||||
remark=request.note or None,
|
||||
|
||||
@ -284,6 +284,48 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 打印标签
|
||||
// ============================================================
|
||||
|
||||
function printCurrentQRCode() {
|
||||
const printArea = document.getElementById("label-print-area");
|
||||
if (!printArea) return;
|
||||
const printContent = printArea.innerHTML;
|
||||
|
||||
const styles = Array.from(document.querySelectorAll("style, link[rel=\"stylesheet\"]"))
|
||||
.map(el => el.outerHTML)
|
||||
.join("");
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.style.position = "absolute";
|
||||
iframe.style.width = "0";
|
||||
iframe.style.height = "0";
|
||||
iframe.style.border = "none";
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
const doc = iframe.contentWindow!.document;
|
||||
doc.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>打印标签</title>${styles}
|
||||
<style>
|
||||
@page { size: auto; margin: 0mm; }
|
||||
body { margin: 0; padding: 0; background: #fff; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body><div style="width:100%;max-width:360px;margin:0 auto;padding:12px;box-sizing:border-box;background:#fff;font-family:monospace,'Helvetica Neue',sans-serif;">${printContent}</div></body>
|
||||
</html>
|
||||
`);
|
||||
doc.close();
|
||||
|
||||
iframe.contentWindow!.onload = () => {
|
||||
iframe.contentWindow!.focus();
|
||||
iframe.contentWindow!.print();
|
||||
setTimeout(() => { document.body.removeChild(iframe); }, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染
|
||||
// ============================================================
|
||||
@ -299,23 +341,34 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
|
||||
>
|
||||
{createdSn ? (
|
||||
/* ---- 成功页 ---- */
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<img
|
||||
src={`/api/v1/products/qrcode/${createdSn}`}
|
||||
alt={`QR-${createdSn}`}
|
||||
className="h-48 w-48 rounded-lg border"
|
||||
/>
|
||||
<p className="mt-3 font-mono text-lg font-bold tracking-widest text-gray-800">
|
||||
{createdSn}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-green-600">产品创建成功!</p>
|
||||
<Space className="mt-5">
|
||||
<Button onClick={() => window.open(`/api/v1/products/qrcode/${createdSn}`, "_blank")}>
|
||||
打开二维码
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setCreatedSn(null)}>
|
||||
继续创建
|
||||
</Button>
|
||||
<div className="py-4">
|
||||
<div id="label-print-area" style={{
|
||||
width: '100%', maxWidth: '360px', margin: '0 auto', padding: '12px',
|
||||
boxSizing: 'border-box', background: '#fff',
|
||||
fontFamily: 'monospace, "Helvetica Neue", Helvetica, sans-serif',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ float: 'left', width: '110px', height: '110px', marginRight: '10px', marginBottom: '4px' }}>
|
||||
<img src={`/api/v1/products/qrcode/${createdSn}`} alt="QR"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', fontWeight: 900, color: '#000', lineHeight: '1.4', wordBreak: 'break-all' }}>
|
||||
<div style={{ marginBottom: '4px' }}>名: {selected?.material_name ?? ""}</div>
|
||||
<div style={{ marginBottom: '4px' }}>规: {selected?.spec_model ?? ""}</div>
|
||||
<div style={{ marginBottom: '4px' }}>单: {orderNo || "—"}</div>
|
||||
</div>
|
||||
<div style={{ clear: 'both' }}></div>
|
||||
</div>
|
||||
<div style={{ width: '100%', marginTop: '8px', paddingTop: '8px', borderTop: '2px solid #000',
|
||||
textAlign: 'center', fontSize: '16px', fontWeight: 900, color: '#000', letterSpacing: '1px' }}>
|
||||
码: {createdSn}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-sm text-green-600">产品创建成功!</p>
|
||||
<Space className="mt-3 flex justify-center">
|
||||
<Button type="primary" onClick={printCurrentQRCode}>🖨️ 直接打印标签</Button>
|
||||
<Button onClick={() => setCreatedSn(null)}>继续创建</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@ -17,9 +17,6 @@
|
||||
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
|
||||
>
|
||||
<view class="canvas-inner">
|
||||
<!-- 连线层 -->
|
||||
<view v-for="line in treeLines" :key="line.key" class="canvas-line" :style="lineStyle(line)"></view>
|
||||
|
||||
<!-- 节点卡片层 -->
|
||||
<view v-for="node in treeNodes" :key="node.id"
|
||||
class="canvas-node" :class="statusColor(node.status)"
|
||||
@ -27,7 +24,7 @@
|
||||
@tap="node.records && node.records.length && $emit('viewRecords', node)">
|
||||
|
||||
<view class="cn-header">
|
||||
<text class="cn-name">{{ node.task_name }}</text>
|
||||
<text :class="node.parent_task_id ? 'badge-sub' : 'badge-main'">{{ node.parent_task_id ? branchLabelMap[node.id] || '分支' : '主分支' }}</text>
|
||||
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
|
||||
</view>
|
||||
|
||||
@ -60,80 +57,86 @@ export default {
|
||||
treeNodes() {
|
||||
if (!this.product || !this.product.task_tree) return [];
|
||||
|
||||
// 1. 拍平所有任务(无视后端的错误嵌套)
|
||||
const allTasks = [];
|
||||
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
|
||||
const nodes = [];
|
||||
const rowMaxX = {};
|
||||
let currentMaxYIdx = -1;
|
||||
|
||||
const flatMap = {};
|
||||
const flatten = (tasks) => {
|
||||
if (!tasks) return;
|
||||
for (const t of tasks) { allTasks.push(t); flatten(t.child_tasks); }
|
||||
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
|
||||
};
|
||||
flatten(this.product.task_tree);
|
||||
|
||||
// 2. 绝对时间轴排序(强制自上而下流转)
|
||||
allTasks.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
||||
const layoutNode = (t) => {
|
||||
if (t._visited) return;
|
||||
t._visited = true;
|
||||
|
||||
// 3. 智能分层(判定谁是同行裂变,谁是上下游)
|
||||
const levels = [];
|
||||
let currentLevel = [];
|
||||
let lastTime = null;
|
||||
|
||||
allTasks.forEach(t => {
|
||||
const tTime = new Date(t.created_at).getTime();
|
||||
if (lastTime === null) {
|
||||
currentLevel.push(t); lastTime = tTime;
|
||||
if (!t.parent_task_id) {
|
||||
// 规则A:根节点 → 新行 X=0
|
||||
currentMaxYIdx++;
|
||||
t._yIdx = currentMaxYIdx;
|
||||
t._xIdx = 0;
|
||||
} else {
|
||||
if (Math.abs(tTime - lastTime) < 2000 ||
|
||||
(t.parent_task_id && currentLevel.some(c => c.parent_task_id === t.parent_task_id))) {
|
||||
currentLevel.push(t);
|
||||
} else {
|
||||
levels.push(currentLevel);
|
||||
currentLevel = [t]; lastTime = tTime;
|
||||
const parent = flatMap[t.parent_task_id];
|
||||
if (parent && !parent._visited) layoutNode(parent);
|
||||
|
||||
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
|
||||
|
||||
if (type === 'SPAWN') {
|
||||
t._yIdx = parent._yIdx;
|
||||
t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1;
|
||||
} else if (type === 'TRANSFER' || type === 'MAIN') {
|
||||
currentMaxYIdx++;
|
||||
t._yIdx = currentMaxYIdx;
|
||||
t._xIdx = 0;
|
||||
} else if (type === 'RECOVERY') {
|
||||
currentMaxYIdx++;
|
||||
t._yIdx = currentMaxYIdx;
|
||||
t._xIdx = parent._xIdx;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (currentLevel.length > 0) levels.push(currentLevel);
|
||||
|
||||
// 4. 计算 X, Y 坐标
|
||||
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
|
||||
const nodes = [];
|
||||
nodes._lines = [];
|
||||
rowMaxX[t._yIdx] = Math.max(rowMaxX[t._yIdx] || 0, t._xIdx);
|
||||
t.x = 40 + t._xIdx * (CARD_W + GAP_X);
|
||||
t.y = 20 + t._yIdx * (CARD_H + GAP_Y);
|
||||
nodes.push(t);
|
||||
|
||||
let maxCards = Math.max(...levels.map(l => l.length));
|
||||
const CENTER_X = (maxCards * CARD_W + (maxCards - 1) * GAP_X) / 2 + 60;
|
||||
if (t.child_tasks && t.child_tasks.length) {
|
||||
t.child_tasks
|
||||
.sort((a, b) => (a.task_type === 'TRANSFER' ? -1 : 1))
|
||||
.forEach(child => layoutNode(child));
|
||||
}
|
||||
};
|
||||
|
||||
levels.forEach((lvlTasks, depth) => {
|
||||
const y = 40 + depth * (CARD_H + GAP_Y);
|
||||
const lvlWidth = lvlTasks.length * CARD_W + (lvlTasks.length - 1) * GAP_X;
|
||||
const startX = CENTER_X - lvlWidth / 2;
|
||||
lvlTasks.forEach((t, i) => {
|
||||
nodes.push({ ...t, x: startX + i * (CARD_W + GAP_X), y, _depth: depth });
|
||||
});
|
||||
});
|
||||
|
||||
// 5. 智能连线生成
|
||||
for (let d = 1; d < levels.length; d++) {
|
||||
const curLvl = levels[d];
|
||||
const prevLvl = levels[d - 1];
|
||||
curLvl.forEach(curr => {
|
||||
const currNode = nodes.find(n => n.id === curr.id);
|
||||
let parents = [];
|
||||
if (curr.parent_task_id) {
|
||||
parents = nodes.filter(n => n.id === curr.parent_task_id);
|
||||
}
|
||||
if (parents.length === 0) {
|
||||
parents = prevLvl.map(p => nodes.find(n => n.id === p.id));
|
||||
}
|
||||
parents.forEach(p => {
|
||||
nodes._lines.push({
|
||||
key: p.id + '-' + curr.id,
|
||||
x1: p.x + CARD_W / 2, y1: p.y + CARD_H,
|
||||
x2: currNode.x + CARD_W / 2, y2: currNode.y
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
this.product.task_tree.forEach(root => layoutNode(root));
|
||||
return nodes;
|
||||
},
|
||||
treeLines() { return this.treeNodes._lines || []; },
|
||||
branchLabelMap() {
|
||||
const map = {};
|
||||
if (!this.product || !this.product.task_tree) return map;
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (t.task_type !== 'SPAWN') {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
spawnIndex++;
|
||||
const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`;
|
||||
map[t.id] = `分支 ${num}`;
|
||||
traverse(t.child_tasks, num);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.product.task_tree.forEach((t) => {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, '');
|
||||
});
|
||||
return map;
|
||||
},
|
||||
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.x)) + 300); },
|
||||
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(800, Math.max(...this.treeNodes.map(n => n.y)) + 300); }
|
||||
},
|
||||
@ -153,8 +156,8 @@ export default {
|
||||
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' };
|
||||
},
|
||||
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" }; return map[s] || s; },
|
||||
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; default: return "s-gray"; } }
|
||||
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
|
||||
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 "CANCELED": return "s-canceled"; default: return "s-gray"; } }
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -172,7 +175,10 @@ export default {
|
||||
.canvas-node.s-blue { border-left-color: #3b82f6; }
|
||||
.canvas-node.s-green { border-left-color: #22c55e; }
|
||||
.canvas-node.s-red { border-left-color: #ef4444; }
|
||||
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.5; filter: grayscale(0.6); }
|
||||
.cn-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
|
||||
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
|
||||
.cn-name { font-size: 24px; font-weight: 800; color: #1f2937; }
|
||||
.cn-badge { font-size: 14px; padding: 4px 12px; border-radius: 8px; font-weight: 700; }
|
||||
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
|
||||
|
||||
@ -23,7 +23,9 @@
|
||||
<view v-for="t in focusTasks" :key="t.id" class="task-list-item"
|
||||
:class="statusColor(t.status)" @tap="lockTask(t.id)">
|
||||
<view class="tli-left">
|
||||
<text class="tag-branch">{{ branchLabelsMap[t.id] || '' }}</text>
|
||||
<text class="tli-name">{{ t.task_name }}</text>
|
||||
<text class="tli-assignee">→ {{ t.assignee_id || '未分配' }}</text>
|
||||
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
|
||||
</view>
|
||||
<view class="tli-right">
|
||||
@ -44,6 +46,7 @@
|
||||
|
||||
<view class="focus-card" :class="statusColor(lockedTask.status)">
|
||||
<view class="fc-header">
|
||||
<text class="tag-branch">{{ branchLabelsMap[lockedTask.id] || '' }}</text>
|
||||
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
|
||||
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">⚠返工</text>
|
||||
<text v-if="lockedTask.parent_task_id && lockedTask.status === 'WIP'" class="sub-branch-end"
|
||||
@ -75,7 +78,7 @@
|
||||
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-actions">
|
||||
<view v-if="isAssignee" class="footer-actions">
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'transfer' })"><text class="btn-icon">🔄</text><text class="btn-txt">完工转交</text></button>
|
||||
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record"
|
||||
@ -87,12 +90,19 @@
|
||||
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-reject"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'reject' })"><text class="btn-icon">❌</text><text class="btn-txt">驳回任务</text></button>
|
||||
</view>
|
||||
<view v-else-if="lockedTask.status === 'PENDING' && canRecall" class="footer-actions">
|
||||
<button class="footer-btn footer-recall"
|
||||
@tap="$emit('action', { task: lockedTask, type: 'recall' })"><text class="btn-icon">🔄</text><text class="btn-txt">撤回转交</text></button>
|
||||
</view>
|
||||
<view v-else class="footer-actions footer-readonly">
|
||||
<text class="readonly-hint">🔒 非当前任务指派人,仅可查看</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
name: "WorkspaceArea",
|
||||
@ -117,15 +127,54 @@ export default {
|
||||
const walk = (tasks) => {
|
||||
if (!tasks) return;
|
||||
for (const t of tasks) {
|
||||
const isMine = t.status === 'WIP' || t.status === 'PENDING';
|
||||
if (isMine && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) result.push(t);
|
||||
if (t.status === 'WIP' || t.status === 'PENDING') result.push(t);
|
||||
walk(t.child_tasks);
|
||||
}
|
||||
};
|
||||
if (this.product) walk(this.product.task_tree);
|
||||
result.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);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
|
||||
isAssignee() { if (!this.lockedTask) return false; return this.lockedTask.assignee_id == this.currentUserId || this.lockedTask.assignee_id == this.currentUsername; },
|
||||
canRecall() {
|
||||
if (!this.lockedTask || this.lockedTask.status !== 'PENDING') return false;
|
||||
if (this.isAssignee) return false;
|
||||
if (!this.lockedTask.parent_task_id) return false;
|
||||
const parent = this.taskMap[this.lockedTask.parent_task_id];
|
||||
if (!parent) return false;
|
||||
return parent.assignee_id == this.currentUserId || parent.assignee_id == this.currentUsername;
|
||||
},
|
||||
branchLabelsMap() {
|
||||
const map = {};
|
||||
if (!this.product || !this.product.task_tree) return map;
|
||||
const traverse = (tasks, prefix) => {
|
||||
if (!tasks) return;
|
||||
let spawnIndex = 0;
|
||||
tasks.forEach((t) => {
|
||||
if (t.task_type !== 'SPAWN') {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, prefix);
|
||||
} else {
|
||||
spawnIndex++;
|
||||
const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`;
|
||||
map[t.id] = `分支 ${num}`;
|
||||
traverse(t.child_tasks, num);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.product.task_tree.forEach((t) => {
|
||||
map[t.id] = '主分支';
|
||||
traverse(t.child_tasks, '');
|
||||
});
|
||||
return map;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
statusLabel(s) { return STATUS_MAP[s] || s; },
|
||||
@ -150,7 +199,9 @@ export default {
|
||||
.task-list-item.s-yellow { border-left-color: #f59e0b; }
|
||||
.task-list-item.s-blue { border-left-color: #3b82f6; }
|
||||
.tli-left { flex: 1; min-width: 0; }
|
||||
.tag-branch { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; display: inline-block; width: max-content; margin-bottom: 4px; }
|
||||
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
|
||||
.tli-assignee { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||||
.tli-remark { font-size: 12px; color: #a16207; font-weight: bold; margin-top: 4px; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tli-right { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; margin-left: 8px; }
|
||||
.tli-badge { font-size: 11px; padding: 2px 10px; border-radius: 12px; font-weight: 600; }
|
||||
@ -189,10 +240,13 @@ export default {
|
||||
.footer-btn::after { border: none; }
|
||||
.btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
|
||||
.btn-txt { font-size: 24rpx; font-weight: 700; }
|
||||
.footer-readonly { justify-content: center; background: #fef2f2; }
|
||||
.readonly-hint { font-size: 24rpx; color: #dc2626; font-weight: 600; }
|
||||
.footer-transfer { background: #dcfce7; color: #16a34a; }
|
||||
.footer-record { background: #eff6ff; color: #2563eb; }
|
||||
.footer-receive { background: #dbeafe; color: #1d4ed8; }
|
||||
.footer-reject { background: #fce4ec; color: #dc2626; }
|
||||
.footer-end { background: #fef3c7; color: #b45309; }
|
||||
.footer-spawn { background: #ede9fe; color: #7c3aed; }
|
||||
.footer-recall { background: #fef2f2; color: #dc2626; }
|
||||
</style>
|
||||
|
||||
@ -124,13 +124,14 @@
|
||||
<view class="user-grid">
|
||||
<view v-for="u in userGridOptions" :key="u.id"
|
||||
:class="['user-grid-item', transferForm.selectedUserId === u.id ? 'user-grid-active' : '']"
|
||||
@tap="transferForm.selectedUserId = u.id">{{ formatName(u.name) }}</view>
|
||||
@tap="selectTransferUser(u.id)">{{ formatName(u.name) }}</view>
|
||||
</view>
|
||||
<view class="field-label" style="margin-top:12px;">或</view>
|
||||
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="transferForm.isWarehouse = !transferForm.isWarehouse; transferForm.selectedUserId = '';">📦 入库 (virtual_warehouse)</view>
|
||||
<input v-model="transferForm.note" class="popup-input" placeholder="交接备注(选填)" style="margin-top:12px;" />
|
||||
<view :class="['user-grid-item', transferForm.isWarehouse ? 'user-grid-active' : '']" style="width:100%;" @tap="toggleWarehouse">📦 入库 (virtual_warehouse)</view>
|
||||
<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)" @tap="doTransfer">{{ actionLoading ? '提交中...' : (transferForm.isWarehouse ? '📦 确认入库' : '确认转交') }}</button></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>
|
||||
</template>
|
||||
<template v-if="actionPopup.type === 'spawn'">
|
||||
<text class="popup-title">➕ 派发协助分支</text>
|
||||
@ -141,8 +142,9 @@
|
||||
:class="['user-grid-item', spawnForm.assignee_id === u.id ? 'user-grid-active' : '']"
|
||||
@tap="spawnForm.assignee_id = u.id">{{ formatName(u.name) }}</view>
|
||||
</view>
|
||||
<input v-model="spawnForm.remark" class="popup-input" placeholder="派发备注(选填)" style="margin-top:12px;" />
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary btn-spawn" :disabled="actionLoading || !spawnForm.assignee_id" @tap="doSpawn">{{ actionLoading ? '提交中...' : '确认派发' }}</button></view>
|
||||
<view class="field-label" style="margin-top:12px;">派发备注 <text class="required">*</text></view>
|
||||
<textarea v-model="spawnForm.remark" class="popup-textarea" placeholder="请填写派发备注说明(必填)" :maxlength="500" />
|
||||
<view class="popup-btns"><button class="btn-cancel" @tap="closeActionPopup">取消</button><button class="btn-primary btn-spawn" :disabled="actionLoading || !spawnForm.assignee_id || !spawnForm.remark.trim()" @tap="doSpawn">{{ actionLoading ? '提交中...' : '确认派发' }}</button></view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
@ -156,7 +158,7 @@ import TreeCanvas from "./components/TreeCanvas.vue";
|
||||
|
||||
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||
|
||||
export default {
|
||||
components: { WorkspaceArea, TreeCanvas },
|
||||
@ -198,7 +200,7 @@ export default {
|
||||
openEditProduct() { this.editForm = { order_no: this.product.order_no || "", external_serial: this.product.external_serial || "" }; this.editProductVisible = true; },
|
||||
async doEditProduct() { this.editSaving = true; try { this.product = await patch(`/products/${this.product.id}`, { order_no: this.editForm.order_no.trim(), external_serial: this.editForm.external_serial.trim() }); uni.showToast({ title: "已保存", icon: "success" }); this.editProductVisible = false; } catch {} finally { this.editSaving = false; } },
|
||||
|
||||
async loadUsers() { try { this.users = await get("/users/", { limit: 200 }); this.userOptions = (this.users || []).map(u => ({ id: u.username || u.id, name: u.full_name || u.username })); } catch {} },
|
||||
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 })); } catch {} },
|
||||
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 || ""; } } catch {} },
|
||||
|
||||
onTaskNameChange(e) { const idx = e.detail.value; this.firstForm.taskNameIdx = idx; this.firstForm.task_name = TASK_NAME_OPTIONS[idx]; },
|
||||
@ -221,6 +223,7 @@ export default {
|
||||
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(); } }
|
||||
this.actionPopup = { visible: true, type, task }; this.rejectReason = ""; this.receiveRemark = ""; this.receiveTaskName = "";
|
||||
this.transferForm = { selectedUserId: "", isWarehouse: false, note: "" };
|
||||
@ -229,11 +232,15 @@ export default {
|
||||
handleViewRecords(task) { uni.navigateTo({ url: `/pages/scan/records?taskId=${task.id}` }); },
|
||||
async doDeleteRecord(record) { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||||
confirmEndBranch(task) { uni.showModal({ title: "结束分支", content: `确定结束「${task.task_name}」吗?`, success: (res) => { if (res.confirm) this.doEndBranch(task); } }); },
|
||||
confirmRecall(task) { uni.showModal({ title: "撤回转交", content: `确定撤回「${task.task_name}」吗?撤回后任务将回到您的手中。`, success: (res) => { if (res.confirm) this.doRecall(task); } }); },
|
||||
async doRecall(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/recall?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "已撤回", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||||
async doEndBranch(task) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/end?operator_id=${encodeURIComponent(opId)}`); uni.showToast({ title: "分支已结束", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
|
||||
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
|
||||
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; } },
|
||||
// 转交(单线)
|
||||
// 转交 — 互斥选择
|
||||
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; } },
|
||||
// 派发协助分支
|
||||
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; } },
|
||||
|
||||
@ -20,7 +20,10 @@
|
||||
</view>
|
||||
<view v-for="task in filtered" :key="task.id" class="card" @tap="goDetail(task)">
|
||||
<view class="card-row">
|
||||
<text class="card-name">{{ task.task_name }}</text>
|
||||
<view style="display:flex;align-items:center;gap:6px;min-width:0;">
|
||||
<text :class="['tag-badge', task.task_type === 'SPAWN' ? 'tag-sub' : 'tag-main']">{{ task.task_type === 'SPAWN' ? '协助' : '主干' }}</text>
|
||||
<text class="card-name">{{ task.task_name }}</text>
|
||||
</view>
|
||||
<text :class="['card-status', statusColor(task.status)]">{{ statusLabel(task.status) }}</text>
|
||||
</view>
|
||||
<view class="card-meta">
|
||||
@ -65,7 +68,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
statusLabel(s) {
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
|
||||
const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" };
|
||||
return map[s] || s;
|
||||
},
|
||||
statusColor(s) {
|
||||
@ -97,7 +100,13 @@ export default {
|
||||
try {
|
||||
const username = this.currentUser?.username || "";
|
||||
const res = await get("/tasks/", { assignee_id: username, limit: 100 });
|
||||
this.tasks = res.tasks || [];
|
||||
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);
|
||||
});
|
||||
} catch { this.tasks = []; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
@ -135,7 +144,10 @@ export default {
|
||||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||||
.card-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.card-name { font-size: 15px; font-weight: 700; color: #1f2937; }
|
||||
.card-name { font-size: 15px; font-weight: 700; color: #1f2937; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tag-badge { display: inline-block; padding: 2rpx 12rpx; font-size: 20rpx; border-radius: 6rpx; font-weight: bold; flex-shrink: 0; }
|
||||
.tag-main { background-color: #dbeafe; color: #1e40af; }
|
||||
.tag-sub { background-color: #f3e8ff; color: #6b21a8; }
|
||||
.card-status { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
|
||||
.s-yellow { background: #fef3c7; color: #b45309; }
|
||||
.s-blue { background: #dbeafe; color: #1d4ed8; }
|
||||
|
||||
Reference in New Issue
Block a user