This commit is contained in:
2026-08-17 17:49:10 +08:00
parent 911a3620e1
commit c7334b2cf8
30 changed files with 1043 additions and 249 deletions
@@ -0,0 +1,48 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/measurement/InboundMeasurementDialog.vue'), 'utf8')
const panelSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/measurement/InboundMeasurementPanel.vue'), 'utf8')
const taskListSource = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundTaskList.vue'), 'utf8')
const orderListSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
describe('InboundMeasurementDialog', () => {
it('owns the complete measurement workspace behind one centered dialog', () => {
expect(source).toContain('class="inbound-measurement-workspace-dialog"')
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
expect(source).toContain('height: 92vh')
expect(source).toContain('<WeightOcrPanel')
expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
expect(source).toContain('measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)')
})
it('is the only measurement dialog opened by both formal list entries', () => {
for (const entrySource of [taskListSource, orderListSource]) {
expect(entrySource).toContain('<InboundMeasurementDialog')
expect(entrySource).not.toContain('<InboundMeasurementPanel')
expect(entrySource).not.toContain('<WeightOcrPanel')
}
})
it('keeps draft saves open, closes only after submit, and refreshes parent entries consistently', () => {
expect(source).toContain('@saved="handleUpdated"')
expect(source).toContain('@updated="handleUpdated"')
expect(source).toContain('@submitted="handleSubmitted"')
expect(source).toContain("emit('updated')")
expect(source).toContain("emit('update:modelValue', false)")
expect(source).toContain("emit('submitted')")
expect(panelSource).toContain("emit('saved')")
expect(panelSource).toContain("emit('submitted')")
})
it('uses the existing permission-aware panels and protects unsaved work on close', () => {
expect(source).toContain('measurementPanelRef.value?.isDirty')
expect(source).toContain('当前测量数据尚未保存')
expect(source).toContain(':close-on-click-modal="false"')
expect(source).toContain(':before-close="beforeClose"')
expect(source).toContain('measurementPanelRef?.canEdit')
expect(source).toContain('measurementPanelRef?.canSubmit')
})
})
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
const props = defineProps<{
modelValue: boolean
customerId: string
inboundId: string
inboundCode?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: []
submitted: []
closed: []
}>()
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
const title = computed(() => `测量 · ${props.inboundCode?.trim() || props.inboundId}`)
async function mayClose() {
if (!measurementPanelRef.value?.isDirty) return true
try {
await ElMessageBox.confirm('当前测量数据尚未保存,关闭后将丢失修改。确定关闭吗?', '关闭测量', {
type: 'warning',
confirmButtonText: '放弃修改并关闭',
cancelButtonText: '继续编辑',
})
return true
} catch {
return false
}
}
async function requestClose() {
if (await mayClose()) emit('update:modelValue', false)
}
async function beforeClose(done: () => void) {
if (await mayClose()) done()
}
function handleWeightSummaryChanged(actualGrossWeightKg: number | null) {
measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)
}
function handleUpdated() {
emit('updated')
}
function handleSubmitted() {
emit('update:modelValue', false)
emit('submitted')
}
</script>
<template>
<el-dialog
:model-value="modelValue"
class="inbound-measurement-workspace-dialog"
:title="title"
width="min(1240px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
append-to-body
:close-on-click-modal="false"
:before-close="beforeClose"
@update:model-value="emit('update:modelValue', $event)"
@closed="emit('closed')"
>
<div class="measurement-dialog-workspace">
<section class="surface-panel measurement-dialog-weight" aria-label="称重图片、OCR 与托盘核对">
<WeightOcrPanel
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleUpdated"
@summary-changed="handleWeightSummaryChanged"
/>
</section>
<InboundMeasurementPanel
ref="measurementPanelRef"
:customer-id="customerId"
:inbound-id="inboundId"
@updated="handleUpdated"
@saved="handleUpdated"
@submitted="handleSubmitted"
/>
</div>
<template #footer>
<div class="measurement-dialog-footer">
<el-button :disabled="Boolean(measurementPanelRef?.saving || measurementPanelRef?.submitting)" @click="requestClose">取消</el-button>
<el-button
v-if="measurementPanelRef?.canEdit"
type="primary"
plain
:loading="measurementPanelRef?.saving"
:disabled="!measurementPanelRef?.mayMaintain"
@click="measurementPanelRef?.saveDraft()"
>保存测量数据</el-button>
<el-button
v-if="measurementPanelRef?.canSubmit"
type="primary"
:loading="measurementPanelRef?.submitting"
:disabled="!measurementPanelRef?.maySubmit"
@click="measurementPanelRef?.submitForReview()"
>提交审核</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.measurement-dialog-workspace { display: grid; min-width: 0; gap: 20px; }
.measurement-dialog-weight { min-width: 0; padding: 20px; }
.measurement-dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.measurement-dialog-footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) {
.measurement-dialog-weight { padding: 14px; }
.measurement-dialog-footer { display: grid; grid-template-columns: 1fr; }
.measurement-dialog-footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
</style>
<style>
.inbound-measurement-workspace-dialog.el-dialog { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
.inbound-measurement-workspace-dialog .el-dialog__header { flex: 0 0 auto; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--cc-border); }
.inbound-measurement-workspace-dialog .el-dialog__body { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
.inbound-measurement-workspace-dialog .el-dialog__footer { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
.inbound-measurement-workspace-dialog .measurement-panel { border: 0; border-radius: 0; box-shadow: none; }
.inbound-measurement-workspace-dialog .measurement-panel__heading .measurement-panel__actions,
.inbound-measurement-workspace-dialog .measurement-panel__footer { display: none; }
@media (max-width: 720px) {
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); }
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 16px; }
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 14px; }
}
@media (max-width: 360px) {
.inbound-measurement-workspace-dialog.el-dialog { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
.inbound-measurement-workspace-dialog .el-dialog__header { padding: 14px 12px; }
.inbound-measurement-workspace-dialog .el-dialog__body { padding: 10px; }
}
</style>
@@ -24,6 +24,8 @@ const props = defineProps<{
const emit = defineEmits<{
changed: []
updated: []
saved: []
submitted: []
}>()
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
@@ -360,6 +362,7 @@ async function saveDraft() {
form.version = version
ElMessage.success('确认箱数已保存,计算结果已由服务端更新')
await loadMeasurement()
emit('saved')
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
@@ -394,6 +397,7 @@ async function submitForReview() {
})
ElMessage.success('测量数据已提交审核')
await loadMeasurement()
emit('submitted')
emit('changed')
} catch (error) {
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
@@ -459,7 +463,7 @@ watch(
onBeforeUnmount(releaseImagePreview)
defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })
defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
</script>
<template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ImageOff } from '@lucide/vue'
import { downloadWeightThumbnail } from '../../weight/api/weightOcr'
const props = withDefaults(defineProps<{
customerId: string
inboundId: string
fileId?: string | null
alt: string
emptyText?: string
}>(), {
fileId: null,
emptyText: '未绑定测量图片',
})
defineEmits<{ open: [] }>()
const imageUrl = ref('')
const loading = ref(false)
const failed = ref(false)
let requestId = 0
function releaseImage() {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
}
async function load() {
const currentRequest = ++requestId
releaseImage()
failed.value = false
if (!props.fileId) {
loading.value = false
return
}
loading.value = true
try {
const blob = await downloadWeightThumbnail(props.customerId, props.inboundId, props.fileId)
if (currentRequest !== requestId) return
imageUrl.value = URL.createObjectURL(blob)
} catch {
if (currentRequest === requestId) failed.value = true
} finally {
if (currentRequest === requestId) loading.value = false
}
}
watch(() => [props.customerId, props.inboundId, props.fileId], load, { immediate: true })
onBeforeUnmount(() => {
requestId += 1
releaseImage()
})
</script>
<template>
<button
class="measurement-evidence-thumb"
type="button"
:disabled="!fileId"
:aria-label="fileId ? `查看${alt}` : emptyText"
@click="$emit('open')"
>
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
<span v-else class="measurement-evidence-thumb__state" :class="{ 'is-loading': loading }">
<ImageOff v-if="failed || !fileId" :size="22" aria-hidden="true" />
<span>{{ !fileId ? emptyText : failed ? '图片加载失败,点击重试' : '图片加载中' }}</span>
</span>
</button>
</template>
<style scoped>
.measurement-evidence-thumb { display: block; width: 100%; min-width: 96px; aspect-ratio: 4 / 3; overflow: hidden; padding: 0; border: 1px solid var(--cc-border); border-radius: 6px; background: #f2f4f7; cursor: zoom-in; }
.measurement-evidence-thumb:disabled { cursor: default; }
.measurement-evidence-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.measurement-evidence-thumb__state { display: grid; width: 100%; height: 100%; place-items: center; align-content: center; gap: 7px; padding: 8px; color: #667085; font-size: 12px; }
.measurement-evidence-thumb__state.is-loading { color: #98a2b3; }
.measurement-evidence-thumb:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
</style>
+2 -1
View File
@@ -1,4 +1,5 @@
export { default as InboundMeasurementPanel } from './InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './components/MeasurementEvidenceThumbnail.vue'
export * from './api'
export type * from './types'