Files
admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue
T
2026-09-15 09:58:15 +08:00

743 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ImagePlus,
RefreshCw,
Scale,
Trash2,
} from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import {
confirmWeightResult,
createWeightResult,
deleteWeightResult,
downloadWeightImage,
getWeightOcrRawText,
getWeightPalletOptions,
getWeightWorkspace,
uploadWeightImage,
} from '../api/weightOcr'
import type {
WeightPalletOption,
WeightResultResource,
WeightWorkspaceResource,
} from '../types'
import WeightImageThumbnail from './WeightImageThumbnail.vue'
const props = withDefaults(defineProps<{
customerId?: string
tenantId?: string
inboundId: string
reviewMode?: boolean
correctionMode?: boolean
}>(), {
reviewMode: false,
correctionMode: false,
})
const emit = defineEmits<{
changed: []
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null, hasConfirmedWeight: boolean]
}>()
const access = useAccessPreviewStore()
const fileInput = ref<HTMLInputElement>()
const workspace = ref<WeightWorkspaceResource | null>(null)
const palletOptions = ref<WeightPalletOption[]>([])
const selectedPalletId = ref('')
const palletKeyword = ref('')
const palletLoading = ref(false)
const palletError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const uploading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const activeAction = ref('')
const previewOpen = ref(false)
const previewLoading = ref(false)
const previewError = ref<ApiErrorDetails | null>(null)
const previewUrl = ref('')
const previewResultId = ref('')
const previewSnapshot = ref<WeightResultResource | null>(null)
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB'; reason: string }>>({})
let loadSequence = 0
let palletLoadSequence = 0
let palletAbortController: AbortController | null = null
const PALLET_OPTION_PAGE_SIZE = 50
const editableStatuses = new Set(['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
const minimumWeight = { KG: 0.001, LB: 0.002 } as const
const maximumSafeWeight = 9_000_000_000_000
const customerId = computed(() => props.customerId || props.tenantId || '')
const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit'))
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
const canLoadPalletOptions = computed(() => Boolean(
!props.reviewMode
&& customerId.value
&& props.inboundId
&& canView.value
&& canEdit.value
&& editable.value,
))
const virtualPalletOptions = computed(() => palletOptions.value.map((option) => ({
label: palletOptionLabel(option),
value: option.palletId,
})))
const reviewCorrectionEnabled = computed(() => Boolean(
props.reviewMode
&& props.correctionMode
&& workspace.value?.processStatus === 'MEASURE_PENDING',
))
const rows = computed(() => workspace.value?.weightResults ?? [])
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|| previewSnapshot.value)
const previewConfirmDisabled = computed(() => {
const row = previewResult.value
return !row
|| !isWeightDraftValid(row)
|| (row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
&& (!hasWeightDraftChanged(row) || !weightDrafts.value[row.id]?.reason.trim()))
|| Boolean(activeAction.value)
})
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
&& (editable.value || (reviewCorrectionEnabled.value && canEdit.value))))
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
&& (editable.value || reviewCorrectionEnabled.value)))
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
? '保存重量修正'
: '确认重量')
function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
function palletOptionLabel(option: WeightPalletOption) {
return `${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`
}
function actionKey(action: string, id: string) {
return `${action}:${id}`
}
function isActionLoading(action: string, id: string) {
return activeAction.value === actionKey(action, id)
}
function normalizeUnit(value: string | null) {
return value?.trim().toUpperCase().startsWith('LB') ? 'LB' as const : 'KG' as const
}
function isSupportedUnit(value: string | null) {
return ['KG', 'KGS', 'LB', 'LBS'].includes(value?.trim().toUpperCase() || '')
}
function syncWeightDrafts(results: WeightResultResource[]) {
weightDrafts.value = Object.fromEntries(results.map((row) => [row.id, {
value: row.adjustedWeightValue ?? row.ocrWeightValue,
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
reason: '',
}]))
}
function ensureWeightDraft(row: WeightResultResource) {
weightDrafts.value[row.id] ??= {
value: row.adjustedWeightValue ?? row.ocrWeightValue,
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
reason: '',
}
}
function isWeightDraftValid(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
const value = Number(draft?.value)
return Boolean(draft
&& Number.isFinite(value)
&& value >= minimumWeight[draft.unit]
&& value <= maximumSafeWeight)
}
function draftGrossWeightKg(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
if (!draft || !Number.isFinite(Number(draft.value))) return null
const value = Number(draft.value)
return draft.unit === 'LB' ? value * 0.45359237 : value
}
function showActionError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
const requestReference = details.requestId ? `(请求 ID:${details.requestId})` : ''
ElMessage.error({
message: `${details.message}${requestReference}`,
duration: 5_000,
showClose: true,
})
}
function cardWeight(row: WeightResultResource) {
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.netWeightKg)
const draft = weightDrafts.value[row.id]
return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG')
}
function putawayStatusLabel(status: WeightResultResource['slotStatus']) {
return ({
RESERVED_EMPTY: '预留空位',
BOUND: '待上架',
TRANSFERRED: '已上架',
RELEASED: '已释放',
} as const)[status as Exclude<WeightResultResource['slotStatus'], null>] || '未分配'
}
function hasWeightDraftChanged(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
if (!draft) return false
const savedValue = row.adjustedWeightValue ?? row.ocrWeightValue
const rawSavedUnit = row.adjustedWeightUnit ?? row.ocrWeightUnit
const savedUnit = normalizeUnit(rawSavedUnit)
if (!isSupportedUnit(rawSavedUnit)) return true
return Number(draft.value) !== Number(savedValue) || draft.unit !== savedUnit
}
function buildAdjustmentReason(row: WeightResultResource) {
const draft = weightDrafts.value[row.id]
const ocrUnit = row.ocrWeightUnit?.trim().toUpperCase()
const ocrValue = row.ocrWeightValue == null
? '—'
: `${Number(row.ocrWeightValue).toFixed(3)}${ocrUnit ? ` ${ocrUnit}` : '(单位缺失)'}`
return `OCR 原值 ${ocrValue},人工确认 ${formatWeight(Number(draft.value), draft.unit)}`
}
function emitWorkspaceChanged() {
emit(
'summaryChanged',
workspace.value?.actualGrossWeightKg ?? null,
workspace.value?.actualNetWeightKg ?? null,
workspace.value?.weightResults.some((item) => item.recognitionStatus === 'CONFIRMED') === true,
)
emit('changed')
}
async function loadWorkspace() {
if (!customerId.value || !props.inboundId || !canView.value) {
workspace.value = null
weightDrafts.value = {}
return
}
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await getWeightWorkspace(customerId.value, props.inboundId)
if (sequence === loadSequence) {
workspace.value = result
syncWeightDrafts(result.weightResults)
}
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
async function loadPalletOptions(keyword = palletKeyword.value) {
if (!canLoadPalletOptions.value) {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = null
return
}
cancelPalletOptionsLoad()
const sequence = ++palletLoadSequence
const controller = new AbortController()
palletAbortController = controller
palletKeyword.value = keyword.trim()
palletLoading.value = true
palletError.value = null
try {
const result = await getWeightPalletOptions(customerId.value, props.inboundId, {
page: 1,
size: PALLET_OPTION_PAGE_SIZE,
keyword: palletKeyword.value || undefined,
signal: controller.signal,
})
if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = result.records
if (!result.records.some((option) => option.palletId === selectedPalletId.value)) selectedPalletId.value = ''
} catch (error) {
if (sequence !== palletLoadSequence || controller.signal.aborted) return
palletOptions.value = []
selectedPalletId.value = ''
palletError.value = getApiErrorDetails(error, '可选托盘加载失败,请稍后重试')
} finally {
if (sequence === palletLoadSequence) {
palletLoading.value = false
if (palletAbortController === controller) palletAbortController = null
}
}
}
function searchPalletOptions(keyword: string) {
void loadPalletOptions(keyword)
}
function reloadPalletOptions() {
void loadPalletOptions()
}
function cancelPalletOptionsLoad() {
++palletLoadSequence
palletAbortController?.abort()
palletAbortController = null
palletLoading.value = false
}
function chooseFile() {
if (!editable.value) {
ElMessage.warning('当前入库状态不允许上传称重图片')
return
}
if (!selectedPalletId.value) {
ElMessage.warning('请先选择本次称重使用的托盘')
return
}
fileInput.value?.click()
}
function validateImage(file: File) {
if (!['image/jpeg', 'image/png', 'image/bmp'].includes(file.type)) return '称重图片仅支持 JPG、PNG、BMP'
if (file.size > 5 * 1024 * 1024) return '称重图片不能超过 5MB'
return ''
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || uploading.value) return
const validationError = validateImage(file)
if (validationError) {
ElMessage.warning(validationError)
return
}
uploading.value = true
try {
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
const created = await createWeightResult(customerId.value, props.inboundId, {
fileId: String(upload.fileId),
palletId: selectedPalletId.value,
ocrProvider: 'FILE_WEIGHT_OCR',
ocrRawText: getWeightOcrRawText(upload),
ocrWeightValue: upload.weightValue,
ocrWeightUnit: upload.weightUnit,
confidence: upload.confidence,
})
ensureWeightDraft(created)
await openPreview(created, URL.createObjectURL(file))
await loadWorkspace()
ensureWeightDraft(created)
emitWorkspaceChanged()
} catch (error) {
showActionError(error, '称重图片上传失败,请检查后重试')
} finally {
uploading.value = false
}
}
async function confirmResult(row: WeightResultResource, closeAfterSuccess = false) {
const draft = weightDrafts.value[row.id]
if (!draft || !isWeightDraftValid(row)) {
ElMessage.warning('请输入大于 0 的有效重量')
return
}
const grossWeightKg = draftGrossWeightKg(row)
if (grossWeightKg == null || grossWeightKg <= Number(row.palletTareWeightKg)) {
ElMessage.warning(`称重毛重必须大于托盘皮重 ${formatWeight(row.palletTareWeightKg)}`)
return
}
const key = actionKey('confirm', row.id)
activeAction.value = key
try {
const payload = { version: row.version } as Parameters<typeof confirmWeightResult>[3]
if (hasWeightDraftChanged(row)) {
if (!canEdit.value) {
ElMessage.warning('当前账号缺少重量修改权限')
return
}
payload.adjustedWeightValue = Number(draft.value)
payload.adjustedWeightUnit = draft.unit
payload.adjustmentReason = row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
? draft.reason.trim()
: buildAdjustmentReason(row)
}
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
if (closeAfterSuccess) previewOpen.value = false
ElMessage.success(row.recognitionStatus === 'CONFIRMED' ? '含托盘毛重已修正,货物重量和合计已由服务端重算' : '重量已确认')
await loadWorkspace()
emitWorkspaceChanged()
return true
} catch (error) {
showActionError(error, '重量确认失败,请刷新后重试')
} finally {
activeAction.value = ''
}
return false
}
function releasePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
}
async function openPreview(row: WeightResultResource, localUrl = '') {
releasePreview()
previewResultId.value = row.id
previewSnapshot.value = row
previewError.value = null
previewOpen.value = true
if (localUrl) {
previewUrl.value = localUrl
previewLoading.value = false
return
}
previewLoading.value = true
try {
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
previewError.value = getApiErrorDetails(error, '称重图片读取失败,请稍后重试')
} finally {
previewLoading.value = false
}
}
async function removeResult(row: WeightResultResource) {
try {
await ElMessageBox.confirm(
row.recognitionStatus === 'CONFIRMED'
? '删除后将重新计算货物总重量;对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?'
: '对应库位仍为本票保留,不会释放给其他订单。确认删除这张称重图片吗?',
'删除称重图片',
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
} catch {
return
}
const key = actionKey('delete', row.id)
activeAction.value = key
try {
await deleteWeightResult(customerId.value, props.inboundId, row.id, row.version)
if (previewResultId.value === row.id) previewOpen.value = false
ElMessage.success('称重图片已删除')
await loadWorkspace()
emitWorkspaceChanged()
} catch (error) {
showActionError(error, '称重图片删除失败,请刷新后重试')
} finally {
activeAction.value = ''
}
}
watch(
() => [customerId.value, props.inboundId, canView.value] as const,
() => {
++loadSequence
workspace.value = null
palletOptions.value = []
selectedPalletId.value = ''
palletKeyword.value = ''
palletError.value = null
weightDrafts.value = {}
loadError.value = null
previewOpen.value = false
releasePreview()
void loadWorkspace()
},
{ immediate: true },
)
watch(
() => [customerId.value, props.inboundId, canLoadPalletOptions.value] as const,
() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
palletKeyword.value = ''
palletError.value = null
if (canLoadPalletOptions.value) void loadPalletOptions()
},
)
watch(previewOpen, (open) => {
if (!open) {
releasePreview()
}
})
onBeforeUnmount(() => {
cancelPalletOptionsLoad()
palletOptions.value = []
selectedPalletId.value = ''
releasePreview()
})
defineExpose({ reload: loadWorkspace })
</script>
<template>
<section class="weight-workspace" aria-labelledby="weight-ocr-title">
<header class="weight-heading">
<div>
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
</div>
<div class="weight-heading__actions">
<div v-if="workspace" class="weight-summary-group" aria-live="polite">
<div class="weight-summary"><span>实测毛重</span><strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong></div>
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
</div>
<div class="pallet-picker">
<el-select-v2
v-if="canEdit && !reviewMode"
v-model="selectedPalletId"
:options="virtualPalletOptions"
aria-label="选择称重托盘"
placeholder="选择称重托盘"
filterable
remote
:remote-method="searchPalletOptions"
:loading="palletLoading"
:disabled="loading || uploading || !editable || !canEdit || Boolean(palletError)"
/>
</div>
<input
v-if="!reviewMode"
ref="fileInput"
class="visually-hidden-input"
type="file"
accept="image/jpeg,image/png,image/bmp"
aria-label="选择称重图片"
@change="handleFileChange"
/>
<el-button
v-if="canEdit && !reviewMode"
type="primary"
:loading="uploading"
:disabled="loading || palletLoading || !editable || !selectedPalletId || Boolean(palletError)"
@click="chooseFile"
><ImagePlus :size="16" />上传称重图片</el-button>
</div>
</header>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
当前账号缺少入库测量查看权限。
</el-alert>
<template v-else>
<LoadErrorState
v-if="loadError"
title="称重数据加载失败"
:message="loadError.message"
:loading="loading"
compact
@retry="loadWorkspace"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<LoadErrorState
v-if="palletError"
title="可选托盘加载失败"
:message="palletError.message"
:loading="palletLoading"
compact
class="weight-action-error"
@retry="reloadPalletOptions"
>
<template #details><ApiErrorMeta :details="palletError" /></template>
</LoadErrorState>
<el-alert
v-else-if="!palletLoading && palletOptions.length === 0 && editable && canEdit"
type="warning"
:closable="false"
show-icon
class="weight-action-error"
title="当前仓库暂无可选托盘,暂时不能上传称重图片。"
>
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
</el-alert>
<div v-loading="loading" class="weight-list" aria-live="polite">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<strong>尚未上传称重图片</strong>
</div>
<div v-else class="weight-grid">
<article v-for="(row, index) in rows" :key="row.id" class="weight-card">
<WeightImageThumbnail
:customer-id="customerId"
:inbound-id="inboundId"
:file-id="row.fileId"
:alt="`称重图片 ${index + 1}`"
@open="openPreview(row)"
/>
<el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top">
<el-button
plain
type="danger"
class="weight-delete"
:aria-label="`删除称重图片 ${index + 1}`"
:loading="isActionLoading('delete', row.id)"
:disabled="Boolean(activeAction)"
@click="removeResult(row)"
><Trash2 :size="16" /></el-button>
</el-tooltip>
<div class="weight-card__body">
<button
type="button"
class="weight-card__value"
:aria-label="`查看称重图片 ${index + 1},重量 ${cardWeight(row)}`"
@click="openPreview(row)"
><strong>{{ cardWeight(row) }}</strong></button>
<dl class="weight-card__facts">
<div><dt>托盘编码</dt><dd :title="row.palletCode">{{ row.palletCode || '未记录编码' }}</dd></div>
<div><dt>托盘类型</dt><dd>{{ row.palletType || '未记录类型' }}</dd></div>
<div><dt>库位</dt><dd :title="row.locationCode || '未分配'">{{ row.locationCode || '未分配' }}<template v-if="row.locationCode"> · {{ putawayStatusLabel(row.slotStatus) }}</template><template v-if="row.sharedOverflow"> · A0 临时溢出位</template></dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatWeight(row.grossWeightKg) }}</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
<div><dt>货物重量</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
</dl>
<el-button
v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm"
class="weight-card__correct"
type="primary"
plain
@click="openPreview(row)"
>修改含托盘毛重</el-button>
</div>
</article>
</div>
</div>
</template>
</template>
<AppDialog class="admin-density-dialog"
v-model="previewOpen"
title="确认称重图片"
:loading="previewLoading"
:confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))"
:confirm-disabled="previewConfirmDisabled"
:show-confirm="Boolean(previewResult && previewCanConfirm && (previewResult.recognitionStatus !== 'CONFIRMED' || reviewCorrectionEnabled))"
:confirm-text="previewConfirmText"
confirm-type="success"
width="min(640px, calc(100vw - 28px))"
@confirm="previewResult && confirmResult(previewResult, true)"
>
<LoadErrorState
v-if="previewError"
title="图片读取失败"
:message="previewError.message"
:show-retry="false"
compact
>
<template #details><ApiErrorMeta :details="previewError" /></template>
</LoadErrorState>
<template v-else-if="previewResult">
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
<div class="weight-key-facts" aria-label="称重关键数据">
<div><span>托盘类型</span><strong>{{ previewResult.palletType || '未记录类型' }}</strong></div>
<div><span>当前托盘皮重</span><strong>{{ formatWeight(previewResult.palletTareWeightKg) }}</strong></div>
<div><span>OCR 识别毛重</span><strong>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</strong></div>
</div>
<div v-if="weightDrafts[previewResult.id]" class="preview-weight">
<label>
<span>确认含托盘毛重</span>
<el-input-number
v-model="weightDrafts[previewResult.id].value"
aria-label="当前图片确认重量"
:min="minimumWeight[weightDrafts[previewResult.id].unit]"
:max="maximumSafeWeight"
:precision="3"
:controls="false"
:disabled="Boolean(activeAction) || !previewInputEditable" placeholder="请输入确认重量" />
</label>
<el-segmented
v-model="weightDrafts[previewResult.id].unit"
aria-label="当前图片重量单位"
:options="['KG', 'LB']"
:disabled="Boolean(activeAction) || !previewInputEditable"
/>
</div>
<label v-if="reviewCorrectionEnabled && previewResult.recognitionStatus === 'CONFIRMED' && weightDrafts[previewResult.id]" class="correction-reason">
<span>修改原因 *</span>
<el-input v-model="weightDrafts[previewResult.id].reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写本次毛重修正原因" />
</label>
</template>
</AppDialog>
</section>
</template>
<style scoped>
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
.weight-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-bottom: var(--admin-space-4, 10px); border-bottom: 1px solid var(--cc-border); }
.weight-heading h3 { display: flex; align-items: center; gap: var(--admin-space-3, 8px); margin: 0; font-size: 15px; }
.weight-heading h3 svg { color: var(--cc-primary); }
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: var(--admin-space-6, 16px); }
.pallet-picker { width: clamp(360px, 36vw, 520px); min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
.weight-summary { display: flex; align-items: baseline; gap: var(--admin-space-2, 6px); white-space: nowrap; }
.weight-summary-group { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; }
.weight-summary span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.weight-summary strong { color: #067647; font-size: var(--admin-font-title, 16px); }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 0; margin-top: 14px; }
.weight-empty { display: flex; min-height: 0; align-items: center; gap: var(--admin-space-4, 10px); padding: var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); }
.weight-empty strong { color: #344054; font-size: var(--admin-font-section, 14px); }
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--admin-surface, #fff); }
.weight-card__body { padding: var(--admin-space-5, 12px); text-align: center; }
.weight-delete { position: absolute; z-index: 2; top: 8px; right: 8px; width: 30px; height: 30px; padding: 0; border: 1px solid rgb(255 255 255 / 82%); background: rgb(255 255 255 / 92%); box-shadow: 0 1px 3px rgb(16 24 40 / 16%); }
.weight-card__value { width: 100%; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: center; }
.weight-card__value strong { color: #101828; font-size: 17px; }
.weight-card__value:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 4px; }
.weight-card__facts { display: grid; gap: var(--admin-space-2, 6px); margin: var(--admin-space-4, 10px) 0 0; padding-top: var(--admin-space-4, 10px); border-top: 1px solid var(--cc-border); }
.weight-card__facts div { display: flex; justify-content: space-between; gap: var(--admin-space-3, 8px); font-size: var(--admin-font-caption, 11px); line-height: 1.4; }
.weight-card__facts dt { color: var(--cc-muted); }
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3, 8px) var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-3, 8px) 0; border-block: 1px solid var(--cc-border); }
.weight-key-facts div { display: grid; min-width: 0; gap: var(--admin-space-1, 4px); }
.weight-key-facts span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.weight-key-facts strong { color: #101828; font-size: var(--admin-font-section, 14px); }
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: var(--admin-space-5, 12px); margin-top: 14px; }
.preview-weight label { display: grid; gap: var(--admin-space-2, 6px); color: #475467; font-size: var(--admin-font-caption, 12px); }
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
.correction-reason { display: grid; gap: var(--admin-space-2, 6px); margin-top: 14px; color: #475467; font-size: var(--admin-font-caption, 12px); }
@media (max-width: 720px) {
.weight-heading { flex-direction: column; }
.weight-heading__actions { width: 100%; align-items: stretch; flex-direction: column; }
.pallet-picker { width: 100%; min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
.weight-summary-group { width: 100%; justify-content: space-between; }
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 360px) {
.weight-grid { grid-template-columns: 1fr; }
.preview-weight { grid-template-columns: 1fr; }
.weight-key-facts { grid-template-columns: 1fr 1fr; gap: 12px; }
.weight-key-facts strong { font-size: 15px; }
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
.weight-card__facts dd { text-align: left; }
}
</style>