PC 端早就能挂出库物料,但移动端一直没有入口 —— 只能看,一线的人(生产领料、
测试补料)反而够不着。
- 产品详情页的产品信息卡底部加「出库单据 N 张单 / M 条料」入口,常显不隐藏:
以前没内容时整块消失,用户根本不知道有这功能。
- 新增「出库单据」页与「选择 MOM 出库物料」页,与 PC 端同一套数据源、
同一套接口、同一形态(按出库单号分组 + 点开展开明细)。
- 挂载不需要先选任务:任务只是溯源(记 added_by),展示/报废/删除一律按设备走。
- 代挂确认:勾了不是自己领的单时先拦一道。★ 这是**提示**不是权限 ——
料的归属是设备不是人,代挂是合理操作(测试替生产补挂、库管代录)。
⚠️ 判据是 MOM 的 consumer_name 与登录人姓名比对,比错也只是多让用户勾一下。
- navigateTo / navigateBack 失败在 uni 里是**静默**的(只留一行 warning),
用户看到的就是「点了没反应」。全部补 fail 回调弹窗。
506 lines
24 KiB
Vue
506 lines
24 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view v-if="loading" class="hint">加载中...</view>
|
||
|
||
<template v-else>
|
||
<!-- 设备抬头:先明确「这是哪台设备的料」,避免看串设备 -->
|
||
<view class="card">
|
||
<view class="head-line">
|
||
<text class="head-sn">{{ product.serial_number }}</text>
|
||
<text class="head-name">{{ product.material_name || '—' }}</text>
|
||
</view>
|
||
<text v-if="product.spec_model" class="head-spec">{{ product.spec_model }}</text>
|
||
</view>
|
||
|
||
<!-- 🚚 出库单据:**按出库单号分组**,一行一张单,点开看明细。
|
||
★ 与网页端同一形态(同一张表、同一个接口):单号是主信息,
|
||
「领了什么」收在展开区里。
|
||
★ 显示这台设备的**全部**出库明细,不按「我领的」过滤 ——
|
||
料是领给设备的,不是领给某个人的:生产领的外壳装在这台设备上,
|
||
测试时摔坏了就该由测试来报,不能让测试看不见它。
|
||
★ 不展示出库类型(用途),也不问「挂到哪条任务」:
|
||
现场只需要知道「挂了哪张单、谁挂的、谁出的库」。 -->
|
||
<view class="card">
|
||
<view class="card-header">
|
||
<text class="card-title">🚚 出库单据</text>
|
||
<text class="card-add" @tap="goPick">+ 领料</text>
|
||
</view>
|
||
<text v-if="materials.length" class="count">
|
||
共 {{ orders.length }} 张单 / {{ materials.length }} 条料
|
||
</text>
|
||
|
||
<view v-if="!materials.length" class="empty">
|
||
<text>暂无关联的出库单。</text>
|
||
<text class="empty-sub">点右上角「+ 领料」,选对应的出库单挂到这台设备上。</text>
|
||
</view>
|
||
|
||
<view v-for="o in orders" :key="o.outbound_no" class="order">
|
||
<view class="order-head" @tap="toggleExpand(o.outbound_no)">
|
||
<!-- 不展示出库类型(用途)—— 现场只关心「这台设备挂了哪张单、
|
||
谁挂的、谁出的库」,多一个「内部领用」徽标只是噪音 -->
|
||
<view class="order-line1">
|
||
<text class="order-no">{{ o.outbound_no }}</text>
|
||
</view>
|
||
<view class="order-line2">
|
||
<text class="order-count">{{ o.items.length }} 条物料</text>
|
||
<!-- 谁挂上去的 —— 现场要能追责/问人,只记在库里不显示等于没记 -->
|
||
<text v-if="o.addedByName" class="order-by">挂载 {{ o.addedByName }}</text>
|
||
<text class="order-time">{{ fmtTime(o.outbound_time) }}</text>
|
||
<!-- 整单删除:挂错了要能一次摘掉。只在**全部**是人工挂的时显示 ——
|
||
含 MOM 回调存档的单不给删(后端也拦)。
|
||
做成小按钮而不是裸文字:裸文字在窄屏上会被时间和箭头挤没,
|
||
现场根本看不出这里能点 -->
|
||
<view v-if="o.allManual" class="order-del" @tap.stop="confirmRemoveOrder(o)">
|
||
<text>删除</text>
|
||
</view>
|
||
<text class="chev">{{ expanded[o.outbound_no] ? '▲' : '▼' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="expanded[o.outbound_no]" class="lines">
|
||
<view v-for="m in o.items" :key="m.mom_line_id" class="line">
|
||
<view class="line-info">
|
||
<text class="line-name">{{ m.material_name || '(未命名物料)' }}</text>
|
||
<text class="line-meta">
|
||
<text v-if="m.spec_model">{{ m.spec_model }} · </text>×{{ m.quantity }}
|
||
<text v-if="m.consumer_name"> · 领用 {{ formatName(m.consumer_name) }}</text>
|
||
</text>
|
||
</view>
|
||
<view class="row-btns">
|
||
<view class="btn-scrap" @tap.stop="openScrapDialog(m)"><text>报废</text></view>
|
||
<view class="btn-del" @tap.stop="confirmRemove(m)"><text>删除</text></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- ♻️ 报废记录:状态与金额由后端实时回查 MOM -->
|
||
<view class="card" v-if="scrapRecords.length">
|
||
<view class="card-header">
|
||
<text class="card-title">♻️ 报废记录</text>
|
||
<text class="count">共 {{ scrapRecords.length }} 条</text>
|
||
</view>
|
||
<view v-for="s in scrapRecords" :key="s.id" class="scrap-row">
|
||
<view class="row-line1">
|
||
<text class="scrap-name">{{ s.material_name || '(未命名物料)' }}</text>
|
||
<text :class="['badge', badgeClass(s)]">{{ s.mom_status_label || '状态未知' }}</text>
|
||
<text class="scrap-qty">×{{ s.quantity }}</text>
|
||
</view>
|
||
<view class="row-line2">
|
||
<text class="scrap-meta">报废单 {{ s.scrap_request_no }}</text>
|
||
<text v-if="s.submitted_by" class="scrap-meta">提交人 {{ formatName(s.submitted_by) }}</text>
|
||
<!-- ★ 只有执行过才有金额。未执行显示「—」不显示 0 ——
|
||
0 会让人以为「这东西不值钱」,其实是「还没扫码执行」 -->
|
||
<text v-if="s.mom_executed" class="scrap-meta">损失 ¥{{ Number(s.total_loss).toFixed(2) }}</text>
|
||
</view>
|
||
<text v-if="s.reason" class="scrap-reason">{{ s.reason }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<!-- 报废弹层:数量 + 说明。分类不让人选 ——
|
||
走这条路进来的料都是「已领用到产线」,按定义就是生产损耗,
|
||
后端固定按 PRODUCTION 提交,少一个会选错的地方。 -->
|
||
<view v-if="dlg.visible" class="overlay" @tap="closeDlg">
|
||
<view class="popup" @tap.stop>
|
||
<text class="popup-title">报废 · {{ dlg.materialName }}</text>
|
||
<text class="popup-hint">{{ dlg.specModel || '—' }} | 原领用人 {{ dlg.consumerName || '—' }}</text>
|
||
|
||
<view class="field-label">报废数量 <text class="required">*</text></view>
|
||
<input v-model="dlg.quantity" type="digit" class="popup-input" :placeholder="'最多 ' + dlg.maxQty" />
|
||
|
||
<view class="field-label">原因说明</view>
|
||
<textarea v-model="dlg.reason" class="popup-textarea" placeholder="例如:测试时跌落,外壳磕裂" maxlength="200" />
|
||
|
||
<!-- ★ 代报确认:报的不是自己领的料时多一道。这是**防误操作**不是权限 ——
|
||
后端不会因为这条拒绝(料的归属是设备不是人,谁发现谁报),
|
||
真正的把关在 MOM 侧主管审批。 -->
|
||
<view v-if="dlg.isProxy" class="proxy" @tap="dlg.confirmed = !dlg.confirmed">
|
||
<text class="proxy-icon">{{ dlg.confirmed ? '☑' : '☐' }}</text>
|
||
<text class="proxy-text">这条料不是你领的({{ dlg.consumerName }} 领用),确认代报?</text>
|
||
</view>
|
||
|
||
<view class="popup-btns">
|
||
<button class="btn-cancel" @tap="closeDlg">取消</button>
|
||
<button class="btn-primary" :disabled="submitting || !dlgReady" @tap="doSubmit">
|
||
{{ submitting ? '提交中...' : '提交报废' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { get } from "../../utils/request";
|
||
import { formatUserName } from "../../utils/format";
|
||
import { listProductScraps, submitProductScrap, makeTrackRef } from "../../api/scrap";
|
||
import { removeProductOutboundMaterial, removeProductOutboundOrder } from "../../api/material";
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
productId: "",
|
||
serial: "",
|
||
product: {},
|
||
loading: true,
|
||
scrapRecords: [],
|
||
submitting: false,
|
||
// 展开的单据号集合:{ outbound_no: true }。
|
||
// 默认全收起 —— 一台设备可能领了很多单,全摊开要滑很久才看得完
|
||
expanded: {},
|
||
dlg: {
|
||
visible: false, momLineId: null, materialName: "", specModel: "",
|
||
consumerName: "", maxQty: 0, quantity: "", reason: "",
|
||
isProxy: false, confirmed: false, trackRef: "",
|
||
},
|
||
// 当前登录人姓名,用于判断「代报」
|
||
me: null,
|
||
};
|
||
},
|
||
computed: {
|
||
/** 这台设备挂载的全部 MOM 出库明细 */
|
||
materials() {
|
||
// 读**统一后**的设备出库明细(outbound_records)。
|
||
// 以前读 task_tree[].outbound_materials —— 那是被合并掉的第二个数据源,
|
||
// 也正是「网页端看不到移动端挂的料」的根因。
|
||
return (this.product && this.product.outbound_records) || [];
|
||
},
|
||
/** 按**出库单号**分组,与网页端同一形态:一行一张单,点开看明细 */
|
||
orders() {
|
||
const map = {};
|
||
const out = [];
|
||
this.materials.forEach((m) => {
|
||
const no = m.outbound_no || "(无单号)";
|
||
if (!map[no]) {
|
||
map[no] = {
|
||
outbound_no: no,
|
||
outbound_time: m.outbound_time,
|
||
// 谁挂的:同一张单的明细是同一次挂载写入的,取第一条即可
|
||
addedByName: m.added_by_name || m.added_by || "",
|
||
items: [],
|
||
// 整单是否全部人工挂的 —— 决定「整单删除」按不按得动
|
||
allManual: true,
|
||
};
|
||
out.push(map[no]);
|
||
}
|
||
map[no].items.push(m);
|
||
if (m.source !== "manual") map[no].allManual = false;
|
||
});
|
||
return out;
|
||
},
|
||
/** 代报时必须勾选确认才能提交 */
|
||
dlgReady() {
|
||
return !this.dlg.isProxy || this.dlg.confirmed;
|
||
},
|
||
},
|
||
onLoad(options) {
|
||
this.productId = options.productId || "";
|
||
this.serial = options.serial || "";
|
||
// 登录用户存在 "user" 里(JSON 字符串),与 profile / login 页同一口径。
|
||
// 只用它来判断「这条料是不是我领的」——判错也只是多让用户勾一下确认,
|
||
// 真正的防线在 MOM 侧主管审批
|
||
try {
|
||
const raw = uni.getStorageSync("user");
|
||
this.me = raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null;
|
||
} catch (e) {
|
||
this.me = null;
|
||
}
|
||
this.load();
|
||
},
|
||
onShow() {
|
||
// 从「选择出库物料」页返回时重新拉一次,把刚挂上的料显示出来
|
||
if (!this.loading) this.load();
|
||
},
|
||
methods: {
|
||
formatName: formatUserName,
|
||
|
||
async load() {
|
||
this.loading = true;
|
||
try {
|
||
// 用扫码接口:它一次带回 product.outbound_records(设备出库明细,
|
||
// 统一后的唯一来源)与 task_tree(只在「+ 领料」时用来带个默认任务作溯源)。
|
||
// 没另开专用接口:这份数据产品详情页本来就在拉,语义一致,复用即可
|
||
this.product = await get(`/products/scan/${this.serial}`);
|
||
await this.fetchScrapRecords();
|
||
} catch (e) {
|
||
uni.showToast({ title: e?.data?.detail || "加载失败", icon: "none" });
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
async fetchScrapRecords() {
|
||
try {
|
||
this.scrapRecords = (await listProductScraps(this.productId)) || [];
|
||
} catch (e) {
|
||
// 静默:报废记录是附加信息,拉不到不该挡住物料列表
|
||
console.warn("[material] 拉报废记录失败:", e?.data?.detail || e);
|
||
this.scrapRecords = [];
|
||
}
|
||
},
|
||
|
||
/** 展开/收起某张出库单的明细 */
|
||
toggleExpand(no) {
|
||
this.expanded = { ...this.expanded, [no]: !this.expanded[no] };
|
||
},
|
||
|
||
fmtTime(iso) {
|
||
if (!iso) return "—";
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return "—";
|
||
const p = (n) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
},
|
||
|
||
/**
|
||
* 领料:先定「挂到哪条任务」,再进选择器。
|
||
*
|
||
* ★ 为什么必须先选任务:料在 Track 侧是挂在**任务**上的
|
||
* (`product_outbound_materials`),只有它带 `mom_line_id`,
|
||
* 而报废必须靠它定位到具体哪条出库明细。所以挂载动作离不开任务。
|
||
* 但任务不该像之前那样当成列表分组抬头(现场看不懂「生产·小龙虾」),
|
||
* 所以改成点「+ 领料」时才选一次。
|
||
*/
|
||
goPick() {
|
||
// 统一到**设备级**后任务变成可选(只作溯源),所以不再让用户先选任务 ——
|
||
// 少一步操作,也少一处「到底挂到哪条任务」的困惑
|
||
this.toPick();
|
||
},
|
||
|
||
toPick() {
|
||
// 把**这台设备已挂过的单号**带给选择器,让那边把「已挂载」标出来并禁止再选。
|
||
// 后端本身是幂等的(已挂的明细会跳过),标出来只是省得用户白勾一遍
|
||
const mounted = [...new Set(this.materials.map((m) => m.outbound_no).filter(Boolean))];
|
||
// 不带任务 —— 挂载不需要挂到某条任务上(任务只是溯源,可空)。
|
||
// 「谁挂上去的」记在 added_by、「谁出库的」由 MOM 快照带过来,够了。
|
||
uni.navigateTo({
|
||
url: `/pages/material/pick?productId=${this.productId}`
|
||
+ `&serial=${encodeURIComponent(this.serial || "")}`
|
||
+ `&mounted=${encodeURIComponent(mounted.join(","))}`,
|
||
// navigateTo 失败是静默的(只在控制台留一行),必须弹出来,
|
||
// 多半是 pages.json 没重新读 —— HBuilderX 只认启动时的那份
|
||
fail: (err) => {
|
||
console.error("[material] 打不开选择器:", err);
|
||
uni.showModal({
|
||
title: "打不开「选择出库物料」",
|
||
content: "页面未注册或未编译:" + (err && err.errMsg ? err.errMsg : err)
|
||
+ "\n\n请完全关闭并重启 HBuilderX 后重新运行",
|
||
showCancel: false,
|
||
});
|
||
},
|
||
});
|
||
},
|
||
|
||
openScrapDialog(material) {
|
||
const myName = (this.me && (this.me.display_name || this.me.username)) || "";
|
||
const consumer = material.consumer_name || "";
|
||
this.dlg = {
|
||
visible: true,
|
||
momLineId: material.mom_line_id,
|
||
materialName: material.material_name || "(未命名物料)",
|
||
specModel: material.spec_model || "",
|
||
consumerName: consumer,
|
||
maxQty: material.quantity,
|
||
quantity: String(material.quantity || ""),
|
||
reason: "",
|
||
isProxy: !!consumer && !!myName && consumer !== myName,
|
||
confirmed: false,
|
||
// ★ 打开时生成一次,重试复用 —— 每次提交都换新的话,
|
||
// 用户重试会在 MOM 里多报一张报废单
|
||
trackRef: makeTrackRef(),
|
||
};
|
||
},
|
||
|
||
closeDlg() {
|
||
if (this.submitting) return; // 提交中不许关,避免用户以为没提交
|
||
this.dlg.visible = false;
|
||
},
|
||
|
||
/** 整张出库单一起摘掉(挂错了要能一次撤)。规则与逐条删一致:含 MOM
|
||
* 回调存档的单删不掉(后端 409),那是系统事实 */
|
||
confirmRemoveOrder(order) {
|
||
uni.showModal({
|
||
title: '删除整张出库单',
|
||
content: '把出库单「' + order.outbound_no + '」从这台设备上整张摘掉?\n\n'
|
||
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
|
||
+ '已提交的报废记录也不受影响。',
|
||
confirmText: '删除',
|
||
confirmColor: '#dc2626',
|
||
success: (res) => { if (res.confirm) this.doRemoveOrder(order.outbound_no); },
|
||
});
|
||
},
|
||
|
||
async doRemoveOrder(outboundNo) {
|
||
try {
|
||
await removeProductOutboundOrder(this.productId, outboundNo);
|
||
uni.showToast({ title: '已删除整张出库单', icon: 'success' });
|
||
await this.load();
|
||
} catch (e) {
|
||
uni.showModal({
|
||
title: '删除失败',
|
||
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
|
||
showCancel: false,
|
||
});
|
||
}
|
||
},
|
||
|
||
/** 摘掉一条挂错的领用物料(与 PC 端「出库单据」卡的删除同一语义) */
|
||
confirmRemove(material) {
|
||
// 二次确认必须把「删的是什么、不删什么」说清楚 ——
|
||
// 用户最怕的是「我删了会不会把 MOM 里的出库单也搞没了」
|
||
uni.showModal({
|
||
title: '删除出库明细',
|
||
content: '把「' + (material.material_name || '此物料') + '」从这台设备上摘掉?\n\n'
|
||
+ '只解除 Track 这边的挂载关系,不会动 MOM 里的出库单本身,'
|
||
+ '已提交的报废记录也不受影响。',
|
||
confirmText: '删除',
|
||
confirmColor: '#dc2626',
|
||
success: (res) => { if (res.confirm) this.doRemove(material); },
|
||
});
|
||
},
|
||
|
||
async doRemove(material) {
|
||
try {
|
||
// ⚠️ 传的是**记录 id**(material.id,Track 侧主键),不是 mom_line_id
|
||
await removeProductOutboundMaterial(this.productId, material.id);
|
||
uni.showToast({ title: '已删除', icon: 'success' });
|
||
await this.load();
|
||
} catch (e) {
|
||
uni.showModal({
|
||
title: '删除失败',
|
||
content: String(e?.data?.detail || e?.errMsg || '删除失败'),
|
||
showCancel: false,
|
||
});
|
||
}
|
||
},
|
||
|
||
async doSubmit() {
|
||
const d = this.dlg;
|
||
const qty = Number(d.quantity);
|
||
if (!qty || qty <= 0) return uni.showToast({ title: "请填写报废数量", icon: "none" });
|
||
if (qty > Number(d.maxQty)) {
|
||
return uni.showToast({ title: `不能超过 ${d.maxQty}`, icon: "none" });
|
||
}
|
||
if (d.isProxy && !d.confirmed) {
|
||
return uni.showToast({ title: "请先确认代报", icon: "none" });
|
||
}
|
||
|
||
this.submitting = true;
|
||
try {
|
||
await submitProductScrap(this.productId, {
|
||
mom_line_id: d.momLineId,
|
||
quantity: qty,
|
||
reason: (d.reason || "").trim() || null,
|
||
track_ref: d.trackRef,
|
||
});
|
||
uni.showToast({ title: "已提交,待主管审批", icon: "success" });
|
||
d.visible = false;
|
||
await this.fetchScrapRecords();
|
||
} catch (e) {
|
||
// ★ 不关弹层、不换 trackRef:用户改完数量或稍后重试走的是同一个幂等键,
|
||
// 不会在 MOM 里多报一张单
|
||
uni.showModal({
|
||
title: "报废提交失败",
|
||
content: String(e?.data?.detail || e?.errMsg || "提交失败"),
|
||
showCancel: false,
|
||
});
|
||
} finally {
|
||
this.submitting = false;
|
||
}
|
||
},
|
||
|
||
badgeClass(s) {
|
||
if (s.mom_executed) return "badge-done";
|
||
if (s.mom_status === 2 || s.mom_status === 4) return "badge-off";
|
||
return "badge-wait";
|
||
},
|
||
},
|
||
};
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page { padding: 12px; padding-bottom: 40px; }
|
||
.hint { text-align: center; padding: 40px 0; color: #6b7280; font-size: 13px; }
|
||
|
||
.card { background: #fff; border-radius: 12px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
|
||
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||
.card-title { font-size: 15px; font-weight: 700; }
|
||
.count { font-size: 12px; color: #9ca3af; }
|
||
|
||
.head-line { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||
.head-sn { font-family: monospace; font-size: 14px; font-weight: 700; color: #1f2937; }
|
||
.head-name { font-size: 13px; color: #4b5563; }
|
||
.head-spec { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||
|
||
.empty { padding: 16px 0; text-align: center; }
|
||
.empty text { display: block; font-size: 13px; color: #6b7280; }
|
||
.empty-sub { font-size: 11px; color: #9ca3af; margin-top: 4px; }
|
||
|
||
/* 领料按钮:给足点击面积(工地上戴手套点,小了容易点不中) */
|
||
.card-add { font-size: 12px; font-weight: 600; color: #2563eb; padding: 4px 10px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; flex-shrink: 0; }
|
||
|
||
/* 出库单:一行一张单,点标题行展开明细 */
|
||
.order { border: 1px solid #f3f4f6; border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
|
||
.order:last-child { margin-bottom: 0; }
|
||
.order-head { padding: 10px; background: #fafafa; }
|
||
.order-line1 { display: flex; align-items: center; gap: 6px; }
|
||
.order-no { font-family: monospace; font-size: 13px; font-weight: 700; color: #1f2937; word-break: break-all; }
|
||
/* flex-wrap:窄屏上「条数 + 时间 + 删除 + 箭头」可能放不下,
|
||
换行总比把删除按钮挤没强 */
|
||
.order-line2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
|
||
.order-count { font-size: 11px; color: #6b7280; }
|
||
.order-time { font-size: 11px; color: #9ca3af; }
|
||
/* 谁挂上去的:比时间略深一点,便于一眼看到 */
|
||
.order-by { font-size: 11px; color: #6b7280; }
|
||
/* 整单删除:做成与明细行删除同样的小按钮 —— 裸文字在窄屏上会被时间和箭头
|
||
挤没,现场看不出这里能点。flex-shrink:0 保证再挤也不会消失 */
|
||
.order-del { margin-left: auto; flex-shrink: 0; padding: 3px 10px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; }
|
||
.chev { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
|
||
|
||
/* 展开区:明细行 + 每条自己的操作按钮 */
|
||
.lines { padding: 8px 10px; border-top: 1px solid #f3f4f6; }
|
||
.line { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #f9fafb; }
|
||
.line:last-child { border-bottom: none; padding-bottom: 0; }
|
||
.line-info { flex: 1; min-width: 0; }
|
||
.line-name { font-size: 13px; font-weight: 600; color: #1f2937; display: block; }
|
||
.line-meta { font-size: 11px; color: #9ca3af; display: block; margin-top: 2px; }
|
||
|
||
/* 两个操作并排。都给足点击面积 —— 工地上戴手套点,小了容易点不中 */
|
||
.row-btns { display: flex; gap: 6px; flex-shrink: 0; }
|
||
.btn-scrap { padding: 6px 12px; border: 1px solid #fecaca; border-radius: 8px; background: #fef2f2; color: #dc2626; font-size: 12px; font-weight: 600; }
|
||
.btn-del { padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: #6b7280; font-size: 12px; }
|
||
|
||
.scrap-row { border: 1px solid #f3f4f6; border-radius: 8px; padding: 8px; margin-bottom: 6px; }
|
||
.scrap-row:last-child { margin-bottom: 0; }
|
||
.row-line1 { display: flex; align-items: center; gap: 6px; }
|
||
.scrap-name { font-size: 13px; font-weight: 600; color: #1f2937; flex: 1; min-width: 0; }
|
||
.scrap-qty { font-size: 11px; color: #9ca3af; flex-shrink: 0; }
|
||
.row-line2 { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; }
|
||
.scrap-meta { font-size: 11px; color: #6b7280; }
|
||
.scrap-reason { font-size: 11px; color: #9ca3af; margin-top: 3px; display: block; }
|
||
|
||
.badge { font-size: 10px; font-weight: 700; border-radius: 10px; padding: 1px 6px; flex-shrink: 0; }
|
||
.badge-wait { color: #b45309; background: #fef3c7; }
|
||
.badge-done { color: #047857; background: #d1fae5; }
|
||
.badge-off { color: #6b7280; background: #e5e7eb; }
|
||
|
||
.overlay { position: fixed; inset: 0; z-index: 999; background: rgba(0,0,0,0.45); display: flex; align-items: flex-end; justify-content: center; }
|
||
.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: 6px; }
|
||
.popup-hint { font-size: 12px; color: #9ca3af; display: block; text-align: center; }
|
||
.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; }
|
||
.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; }
|
||
.field-label { font-size: 14px; font-weight: 600; color: #374151; margin-top: 10px; margin-bottom: 4px; }
|
||
.required { color: #ef4444; }
|
||
.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; }
|
||
|
||
.proxy { display: flex; align-items: flex-start; gap: 6px; margin-top: 12px; padding: 8px 10px; border: 1px solid #fde68a; border-radius: 8px; background: #fffbeb; }
|
||
.proxy-icon { font-size: 14px; color: #b45309; flex-shrink: 0; }
|
||
.proxy-text { font-size: 12px; color: #92400e; line-height: 1.4; }
|
||
</style>
|