Compare commits

...

32 Commits

Author SHA1 Message Date
6a79e21302 闭环修复: transfer/spawn/receive补TaskRecord + reject加task_type + branchLabel只对SPAWN编号 2026-08-06 13:14:26 +08:00
661e77416a 三雷修复: canRecall父节点校验+task_type判定主干排序/标签+确认无db.delete 2026-08-06 13:09:10 +08:00
0d323e8181 血缘锚定法: task_type字段+迁移+三大接口写入+TreeCanvas基于task_type布局 2026-08-06 13:03:25 +08:00
e5e2a395e7 撤回链式接力: CANCELED后生成WIP子任务还给操作人+TreeCanvas CANCELED视为串行换行 2026-08-06 12:53:23 +08:00
616638a6c8 撤回改为CANCELED状态标记+TaskRecord留痕+TreeCanvas灰度渲染+全部状态映射更新 2026-08-06 12:51:40 +08:00
eb97c12bed 修复撤回: 移除parent_task_id空校验+兼容无父任务场景(仅删除) 2026-08-06 12:39:47 +08:00
58cb9a23ef 修复撤回: transfer新任务parent_task_id=task.id(溯源链) + canRecall放宽(兼容旧数据) 2026-08-06 12:38:26 +08:00
0287003e93 canRecall加固: PENDING+非接收人+父任务负责人匹配→显示撤回转交 2026-08-06 12:08:23 +08:00
df42250725 排序铁律: 主分支强制置顶(parent_task_id为空优先)+子分支按created_at正序 2026-08-06 12:06:38 +08:00
7c7b72cadd 我的任务列表加分支标签: 主分支(蓝)/协助分支(紫) — parent_task_id判血缘 2026-08-06 11:53:40 +08:00
aad26b3d9e 2D布局回退状态判定: 父WIP→并行同Y / 父COMPLETED→串行换行(后端已限制COMPLETED后不可spawn) 2026-08-06 11:50:10 +08:00
c92e7bd931 修复2D布局: 时间戳判定串行/并行 — created_at>parent.completed_at才是串行,否则并行同Y 2026-08-06 11:45:56 +08:00
8f0f725e26 修复WBS编码: 分支1→分支1.1层级正确(prefix空字符串处理) 2026-08-06 11:42:52 +08:00
67755c7847 修复撤回500: 先删task_logs+task_records再删task,加delete import 2026-08-06 11:41:45 +08:00
57884d7baa 撤回转交: recall接口删除子任务恢复父WIP + 前端canRecall判断 + 红色撤回按钮 2026-08-06 11:39:22 +08:00
8be06bcd43 权限隔离+2D蓝图修复: isAssignee按钮显隐+只读提示+分支标签(badge-main/sub)+并行同Y确认 2026-08-06 11:36:06 +08:00
b2cfa3871e 取消转交的协助分支阻塞: transfer_task移除_check_children_done,仅end_task保留 2026-08-06 11:29:58 +08:00
e5bcde667b 转交弹窗互斥+备注必填: 选人清入库/选库清人 + textarea必填校验 2026-08-06 11:23:31 +08:00
ef9e5bbae5 修复get_all_tasks: 改用_to_flat_response(不递归children)彻底消除MissingGreenlet 2026-08-06 11:19:19 +08:00
9846dd6c8f 修复: MissingGreenlet_product访问try/catch + spawn_subtask不再覆盖overall_status 2026-08-06 11:16:07 +08:00
3176bcde6f 标签float环绕布局: QR左浮+文字自然折行+clear:both防序列号乱入 2026-08-06 11:04:03 +08:00
49c8707955 标签防御: height:fit-content+maxHeight物理切断+12px小字号防重叠 2026-08-06 11:02:16 +08:00
5f7b8e9394 标签预览彻底改为内联样式Flex: QR锁110px+line-clamp截断+无class依赖防重叠 2026-08-06 10:56:29 +08:00
1ba44d4b0a 打印所见即所得: iframe克隆DOM+全局样式+静默打印 2026-08-06 10:52:29 +08:00
81e41bc38b 标签预览重构: 固定QR120px+Flex分栏+line-clamp3截断+CSS类名化 2026-08-06 10:51:28 +08:00
f4ff4b5ecb 标签打印防御性布局: Flex分栏+line-clamp截断+分割线+预览同款 2026-08-06 10:44:22 +08:00
bdc861cf8e Web端创建产品成功弹窗: 打开二维码→直接打印标签(window.print+隐式窗口) 2026-08-06 10:37:08 +08:00
bcf1038f8a 修复: 删除2D连线+spawn确认parent_task_id已正确+WBS算法重写(递归前缀) 2026-08-06 10:28:21 +08:00
11f9f64dc6 并发协作重构: focusTasks全视野+WBS分支编码+TreeCanvas并发同层布局(串行Y+1/并行同Y) 2026-08-06 10:21:15 +08:00
ff3de80c96 派发协助备注改为必填(textarea+disabled校验) 2026-08-06 10:08:56 +08:00
999f8c2f3e IRIS过滤容错: 后端SQL失败时降级全量+前端严格过滤department===IRIS 2026-08-06 10:08:18 +08:00
eccf79b8d1 IRIS部门过滤: 后端users接口按department=IRIS过滤 + 前端双重兜底 2026-08-06 10:07:47 +08:00
12 changed files with 429 additions and 141 deletions

View 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")

View File

@ -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 → 不改变状态,创建子任务)
# ============================================================

View File

@ -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
]

View File

@ -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="任务初始描述/交接备注",
)

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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>
) : (

View File

@ -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; }

View File

@ -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>

View File

@ -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; } },

View File

@ -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; }