纳入 track-uniapp 前端项目 (扫码/任务树/记录/转交Picker)

This commit is contained in:
2026-08-05 15:01:46 +08:00
parent eaaeb38d0a
commit 5e53cd7f79
28 changed files with 12898 additions and 1 deletions

View File

@ -0,0 +1,767 @@
<template>
<view class="page">
<!-- ======== 加载/错误 ======== -->
<view v-if="loading" class="loading">加载中...</view>
<view v-if="error" class="error-box">{{ error }}</view>
<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-arrow">▾</text>
</view>
<!-- ================================================================ -->
<!-- 产品卡片 -->
<!-- ================================================================ -->
<view class="card">
<view class="card-header">
<text class="card-title">📦 产品信息</text>
<view class="card-header-right">
<text :class="['badge', statusColor(product.status)]">{{ statusLabel(product.status) }}</text>
<text class="edit-btn" @tap="openEditProduct">✏️</text>
</view>
</view>
<view class="info-grid">
<view class="info-item">
<text class="label">序列号</text>
<text class="value sn">{{ product.serial_number }}</text>
</view>
<view class="info-item">
<text class="label">物料名称</text>
<text class="value">{{ product.material_name || product.material_id || '—' }}</text>
</view>
<view class="info-item">
<text class="label">规格型号</text>
<text class="value">{{ product.spec_model || '—' }}</text>
</view>
<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>
</view>
</view>
</view>
<!-- ================================================================ -->
<!-- 任务树 -->
<!-- ================================================================ -->
<view class="card">
<view class="card-header">
<text class="card-title">🌿 任务流转树</text>
<text class="task-count">{{ countTasks(product.task_tree) }} 个任务</text>
</view>
<!-- 0任务 → 发起首道工序 -->
<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>
<!-- 递归树 -->
<TaskTreeNode
v-for="(task, idx) in (product.task_tree || [])"
:key="task.id"
:task="task"
:currentUser="currentUser"
:currentUserId="currentUserId"
:currentUsername="currentUsername"
:hasChildren="task.child_tasks && task.child_tasks.length > 0"
:isLast="idx === (product.task_tree || []).length - 1"
@action="handleTaskAction"
@editRecord="openEditRecord"
/>
</view>
</template>
<!-- ================================================================ -->
<!-- 弹窗层 -->
<!-- ================================================================ -->
<!-- 1. 状态定调 -->
<view v-if="showStatusPicker" class="overlay" @tap="() => {}">
<view class="sheet">
<text class="sheet-title">{{ product && product.overall_status ? '修改宏观状态' : '🔔 请设定产品宏观状态' }}</text>
<text class="sheet-hint">首次扫码,请选择一个状态以开启流转</text>
<view class="sheet-options">
<view v-for="opt in OVERALL_OPTIONS" :key="opt"
:class="['sheet-opt', product && product.overall_status === opt ? 'sheet-opt-active' : '']"
@tap="handleSetOverallStatus(opt)"><text>{{ opt }}</text></view>
</view>
<button v-if="product && product.overall_status" class="sheet-close" @tap="showStatusPicker = false">关闭</button>
</view>
</view>
<!-- 2. 编辑产品 -->
<view v-if="editProductVisible" class="overlay" @tap="editProductVisible = false">
<view class="popup" @tap.stop>
<text class="popup-title">编辑产品</text>
<input v-model="editForm.order_no" class="popup-input" placeholder="订单编号" />
<input v-model="editForm.external_serial" class="popup-input" placeholder="外部序列号" />
<view class="popup-btns">
<button class="btn-cancel" @tap="editProductVisible = false">取消</button>
<button class="btn-primary" :disabled="editSaving" @tap="doEditProduct">
{{ editSaving ? '保存中...' : '保存' }}
</button>
</view>
</view>
</view>
<!-- 3. 发起首道工序 -->
<view v-if="createFirstVisible" class="overlay" @tap="createFirstVisible = false">
<view class="popup" @tap.stop>
<text class="popup-title">🚀 发起首道工序</text>
<!-- 工序名称 Picker -->
<view class="field-label">工序名称 <text class="required">*</text></view>
<picker :range="TASK_NAME_OPTIONS" :value="firstForm.taskNameIdx" @change="onTaskNameChange">
<view class="picker-box">{{ firstForm.task_name || '请选择工序名称' }}</view>
</picker>
<!-- 接收人 Picker -->
<view class="field-label">接收人 <text class="required">*</text></view>
<picker :range="userLabels" :value="firstForm.assigneeIdx" @change="onAssigneeChange">
<view class="picker-box">{{ firstForm.assigneeLabel || '请选择接收人' }}</view>
</picker>
<!-- 备注(必填) -->
<view class="field-label">备注 <text class="required">*</text></view>
<textarea v-model="firstForm.note" class="popup-textarea" placeholder="请填写备注说明(必填)" :maxlength="500" />
<!-- 立即接收开关 -->
<label class="switch-row" @tap="firstForm.autoReceive = !firstForm.autoReceive">
<text class="switch-label">⚡ 立即接收并开始计时</text>
<switch :checked="firstForm.autoReceive" color="#2563EB" style="transform:scale(0.8)" />
</label>
<text class="switch-hint">{{ firstForm.autoReceive ? '创建后自动调用接收接口,任务直接变为 WIP 并记录开工时间' : '仅创建任务(PENDING),由工人自行接收' }}</text>
<view class="popup-btns">
<button class="btn-cancel" @tap="createFirstVisible = false">取消</button>
<button class="btn-primary" :disabled="firstSaving || !firstForm.task_name || !firstForm.assignee_id || !firstForm.note.trim()" @tap="doCreateFirstTask">
{{ firstSaving ? '创建中...' : (firstForm.autoReceive ? '创建并接收' : '确认创建') }}
</button>
</view>
</view>
</view>
<!-- 4. 记录/拍照 -->
<view v-if="recordPopup.visible" class="overlay" @tap="closeRecordPopup">
<view class="popup" @tap.stop>
<text class="popup-title">{{ recordForm.recordId ? '✏️ 编辑记录' : '📝 记录/拍照' }}</text>
<text class="popup-task">{{ recordPopup.task && recordPopup.task.task_name }}</text>
<textarea v-model="recordForm.remark" class="popup-textarea" placeholder="填写备注说明" :maxlength="2000" />
<!-- 图片九宫格预览 -->
<view class="img-grid">
<view v-for="(img, i) in recordForm.images" :key="i" class="img-cell">
<image :src="img" mode="aspectFill" class="img-thumb" @tap="previewRecordImage(i)" />
<text v-if="canDeleteImage" class="img-del" @tap.stop="removeRecordImage(i)">✕</text>
</view>
<!-- 上传中占位 -->
<view v-for="n in recordForm.pendingCount" :key="'p'+n" class="img-cell img-cell-loading">
<text class="img-loading-text">⏳</text>
</view>
</view>
<!-- 上传按钮 (未满9张时显示) -->
<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 ? `上传中 (${recordForm.images.length}/${recordForm.images.length + recordForm.pendingCount})` : (recordSaving ? '保存中...' : (recordForm.recordId ? '更新记录' : '保存记录')) }}
</button>
</view>
</view>
</view>
<!-- 5. 任务操作弹出 -->
<view v-if="actionPopup.visible" class="overlay" @tap="closeActionPopup">
<view class="popup" @tap.stop>
<!-- 接收 -->
<template v-if="actionPopup.type === 'receive'">
<text class="popup-title">确认接收任务</text>
<view class="popup-task">{{ actionPopup.task && actionPopup.task.task_name }}</view>
<text class="popup-hint">状态: {{ statusLabel(actionPopup.task && actionPopup.task.status) }} → 进行中</text>
<view class="popup-btns">
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
<button class="btn-primary" :disabled="actionLoading" @tap="doReceive">确认接收</button>
</view>
</template>
<!-- 驳回 -->
<template v-if="actionPopup.type === 'reject'">
<text class="popup-title">品质驳回</text>
<textarea v-model="rejectReason" class="popup-textarea" placeholder="请填写驳回原因(必填)" :maxlength="500" />
<text class="popup-hint">⚠ 驳回后将自动创建返工任务</text>
<view class="popup-btns">
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
<button class="btn-danger" :disabled="actionLoading || !rejectReason.trim()" @tap="doReject">确认驳回</button>
</view>
</template>
<!-- 转交 -->
<template v-if="actionPopup.type === 'transfer'">
<text class="popup-title">完工转交</text>
<!-- 工序选择 (Picker) -->
<view class="form-item">
<text class="form-label">下一道工序 <text class="required">*</text></text>
<picker mode="selector" :range="processOptions" @change="onProcessChange">
<view class="picker-value">
<text :class="transferForm.next_task_name ? '' : 'picker-placeholder'">
{{ transferForm.next_task_name || '请选择下一道工序' }}
</text>
<text class="picker-arrow">▾</text>
</view>
</picker>
</view>
<!-- 接收人选择 (Picker,单选) -->
<view class="form-item">
<text class="form-label">接收人 <text class="required">*</text></text>
<picker mode="selector" :range="userOptions" range-key="name" @change="onUserChange">
<view class="picker-value">
<text :class="selectedUserName ? '' : 'picker-placeholder'">
{{ selectedUserName || '请选择接收人' }}
</text>
<text class="picker-arrow">▾</text>
</view>
</picker>
</view>
<!-- 同时入库 -->
<label class="wh-label" @tap="transferForm.warehouse = !transferForm.warehouse">
<checkbox :checked="transferForm.warehouse" style="transform:scale(0.8)" /> 同时入库 (virtual_warehouse)
</label>
<input v-model="transferForm.note" class="popup-input" placeholder="交接备注(选填)" />
<view v-if="transferForm.assignees.length && transferForm.next_task_name" class="preview-hint">
将创建 {{ transferForm.assignees.length }}{{ transferForm.warehouse ? ' (+仓库)' : '' }} 个「{{ transferForm.next_task_name }}」任务
</view>
<view class="popup-btns">
<button class="btn-cancel" @tap="closeActionPopup">取消</button>
<button class="btn-primary" :disabled="actionLoading || !transferForm.next_task_name || (!transferForm.assignees.length && !transferForm.warehouse)" @tap="doTransfer">确认转交</button>
</view>
</template>
</view>
</view>
</view>
</template>
<script>
import request, { get, post, patch, put } from "../../utils/request";
import TaskTreeNode from "./components/TaskTreeNode.vue";
const OVERALL_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const TASK_NAME_OPTIONS = ["备货", "生产", "测试", "维修", "在库"];
const STATUS_MAP = {
PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成",
REJECTED: "已驳回", ARCHIVED: "已入库",
};
export default {
components: { TaskTreeNode },
data() {
return {
OVERALL_OPTIONS,
loading: true,
error: "",
product: null,
// 状态定调
showStatusPicker: false,
// 编辑产品
editProductVisible: false,
editForm: { order_no: "", external_serial: "" },
editSaving: false,
// 用户列表
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: "",
// Picker 数据源
processOptions: ["备货", "生产", "测试", "维修", "质检", "打包"],
userOptions: [
{ id: "duxingchen", name: "杜邢宸" },
{ id: "zhangsan", name: "张三" },
{ id: "lisi", name: "李四" },
{ id: "wangwu", name: "王五" },
],
// 任务操作
actionPopup: { visible: false, type: "", task: null },
actionLoading: false,
rejectReason: "",
transferForm: { next_task_name: "", assignees: [], selectedUserId: "", warehouse: false, note: "" },
};
},
computed: {
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;
},
selectedUserName() {
const u = this.userOptions.find(u => u.id === this.transferForm.selectedUserId);
return u ? u.name : "";
},
},
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; },
// ---- 数据 ----
async doQuery(sn) {
this.loading = true; this.error = "";
try {
this.product = await get(`/products/scan/${sn}`);
if (!this.product.overall_status) this.showStatusPicker = true;
} catch (e) { this.error = e?.data?.detail || "查询失败"; }
finally { this.loading = false; }
},
// ---- 状态定调 ----
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 });
} catch { /* 静默 */ }
},
loadCurrentUser() {
try {
let user = uni.getStorageSync("user");
// ⚠️ Storage 可能返回 JSON 字符串,必须强制解析
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,
});
// 场景B: 立即接收
if (this.firstForm.autoReceive) {
try {
await post(`/tasks/${task.id}/receive`);
} catch { /* receive 失败不影响流程 */ }
}
uni.showToast({
title: this.firstForm.autoReceive ? "已创建并接收 (WIP)" : "已创建 (PENDING)",
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 }; },
// ---- 选图 → 压缩 → 上传 ----
async handleChooseImage() {
const maxSlots = 9 - (this.recordForm.images.length + this.recordForm.pendingCount);
if (maxSlots <= 0) { uni.showToast({ title: "最多上传 9 张图片", icon: "none" }); return; }
// 第一步:选择图片(仅压缩图,禁止原图)
const chooseRes = await new Promise((resolve, reject) => {
uni.chooseImage({
count: maxSlots,
sizeType: ["compressed"],
sourceType: ["camera", "album"],
success: resolve,
fail: reject,
});
}).catch(() => null);
if (!chooseRes || !chooseRes.tempFilePaths || !chooseRes.tempFilePaths.length) return;
// 第二步:强制二次压缩 (quality=60,车间场景足够)
let compressSkipCount = 0;
const compressedPaths = [];
for (const p of chooseRes.tempFilePaths) {
try {
const compressed = await new Promise((resolve, reject) => {
uni.compressImage({
src: p,
quality: 60,
success: resolve,
fail: reject,
});
});
compressedPaths.push(compressed.tempFilePath);
} catch {
// 压缩失败 → 丢弃该图片,禁止原图直传
compressSkipCount++;
}
}
if (compressSkipCount > 0) {
uni.showToast({ title: `${compressSkipCount} 张压缩失败已丢弃`, icon: "none", duration: 1500 });
}
if (!compressedPaths.length) {
uni.showToast({ title: "所有图片压缩失败,请重试", icon: "none" });
return;
}
// 第三步:逐张上传(只传压缩后的文件)
this.isUploading = true;
this.recordForm.pendingCount += compressedPaths.length;
let failCount = 0;
for (const path of compressedPaths) {
const url = await this.uploadFile(path);
if (url) {
this.recordForm.images.push(url);
} else {
failCount++;
uni.showToast({ title: "单张图片上传失败", icon: "none", duration: 1500 });
}
this.recordForm.pendingCount--;
}
this.isUploading = false;
if (failCount > 0 && this.recordForm.images.length === 0) {
uni.showToast({ title: `${failCount} 张全部上传失败,请重试`, icon: "none" });
}
},
uploadFile(filePath) {
return new Promise((resolve) => {
uni.uploadFile({
url: "http://192.168.9.80:8011/api/v1/upload/",
filePath,
name: "file",
timeout: 30000,
success(res) {
if (res.statusCode !== 200) { resolve(null); return; }
try {
const data = JSON.parse(res.data);
if (!data || !data.url) { resolve(null); return; }
resolve(data.url);
} catch { resolve(null); }
},
fail: () => resolve(null),
});
});
},
removeRecordImage(i) { this.recordForm.images.splice(i, 1); },
previewRecordImage(i) { uni.previewImage({ urls: this.recordForm.images, current: i }); },
// ---- 保存记录 ----
async doSaveRecord() {
if (this.isUploading) { uni.showToast({ title: "图片上传中,请稍候", icon: "none" }); return; }
if (this.recordForm.pendingCount > 0) { uni.showToast({ title: "有图片未上传成功,请删除或重试", icon: "none" }); 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: this.recordForm.recordId ? "记录已更新" : "记录已保存", icon: "success" });
this.closeRecordPopup();
this.doQuery(this.product.serial_number);
} catch {}
finally { this.recordSaving = false; }
},
// ---- 任务操作 ----
handleTaskAction({ task, type, record }) {
if (type === "record") { this.openRecordPopup(task); return; }
if (type === "deleteRecord") { this.doDeleteRecord(record); return; }
this.actionPopup = { visible: true, type, task };
this.rejectReason = "";
this.transferForm = { next_task_name: "", assignees: [], selectedUserId: "", warehouse: false, note: "" };
},
closeActionPopup() { this.actionPopup = { visible: false, type: "", task: null }; },
async doReceive() {
this.actionLoading = true;
try {
await post(`/tasks/${this.actionPopup.task.id}/receive`);
uni.showToast({ title: "已接收", icon: "success" });
this.closeActionPopup();
this.doQuery(this.product.serial_number);
} catch {} finally { this.actionLoading = false; }
},
async doReject() {
this.actionLoading = true;
try {
await post(`/tasks/${this.actionPopup.task.id}/reject`, { reason: this.rejectReason.trim() });
uni.showToast({ title: "已驳回,返工任务已创建", icon: "success" });
this.closeActionPopup();
this.doQuery(this.product.serial_number);
} catch {} finally { this.actionLoading = false; }
},
onProcessChange(e) {
this.transferForm.next_task_name = this.processOptions[e.detail.value];
},
onUserChange(e) {
const user = this.userOptions[e.detail.value];
if (user) {
this.transferForm.selectedUserId = user.id;
this.transferForm.assignees = [user.id];
}
},
async doTransfer() {
this.actionLoading = true;
const finalAssignees = [...this.transferForm.assignees];
if (this.transferForm.warehouse) finalAssignees.push("virtual_warehouse");
try {
await post(`/tasks/${this.actionPopup.task.id}/transfer`, {
next_assignees: finalAssignees,
next_task_name: this.transferForm.next_task_name.trim(),
note: this.transferForm.note.trim() || undefined,
});
uni.showToast({ title: "转交成功", icon: "success" });
this.closeActionPopup();
this.doQuery(this.product.serial_number);
} catch {} finally { this.actionLoading = false; }
},
},
};
</script>
<style scoped>
.page { min-height: 100vh; padding: 16px; padding-bottom: 100px; }
.loading { text-align: center; padding: 48px 0; color: #6b7280; }
.error-box { padding: 12px; border-radius: 10px; background: #fef2f2; color: #dc2626; font-size: 13px; border: 1px solid #fecaca; }
/* 宏观状态 */
.overall-bar { display: flex; align-items: center; gap: 8px; padding: 10px 14px;
background: #fff; border-radius: 12px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.overall-label { font-size: 13px; color: #6b7280; }
.overall-val { font-size: 15px; font-weight: 700; color: #2563eb; flex: 1; }
.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); }
.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; }
.edit-btn { font-size: 18px; padding: 2px 6px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.label { font-size: 12px; color: #9ca3af; }
.value { font-size: 14px; color: #1f2937; font-weight: 600; word-break: break-all; }
.sn { font-family: monospace; }
.warehouse { color: #7c3aed; }
.badge { font-size: 11px; padding: 2px 10px; border-radius: 20px; font-weight: 600; }
.s-yellow .badge, .s-yellow { color: #b45309; }
.s-blue .badge, .s-blue { color: #1d4ed8; }
.s-green .badge, .s-green { color: #15803d; }
.s-red .badge, .s-red { color: #be123c; }
.s-gray .badge, .s-gray { color: #6b7280; }
.task-count { font-size: 12px; color: #9ca3af; }
/* 0任务 */
.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; }
.sheet-hint { font-size: 13px; color: #9ca3af; display: block; text-align: center; margin: 6px 0 16px; }
.sheet-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.sheet-opt { padding: 14px 8px; border-radius: 12px; text-align: center; font-size: 15px; font-weight: 600;
background: #f3f4f6; color: #374151; border: 2px solid transparent; }
.sheet-opt-active { background: #dbeafe; color: #2563eb; border-color: #2563eb; }
.sheet-close { margin-top: 14px; height: 40px; background: #f3f4f6; border: none; border-radius: 10px; font-size: 14px; color: #6b7280; line-height: 40px; }
/* 通用弹窗 */
.popup { width: 100%; max-width: 480px; background: #fff; border-radius: 16px 16px 0 0;
padding: 20px 16px 32px; max-height: 80vh; overflow-y: auto; }
.popup-title { font-size: 16px; font-weight: 700; display: block; text-align: center; margin-bottom: 12px; }
.popup-task { font-size: 14px; font-weight: 600; color: #2563eb; text-align: center; margin-bottom: 4px; }
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
.popup-textarea { width: 100%; height: 80px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px; font-size: 14px; margin: 10px 0; box-sizing: border-box; }
.popup-input { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-size: 14px; margin: 8px 0; box-sizing: border-box; }
.flex-1 { flex: 1; margin: 0; }
.popup-btns { display: flex; gap: 10px; margin-top: 16px; }
.btn-cancel { flex: 1; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #fff; color: #6b7280; font-size: 14px; line-height: 42px; }
.btn-primary { flex: 1; height: 42px; border: none; border-radius: 10px; background: #2563eb; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
.btn-primary[disabled] { opacity: 0.5; }
.btn-danger { flex: 1; height: 42px; border: none; border-radius: 10px; background: #dc2626; color: #fff; font-size: 14px; font-weight: 600; line-height: 42px; }
.btn-danger[disabled] { opacity: 0.5; }
.btn-sm { height: 42px; width: 42px; border: 1px solid #e5e7eb; border-radius: 10px; background: #f3f4f6; font-size: 18px; line-height: 42px; text-align: center; padding: 0; }
.popup-assignee-area { margin: 8px 0; }
.popup-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.popup-tag { padding: 4px 10px; border-radius: 20px; background: #dbeafe; color: #2563eb; font-size: 12px; font-weight: 600; }
.popup-tag-wh { background: #ede9fe; color: #7c3aed; }
.popup-add-row { display: flex; gap: 6px; }
.wh-label { font-size: 13px; display: flex; align-items: center; gap: 4px; margin-top: 6px; color: #6b7280; }
.preview-hint { font-size: 12px; background: #f0fdf4; color: #16a34a; padding: 8px 10px; border-radius: 8px; margin: 6px 0; }
/* Picker & 表单 */
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
.required { color: #ef4444; }
.picker-box { width: 100%; height: 42px; border: 1px solid #e5e7eb; border-radius: 10px;
padding: 0 12px; font-size: 14px; color: #1f2937; line-height: 42px; box-sizing: border-box; background: #fff; }
.switch-row { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; padding: 8px 0; }
.switch-label { font-size: 15px; font-weight: 600; color: #2563eb; }
.switch-hint { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
/* 图片上传 — 九宫格 */
.img-grid { display: flex; flex-wrap: wrap; margin: 8px -5px; }
.img-cell { position: relative; width: 160rpx; height: 160rpx; margin: 10rpx; }
.img-thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; border: 1px solid #e5e7eb; }
.img-cell-loading { display: flex; align-items: center; justify-content: center;
background: #f3f4f6; border-radius: 12rpx; border: 1px dashed #d1d5db; }
.img-loading-text { font-size: 36rpx; }
.img-del { position: absolute; top: -12rpx; right: -12rpx; width: 40rpx; height: 40rpx;
background: #ef4444; color: #fff; border-radius: 20rpx; font-size: 24rpx;
text-align: center; line-height: 40rpx; z-index: 2; }
.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; }
.btn-upload[disabled] { opacity: 0.5; }
/* Picker 选择器 */
.form-item { margin: 10px 0; }
.form-label { font-size: 14px; font-weight: 600; color: #374151; display: block; margin-bottom: 4px; }
.required { color: #ef4444; }
.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;
}
.picker-placeholder { color: #9ca3af; }
.picker-arrow { font-size: 12px; color: #9ca3af; margin-left: 8px; }
</style>