组件化拆分: TreeCanvas(2D画布) + WorkspaceArea(任务列表+锁定工作区) + detail瘦身(仅弹窗)

This commit is contained in:
2026-08-05 17:25:15 +08:00
parent 4c4ed7234d
commit 99eab80a1c
3 changed files with 322 additions and 221 deletions

View File

@ -0,0 +1,101 @@
<template>
<view class="tree-area">
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转任务</text>
</view>
<template v-else>
<view class="canvas-hint">🖐 2D 蓝图画布支持随意拖拽探索分支走向</view>
<movable-area class="tree-movable-area">
<movable-view class="tree-movable-view" direction="all" :x="0" :y="0"
:scale="true" :scale-min="0.5" :scale-max="2"
:style="{ width: Math.max(canvasWidth, 1200) + 'px', height: Math.max(canvasHeight, 1200) + '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)"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@tap="node.records && node.records.length && $emit('viewRecords', node)">
<view class="cn-header">
<text class="cn-name">{{ node.task_name }}</text>
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
<view class="cn-footer">
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止</text>
<text v-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库</text>
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}记录 </text>
</view>
</view>
</view>
</movable-view>
</movable-area>
</template>
</view>
</template>
<script>
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
export default {
name: "TreeCanvas",
props: { product: { type: Object, default: null } },
emits: ["viewRecords"],
computed: {
treeNodes() {
const nodes = []; const CARD_W = 200, CARD_H = 140, GAP_X = 60, GAP_Y = 60;
const layout = (tasks, depth, startX) => {
if (!tasks || !tasks.length) return startX;
const y = 20 + depth * (CARD_H + GAP_Y); let x = startX; const childXs = [];
for (const t of tasks) {
nodes.push({ ...t, x, y }); const childStartX = x;
if (t.child_tasks && t.child_tasks.length) { x = layout(t.child_tasks, depth + 1, x); } else { x += CARD_W + GAP_X; }
childXs.push({ id: t.id, cx: childStartX + CARD_W / 2, cy: y + CARD_H, children: t.child_tasks || [] });
}
nodes._lines = nodes._lines || [];
for (const cx of childXs) { if (cx.children.length) { const midX = cx.cx; for (const c of cx.children) { const childNode = nodes.find(n => n.id === c.id); if (childNode) { nodes._lines.push({ key: cx.id + '-' + c.id, x1: midX, y1: cx.cy, x2: childNode.x + CARD_W / 2, y2: childNode.y }); } } } }
return x;
};
if (this.product && this.product.task_tree) { nodes._lines = []; layout(this.product.task_tree, 0, 40); }
return nodes;
},
treeLines() { return this.treeNodes._lines || []; },
canvasWidth() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.x)) + 300); },
canvasHeight() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.y)) + 300); },
},
methods: {
statusLabel(s) { return STATUS_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"; } },
getTaskRemark(t) { if (!t) return ""; if (t.remark) return t.remark; if (t.records && t.records.length > 0) { const recs = [...t.records].reverse(); const rec = recs.find(r => r.remark && r.remark.length > 0); if (rec) return rec.remark; } return ""; },
lineStyle(line) { const dx = line.x2 - line.x1; const dy = line.y2 - line.y1; const len = Math.sqrt(dx * dx + dy * dy); 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' }; },
},
};
</script>
<style scoped>
.tree-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; margin-top: 4px; }
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 4px 0; flex-shrink: 0; }
.tree-movable-area { flex: 1; width: 100%; background-color: #ebedf0; border-radius: 12px; overflow: hidden; box-shadow: inset 0 0 20px rgba(0,0,0,0.05); }
.canvas-inner { position: absolute; left: 0; top: 0; }
.canvas-node { position: absolute; width: 180px; min-height: 100px; background: #ffffff; border-radius: 12px; padding: 14px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; border-top: none; z-index: 10; display: flex; flex-direction: column; gap: 6px; }
.canvas-node.s-yellow { border-left-color: #f59e0b; }
.canvas-node.s-blue { border-left-color: #3b82f6; }
.canvas-node.s-green { border-left-color: #22c55e; }
.canvas-node.s-red { border-left-color: #ef4444; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.cn-name { font-size: 14px; font-weight: 800; color: #1f2937; }
.cn-badge { font-size: 10px; padding: 2px 6px; border-radius: 8px; font-weight: 600; }
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
.cn-assignee { font-size: 11px; color: #6b7280; }
.cn-remark { font-size: 11px; color: #a16207; background: #fefce8; padding: 4px 8px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; border-top: 1px solid #f3f4f6; }
.cn-end { font-size: 10px; font-weight: 600; color: #16a34a; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 11px; color: #2563eb; font-weight: 600; }
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; border-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
.zero-icon { font-size: 40px; margin-bottom: 8px; }
.zero-text { font-size: 14px; color: #9ca3af; }
</style>

View File

@ -0,0 +1,192 @@
<template>
<view class="workspace-area">
<!-- 0任务 -->
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转任务</text>
</view>
<!-- 无本人任务 -->
<view v-else-if="!focusTasks.length" class="zero-task">
<text class="zero-icon">🔒</text>
<text class="zero-text">当前没有需要你处理的任务</text>
<text class="task-count"> {{ countTasks(product.task_tree) }} 个任务</text>
</view>
<!-- 任务列表未锁定 -->
<template v-else-if="!lockedTaskId">
<view class="list-header">
<text class="list-title">📋 待处理任务 ({{ focusTasks.length }})</text>
<text class="list-hint">点击任务进入绝对锁定工作区</text>
</view>
<view class="task-list">
<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="tli-name">{{ t.task_name }}</text>
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
</view>
<view class="tli-right">
<text :class="['tli-badge', statusColor(t.status)]">{{ statusLabel(t.status) }}</text>
<text v-if="t.is_rework" class="tag tag-rework-sm" style="margin-top:2px;">返工</text>
<text class="tli-arrow"></text>
</view>
</view>
</view>
</template>
<!-- 锁定工作区单任务详情 -->
<template v-else>
<view class="lock-bar">
<text class="lock-back" @tap="lockedTaskId = null"> 返回列表</text>
<text class="lock-title">🔒 沉浸工作区</text>
</view>
<view class="focus-card" :class="statusColor(lockedTask.status)">
<view class="fc-header">
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">返工</text>
</view>
<text class="fc-name">{{ lockedTask.task_name }}</text>
<view v-if="getTaskRemark(lockedTask)" style="margin-top:20rpx;padding:20rpx;background:#FFFBE8;border-left:8rpx solid #FADB14;border-radius:12rpx;">
<text style="font-size:26rpx;color:#8C6A00;font-weight:bold;">📌 初始/交接备注</text>
<view style="font-size:28rpx;color:#333;margin-top:10rpx;">{{ getTaskRemark(lockedTask) }}</view>
</view>
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
<text class="fc-link-label"> 上游工序</text>
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} {{ parentTaskOf(lockedTask).assignee_id || '—' }}</text>
</view>
<view v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down">
<text class="fc-link-label"> 下游分支 ({{ lockedTask.child_tasks.length }})</text>
<text v-for="c in lockedTask.child_tasks" :key="c.id" class="fc-link-name">
· {{ c.task_name }} {{ c.assignee_id || '' }}
<text v-if="c.status==='COMPLETED'" class="branch-done">已完成</text>
<text v-else-if="c.status==='ARCHIVED'" class="branch-done">📦已入库</text>
</text>
</view>
<view v-if="lockedTask.records && lockedTask.records.length" class="fc-records-bar" @tap="$emit('viewRecords', lockedTask)">
📋 {{ lockedTask.records.length }} 条干活记录
</view>
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</view>
</view>
<view class="footer-actions">
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer"
@tap="$emit('action', { task: lockedTask, type: 'transfer' })">🔄 完工转交</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record"
@tap="$emit('action', { task: lockedTask, type: 'record' })">📝 记录/拍照</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-end"
@tap="$emit('action', { task: lockedTask, type: 'end' })">🏁 结束分支</button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive"
@tap="$emit('action', { task: lockedTask, type: 'receive' })"> 接收任务</button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-reject"
@tap="$emit('action', { task: lockedTask, type: 'reject' })"> 驳回任务</button>
</view>
</template>
</view>
</template>
<script>
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
export default {
name: "WorkspaceArea",
props: {
product: { type: Object, default: null },
currentUserId: { type: String, default: "" },
currentUsername: { type: String, default: "" },
},
emits: ["action", "viewRecords"],
data() {
return { lockedTaskId: null };
},
computed: {
taskMap() {
const m = {};
const walk = (tasks) => { if (!tasks) return; for (const t of tasks) { m[t.id] = t; walk(t.child_tasks); } };
if (this.product) walk(this.product.task_tree);
return m;
},
focusTasks() {
const result = [];
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);
walk(t.child_tasks);
}
};
if (this.product) walk(this.product.task_tree);
return result;
},
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
},
methods: {
statusLabel(s) { return STATUS_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"; } },
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
getTaskRemark(t) { if (!t) return ""; if (t.remark) return t.remark; if (t.records && t.records.length > 0) { const recs = [...t.records].reverse(); const rec = recs.find(r => r.remark && r.remark.length > 0); if (rec) return rec.remark; } return ""; },
lockTask(taskId) { this.lockedTaskId = taskId; },
parentTaskOf(t) { return t && t.parent_task_id ? (this.taskMap[t.parent_task_id] || null) : null; },
formatTaskTime(t) { if (!t) return ""; const parts = []; if (t.created_at) parts.push("创建: " + this.formatTime(t.created_at)); if (t.received_at) parts.push("接收: " + this.formatTime(t.received_at)); return parts.join(" | "); },
formatTime(d) { if (!d) return ""; const dt = new Date(d); const pad = (n) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
},
};
</script>
<style scoped>
.workspace-area { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.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; }
.task-list { flex: 1; overflow-y: auto; min-height: 0; }
.task-list-item { display: flex; align-items: flex-start; justify-content: space-between; background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid transparent; }
.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; }
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
.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; }
.tli-badge.s-yellow { background: #fef3c7; color: #b45309; }
.tli-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.tli-arrow { font-size: 18px; color: #d1d5db; }
.lock-bar { display: flex; align-items: center; gap: 12px; padding: 8px 0; flex-shrink: 0; }
.lock-back { font-size: 13px; color: #2563eb; font-weight: 600; }
.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 { height: 100%; margin: 0 4px; padding: 20px 16px; border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto; 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; }
.focus-card.s-red { border-top-color: #ef4444; }
.fc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.fc-status { font-size: 12px; padding: 2px 10px; border-radius: 12px; font-weight: 700; background: #f3f4f6; color: #6b7280; }
.s-yellow .fc-status { background: #fef3c7; color: #b45309; }
.s-blue .fc-status { background: #dbeafe; color: #1d4ed8; }
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: #ef4444; color: #fff; }
.fc-name { font-size: 22px; font-weight: 800; color: #1f2937; display: block; margin-bottom: 10px; }
.fc-link { padding: 6px 10px; border-radius: 8px; margin-bottom: 4px; font-size: 12px; margin-top: 10px; }
.fc-up { background: #f0fdf4; color: #16a34a; }
.fc-down { background: #eff6ff; color: #2563eb; }
.fc-link-label { font-weight: 700; display: block; }
.fc-link-name { display: block; margin-top: 2px; }
.fc-records-bar { padding: 8px 12px; background: linear-gradient(135deg,#eff6ff,#dbeafe); border-radius: 8px; font-size: 13px; font-weight: 700; color: #2563eb; margin-top: 10px; margin-bottom: 8px; }
.fc-time { font-size: 11px; color: #9ca3af; margin-top: auto; padding-top: 8px; border-top: 1px solid #f3f4f6; }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
.zero-icon { font-size: 40px; margin-bottom: 8px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.footer-actions { display: flex; gap: 20rpx; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); flex-shrink: 0; }
.footer-btn { flex: 1; height: 88rpx; border: none; border-radius: 12rpx; font-size: 30rpx; font-weight: 700; line-height: 88rpx; }
.footer-btn::after { border: none; }
.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; }
</style>

View File

@ -6,9 +6,7 @@
<template v-if="product && !loading">
<view class="overall-bar" @tap="showStatusPicker = true">
<text class="overall-label">宏观状态</text>
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">
{{ product.overall_status || '点击设定' }}
</text>
<text :class="['overall-val', product.overall_status ? '' : 'overall-empty']">{{ product.overall_status || '点击设定' }}</text>
<text class="overall-arrow"></text>
</view>
@ -17,9 +15,7 @@
<text class="card-title">📦 产品信息</text>
<view class="card-header-right">
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
<text class="mode-toggle" @tap="toggleMode">
{{ currentMode === 'workspace' ? '🌳 流转树' : '🛠 工作区' }}
</text>
<text class="mode-toggle" @tap="toggleMode">{{ currentMode === 'workspace' ? '🌳 流转树' : '🛠 工作区' }}</text>
<text class="edit-btn" @tap="openEditProduct"></text>
</view>
</view>
@ -30,116 +26,16 @@
<view class="info-item"><text class="label">订单编号</text><text class="value">{{ product.order_no || '—' }}</text></view>
<view class="info-item" v-if="product.current_location_id">
<text class="label">当前位置</text>
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">
{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}
</text>
<text :class="['value', product.current_location_id === 'virtual_warehouse' ? 'warehouse' : '']">{{ product.current_location_id === 'virtual_warehouse' ? '🏭 仓库' : product.current_location_id }}</text>
</view>
</view>
</view>
<!-- 🛠 个人工作区 -->
<view v-if="currentMode === 'workspace'" class="workspace-area">
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转任务</text>
<button class="btn-start" @tap="openCreateFirstTask">🚀 发起首道工序</button>
</view>
<view v-else-if="!focusTasks.length" class="zero-task">
<text class="zero-icon">🔒</text><text class="zero-text">当前没有需要你处理的任务</text>
<text class="task-count"> {{ countTasks(product.task_tree) }} 个任务</text>
</view>
<template v-else-if="!lockedTaskId">
<view class="list-header">
<text class="list-title">📋 待处理任务 ({{ focusTasks.length }})</text>
<text class="list-hint">点击任务进入绝对锁定工作区</text>
</view>
<view class="task-list">
<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="tli-name">{{ t.task_name }}</text>
<text v-if="getTaskRemark(t)" class="tli-remark">{{ getTaskRemark(t) }}</text>
</view>
<view class="tli-right">
<text :class="['tli-badge', statusColor(t.status)]">{{ statusLabel(t.status) }}</text>
<text v-if="t.is_rework" class="tag tag-rework-sm" style="margin-top:2px;">返工</text>
<text class="tli-arrow"></text>
</view>
</view>
</view>
</template>
<template v-else>
<view class="lock-bar">
<text class="lock-back" @tap="lockedTaskId = null"> 返回列表</text>
<text class="lock-title">🔒 沉浸工作区</text>
</view>
<view class="focus-card" :class="statusColor(lockedTask.status)">
<view class="fc-header">
<text class="fc-status">{{ statusLabel(lockedTask.status) }}</text>
<text v-if="lockedTask.is_rework" class="tag tag-rework-sm">返工</text>
</view>
<text class="fc-name">{{ lockedTask.task_name }}</text>
<view v-if="getTaskRemark(lockedTask)" style="margin-top:20rpx;padding:20rpx;background:#FFFBE8;border-left:8rpx solid #FADB14;border-radius:12rpx;">
<text style="font-size:26rpx;color:#8C6A00;font-weight:bold;">📌 初始/交接备注</text>
<view style="font-size:28rpx;color:#333;margin-top:10rpx;">{{ getTaskRemark(lockedTask) }}</view>
</view>
<view v-if="lockedTask.parent_task_id && parentTaskOf(lockedTask)" class="fc-link fc-up">
<text class="fc-link-label"> 上游工序</text>
<text class="fc-link-name">{{ parentTaskOf(lockedTask).task_name }} {{ parentTaskOf(lockedTask).assignee_id || '—' }}</text>
</view>
<view v-if="lockedTask.child_tasks && lockedTask.child_tasks.length" class="fc-link fc-down">
<text class="fc-link-label"> 下游分支 ({{ lockedTask.child_tasks.length }})</text>
<text v-for="c in lockedTask.child_tasks" :key="c.id" class="fc-link-name">
· {{ c.task_name }} {{ c.assignee_id || '' }}
<text v-if="c.status==='COMPLETED'" class="branch-done">已完成</text>
<text v-else-if="c.status==='ARCHIVED'" class="branch-done">📦已入库</text>
</text>
</view>
<view v-if="lockedTask.records && lockedTask.records.length" class="fc-records-bar" @tap="handleViewRecords(lockedTask)">
📋 {{ lockedTask.records.length }} 条干活记录
</view>
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</view>
</view>
<view class="footer-actions">
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer" @tap="handleTaskAction({ task: lockedTask, type: 'transfer' })">🔄 完工转交</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-record" @tap="handleTaskAction({ task: lockedTask, type: 'record' })">📝 记录/拍照</button>
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-end" @tap="handleTaskAction({ task: lockedTask, type: 'end' })">🏁 结束分支</button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-receive" @tap="handleTaskAction({ task: lockedTask, type: 'receive' })"> 接收任务</button>
<button v-if="lockedTask.status === 'PENDING'" class="footer-btn footer-reject" @tap="handleTaskAction({ task: lockedTask, type: 'reject' })"> 驳回任务</button>
</view>
</template>
</view>
<WorkspaceArea v-if="currentMode === 'workspace'" :product="product"
:currentUserId="currentUserId" :currentUsername="currentUsername"
@action="handleTaskAction" @viewRecords="handleViewRecords" />
<!-- 🌳 全局流转树 (2D movable-area 画布) -->
<view v-if="currentMode === 'tree'" class="tree-area">
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转任务</text>
</view>
<template v-else>
<view class="canvas-hint">🖐 2D 蓝图画布支持随意拖拽探索分支走向</view>
<movable-area class="tree-movable-area">
<movable-view class="tree-movable-view" direction="all" :x="0" :y="0" :scale="true" :scale-min="0.5" :scale-max="2"
:style="{ width: Math.max(canvasWidth, 1200) + 'px', height: Math.max(canvasHeight, 1200) + '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)"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@tap="node.records && node.records.length && handleViewRecords(node)">
<view class="cn-header">
<text class="cn-name">{{ node.task_name }}</text>
<text :class="['cn-badge', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
<view class="cn-footer">
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止</text>
<text v-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库</text>
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}记录 </text>
</view>
</view>
</view>
</movable-view>
</movable-area>
</template>
</view>
<TreeCanvas v-if="currentMode === 'tree'" :product="product" @viewRecords="handleViewRecords" />
</template>
<!-- 状态定调 -->
@ -187,7 +83,7 @@
<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>
<view class="popup-btns"><button class="btn-cancel" @tap="closeRecordPopup">取消</button><button class="btn-primary" :disabled="recordSaving || isUploading" @tap="doSaveRecord">{{ isUploading ? `上传中` : (recordSaving ? '保存中...' : (recordForm.recordId ? '更新记录' : '保存记录')) }}</button></view>
<view class="popup-btns"><button class="btn-cancel" @tap="closeRecordPopup">取消</button><button class="btn-primary" :disabled="recordSaving || isUploading" @tap="doSaveRecord">{{ isUploading ? '上传中' : (recordSaving ? '保存中...' : (recordForm.recordId ? '更新记录' : '保存记录')) }}</button></view>
</view>
</view>
<!-- 任务操作 -->
@ -226,12 +122,15 @@
<script>
import request, { get, post, patch, put } from "../../utils/request";
import WorkspaceArea from "./components/WorkspaceArea.vue";
import TreeCanvas from "./components/TreeCanvas.vue";
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const STATUS_MAP = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库" };
export default {
components: { WorkspaceArea, TreeCanvas },
data() {
return {
OVERALL_OPTIONS, loading: true, error: "", product: null,
@ -239,7 +138,7 @@ export default {
users: [], TASK_NAME_OPTIONS,
createFirstVisible: false, firstForm: { task_name: "", taskNameIdx: 0, assignee_id: "", assigneeLabel: "", assigneeIdx: 0, note: "", autoReceive: true }, firstSaving: false,
recordPopup: { visible: false, task: null }, recordForm: { recordId: null, remark: "", images: [], pendingCount: 0 }, recordSaving: false, isUploading: false,
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree", lockedTaskId: null,
currentUser: null, currentUserId: "", currentUsername: "", currentMode: "tree",
processOptions: [], userOptions: [],
actionPopup: { visible: false, type: "", task: null }, actionLoading: false, rejectReason: "", receiveRemark: "",
transferForm: { branches: [{ task_name: "", assignee_id: "" }], note: "" },
@ -249,54 +148,31 @@ 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; return this.currentUser.username === this.recordPopup.task.assignee_id; },
canSubmitTransfer() { return this.transferForm.branches.every(b => { if (!b.task_name) return false; if (b.task_name === "🏭 入库 (virtual_warehouse)") return true; return !!b.assignee_id; }); },
taskMap() { const m = {}; const walk = (tasks) => { if (!tasks) return; for (const t of tasks) { m[t.id] = t; walk(t.child_tasks); } }; if (this.product) walk(this.product.task_tree); return m; },
focusTasks() { const result = []; const walk = (tasks) => { if (!tasks) return; for (const t of tasks) { const isMine = t.status === 'WIP' || t.status === 'PENDING'; const matchId = t.assignee_id == this.currentUserId; const matchName = t.assignee_id == this.currentUsername; if (isMine && (matchId || matchName)) result.push(t); walk(t.child_tasks); } }; if (this.product) walk(this.product.task_tree); return result; },
lockedTask() { return this.focusTasks.find(t => t.id === this.lockedTaskId) || null; },
hasMyActiveTask() { return this.focusTasks.length > 0; },
treeNodes() {
const nodes = []; const CARD_W = 200, CARD_H = 140, GAP_X = 60, GAP_Y = 60;
const layout = (tasks, depth, startX) => {
if (!tasks || !tasks.length) return startX;
const y = 20 + depth * (CARD_H + GAP_Y); let x = startX; const childXs = [];
for (const t of tasks) {
nodes.push({ ...t, x, y }); const childStartX = x;
if (t.child_tasks && t.child_tasks.length) { x = layout(t.child_tasks, depth + 1, x); } else { x += CARD_W + GAP_X; }
childXs.push({ id: t.id, cx: childStartX + CARD_W / 2, cy: y + CARD_H, children: t.child_tasks || [] });
}
nodes._lines = nodes._lines || [];
for (const cx of childXs) { if (cx.children.length) { const midX = cx.cx; for (const c of cx.children) { const childNode = nodes.find(n => n.id === c.id); if (childNode) { nodes._lines.push({ key: cx.id + '-' + c.id, x1: midX, y1: cx.cy, x2: childNode.x + CARD_W / 2, y2: childNode.y }); } } } }
return x;
};
if (this.product && this.product.task_tree) { nodes._lines = []; layout(this.product.task_tree, 0, 40); }
return nodes;
hasMyActiveTask() {
const find = (tasks) => { if (!tasks) return false; for (const t of tasks) { if ((t.status === 'WIP' || t.status === 'PENDING') && (t.assignee_id == this.currentUserId || t.assignee_id == this.currentUsername)) return true; if (find(t.child_tasks)) return true; } return false; };
return this.product ? find(this.product.task_tree) : false;
},
treeLines() { return this.treeNodes._lines || []; },
canvasWidth() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.x)) + 300); },
canvasHeight() { const nodes = this.treeNodes; if (!nodes.length) return 400; return Math.max(400, Math.max(...nodes.map(n => n.y)) + 300); },
},
onLoad(options) { this.loadUsers(); this.loadCurrentUser(); const sn = options.serial || ""; if (sn) this.doQuery(sn); },
methods: {
statusLabel(s) { return STATUS_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"; } },
countTasks(tree) { return tree ? tree.reduce((s, t) => s + 1 + this.countTasks(t.child_tasks), 0) : 0; },
getTaskRemark(t) { if (!t) return ""; if (t.remark) return t.remark; if (t.records && t.records.length > 0) { const recs = [...t.records].reverse(); const rec = recs.find(r => r.remark && r.remark.length > 0); if (rec) return rec.remark; } return ""; },
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); if (!this.product.overall_status) this.showStatusPicker = true; this.$nextTick(() => { this.currentMode = this.hasMyActiveTask ? 'workspace' : 'tree'; this.lockedTaskId = null; }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
toggleMode() { this.currentMode = this.currentMode === 'workspace' ? 'tree' : 'workspace'; if (this.currentMode === 'workspace') this.lockedTaskId = null; },
lockTask(taskId) { this.lockedTaskId = taskId; },
lineStyle(line) { const dx = line.x2 - line.x1; const dy = line.y2 - line.y1; const len = Math.sqrt(dx * dx + dy * dy); 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' }; },
async doQuery(sn) { this.loading = true; this.error = ""; try { this.product = await get(`/products/scan/${sn}`); if (!this.product.overall_status) this.showStatusPicker = true; this.$nextTick(() => { this.currentMode = this.hasMyActiveTask ? 'workspace' : 'tree'; }); } catch (e) { this.error = e?.data?.detail || "查询失败"; } finally { this.loading = false; } },
toggleMode() { this.currentMode = this.currentMode === 'workspace' ? 'tree' : 'workspace'; },
async handleSetOverallStatus(status) { try { this.product = await patch(`/products/scan/${this.product.serial_number}/status`, { status }); uni.showToast({ title: `状态已更新: ${status}`, icon: "success" }); this.showStatusPicker = false; } catch {} },
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 {} },
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]; },
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.firstForm = { task_name: TASK_NAME_OPTIONS[0], taskNameIdx: 0, assignee_id: this.users.length > 0 ? this.users[0].username : "", assigneeLabel: this.users.length > 0 ? `${this.users[0].full_name} (${this.users[0].username})` : "", assigneeIdx: 0, note: "", autoReceive: true }; this.createFirstVisible = true; },
async doCreateFirstTask() { this.firstSaving = true; try { const task = await post("/tasks/", { product_id: this.product.id, task_name: this.firstForm.task_name, assignee_id: this.firstForm.assignee_id, notify_parent_on_complete: false, remark: this.firstForm.note.trim() || undefined }); if (this.firstForm.autoReceive) { try { const opId = this.currentUsername || this.currentUserId; await post(`/tasks/${task.id}/receive?operator_id=${encodeURIComponent(opId)}`, { remark: this.firstForm.note.trim() || undefined }); } catch {} } uni.showToast({ title: this.firstForm.autoReceive ? "已创建并接收" : "已创建", icon: "success" }); this.createFirstVisible = false; this.doQuery(this.product.serial_number); } catch {} finally { this.firstSaving = false; } },
async doDeleteRecord(record) { try { await request({ url: `/records/${record.id}`, method: "DELETE" }); uni.showToast({ title: "记录已删除", icon: "success" }); this.doQuery(this.product.serial_number); } catch {} },
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; },
closeRecordPopup() { this.recordPopup = { visible: false, task: null }; },
@ -305,13 +181,18 @@ export default {
removeRecordImage(i) { 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; } },
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 === "transfer") { 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.transferForm = { branches: [{ task_name: "", assignee_id: "" }], note: "" }; },
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 === "transfer") { 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.transferForm = { branches: [{ task_name: "", assignee_id: "" }], note: "" };
},
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); } }); },
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 {} },
parentTaskOf(t) { return t && t.parent_task_id ? (this.taskMap[t.parent_task_id] || null) : null; },
formatTaskTime(t) { if (!t) return ""; const parts = []; if (t.created_at) parts.push("创建: " + this.formatTime(t.created_at)); if (t.received_at) parts.push("接收: " + this.formatTime(t.received_at)); return parts.join(" | "); },
formatTime(d) { if (!d) return ""; const dt = new Date(d); const pad = (n) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; },
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 }); 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; } },
@ -336,67 +217,6 @@ export default {
.overall-empty { color: #ef4444; }
.overall-arrow { font-size: 12px; color: #9ca3af; }
.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; }
.workspace-area { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.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; }
.task-list { flex: 1; overflow-y: auto; min-height: 0; }
.task-list-item { display: flex; align-items: flex-start; justify-content: space-between; background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); border-left: 4px solid transparent; }
.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; }
.tli-name { font-size: 15px; font-weight: 700; color: #1f2937; display: block; }
.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; }
.tli-badge.s-yellow { background: #fef3c7; color: #b45309; }
.tli-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.tli-arrow { font-size: 18px; color: #d1d5db; }
.lock-bar { display: flex; align-items: center; gap: 12px; padding: 8px 0; flex-shrink: 0; }
.lock-back { font-size: 13px; color: #2563eb; font-weight: 600; }
.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 { height: 100%; margin: 0 4px; padding: 20px 16px; border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto; 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; }
.focus-card.s-red { border-top-color: #ef4444; }
.fc-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.fc-status { font-size: 12px; padding: 2px 10px; border-radius: 12px; font-weight: 700; background: #f3f4f6; color: #6b7280; }
.s-yellow .fc-status { background: #fef3c7; color: #b45309; }
.s-blue .fc-status { background: #dbeafe; color: #1d4ed8; }
.tag-rework-sm { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: #ef4444; color: #fff; }
.fc-name { font-size: 22px; font-weight: 800; color: #1f2937; display: block; margin-bottom: 10px; }
.fc-link { padding: 6px 10px; border-radius: 8px; margin-bottom: 4px; font-size: 12px; margin-top: 10px; }
.fc-up { background: #f0fdf4; color: #16a34a; }
.fc-down { background: #eff6ff; color: #2563eb; }
.fc-link-label { font-weight: 700; display: block; }
.fc-link-name { display: block; margin-top: 2px; }
.fc-records-bar { padding: 8px 12px; background: linear-gradient(135deg,#eff6ff,#dbeafe); border-radius: 8px; font-size: 13px; font-weight: 700; color: #2563eb; margin-top: 10px; margin-bottom: 8px; }
.fc-time { font-size: 11px; color: #9ca3af; margin-top: auto; padding-top: 8px; border-top: 1px solid #f3f4f6; }
.tree-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; margin-top: 4px; }
.canvas-hint { font-size: 11px; color: #9ca3af; text-align: center; padding: 4px 0; flex-shrink: 0; }
.tree-movable-area { flex: 1; width: 100%; background-color: #ebedf0; border-radius: 12px; overflow: hidden; box-shadow: inset 0 0 20px rgba(0,0,0,0.05); }
.canvas-inner { position: absolute; left: 0; top: 0; }
.canvas-node { position: absolute; width: 180px; min-height: 100px; background: #ffffff; border-radius: 12px; padding: 14px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; border-top: none; z-index: 10; display: flex; flex-direction: column; gap: 6px; }
.canvas-node.s-yellow { border-left-color: #f59e0b; }
.canvas-node.s-blue { border-left-color: #3b82f6; }
.canvas-node.s-green { border-left-color: #22c55e; }
.canvas-node.s-red { border-left-color: #ef4444; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.cn-name { font-size: 14px; font-weight: 800; color: #1f2937; }
.cn-badge { font-size: 10px; padding: 2px 6px; border-radius: 8px; font-weight: 600; }
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
.cn-assignee { font-size: 11px; color: #6b7280; }
.cn-remark { font-size: 11px; color: #a16207; background: #fefce8; padding: 4px 8px; border-radius: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; border-top: 1px solid #f3f4f6; }
.cn-end { font-size: 10px; font-weight: 600; color: #16a34a; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 11px; color: #2563eb; font-weight: 600; }
.canvas-line { position: absolute; height: 3px; background: #94a3b8; z-index: 1; border-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,0.1); }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.card-header-right { display: flex; align-items: center; gap: 8px; }
.card-title { font-size: 15px; font-weight: 700; }
@ -413,10 +233,6 @@ export default {
.s-green .badge, .s-green { color: #15803d; }
.s-red .badge, .s-red { color: #be123c; }
.s-gray .badge, .s-gray { color: #6b7280; }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
.zero-icon { font-size: 40px; margin-bottom: 8px; }
.zero-text { font-size: 14px; color: #9ca3af; margin-bottom: 16px; }
.btn-start { width: 220px; height: 44px; background: linear-gradient(135deg, #2563EB, #3B82F6); border: none; border-radius: 12px; color: #fff; font-size: 15px; font-weight: 700; line-height: 44px; box-shadow: 0 4px 12px rgba(37,99,235,0.3); }
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
.sheet { width: 100%; max-width: 480px; background: #fff; border-radius: 20px 20px 0 0; padding: 20px 16px 32px; }
.sheet-title { font-size: 17px; font-weight: 700; display: block; text-align: center; }
@ -456,14 +272,6 @@ export default {
.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; }
.btn-upload { width: 100%; height: 42px; border: 1px dashed #d1d5db; border-radius: 10px; background: #f9fafb; color: #6b7280; font-size: 14px; line-height: 42px; margin: 8px 0; }
.footer-actions { display: flex; gap: 20rpx; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); z-index: 99; flex-shrink: 0; }
.footer-btn { flex: 1; height: 88rpx; border: none; border-radius: 12rpx; font-size: 30rpx; font-weight: 700; line-height: 88rpx; }
.footer-btn::after { border: none; }
.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; }
.form-item { margin: 10px 0; }
.form-label { font-size: 14px; font-weight: 600; color: #374151; display: block; margin-bottom: 4px; }
.picker-value { display: flex; align-items: center; justify-content: space-between; width: 100%; height: 42px; padding: 0 12px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f9fafb; font-size: 14px; box-sizing: border-box; }