This commit is contained in:
2026-11-02 17:15:37 +08:00
parent 7704532e8f
commit f8408b8076
26 changed files with 595 additions and 52 deletions
@@ -13,6 +13,7 @@ import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>()
@@ -22,10 +23,14 @@ const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = r
const tab = ref('RECEIPT'), selected = ref('')
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
const looseboardEditor = ref<InstanceType<typeof DispatchLooseboardExecution>>()
const directLooseboard = ref(false)
const hasLooseboard = computed(() => detail.value?.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD') === true)
const tabs = computed(() => [...(hasLooseboard.value ? [{ key: 'LOOSEBOARD', label: '散板取货与送达' }] : []), ...(!directLooseboard.value ? [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }] : [])])
const deliveries = computed(() => detail.value?.documents.deliveries || [])
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy))
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy || looseboardEditor.value?.busy))
let sequence = 0
onBeforeUnmount(() => { sequence++ })
async function load() {
@@ -36,6 +41,7 @@ async function load() {
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料')
if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING'
if (!detail.value && value.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD')) tab.value = 'LOOSEBOARD'
detail.value = value
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
@@ -43,7 +49,7 @@ async function load() {
}
async function canLeave() {
if (busy.value) return false
if (!editor.value?.dirty) return true
if (!editor.value?.dirty && !looseboardEditor.value?.dirty) return true
try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false }
}
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
@@ -52,7 +58,7 @@ function selectTab(value: string) { if (!busy.value) { tab.value = value } }
function saved() { emit('saved') }
async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null; directLooseboard.value = false
if (visible && props.dispatchNo) void load()
}, { immediate: true })
</script>
@@ -60,14 +66,15 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in tabs" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<DispatchLooseboardExecution v-if="hasLooseboard" v-show="tab === 'LOOSEBOARD'" ref="looseboardEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @mode="directLooseboard = $event" @saved="loadingSaved" />
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
<el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div>
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
<p v-if="beforeDeparture && tab === 'RECEIPT'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
</template>
</AppDialog>
</template>
@@ -0,0 +1,92 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos } from '../api/dispatchLooseboardExecution'
import type { LooseboardExecution } from '../api/dispatchLooseboardExecution'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), uploadLooseboardPhoto: vi.fn(), saveLooseboardPhotos: vi.fn(), readLooseboardPhoto: vi.fn() }))
const value = (direct = true): LooseboardExecution => ({ dispatchNo: 'PC1', recordVersion: '3', stage: direct ? 'LOADING' : 'DELIVERING', direct, items: [{ itemId: '101', customerId: '1', orderNo: 'LB1', destination: 'TEST', packages: 2, pickupPhotos: [], arrivalPhotos: [], canPickup: true, canArrive: false }] })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getLooseboardExecution).mockResolvedValue(value()) })
it('shows pure looseboard photo flow and uploads to its authoritative item and node', async () => {
vi.mocked(uploadLooseboardPhoto).mockResolvedValue({ fileId: '77', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 10 })
vi.mocked(saveLooseboardPhotos).mockResolvedValue({ ...value(), stage: 'DELIVERING' })
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('全车为散板')
expect(wrapper.findAll('input[type=file]')).toHaveLength(2)
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
const input = wrapper.get('input[type=file]')
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.any(File))
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', { recordVersion: '3', idempotencyKey: expect.any(String), fileIds: ['77'] })
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('keeps pickup upload visible but disabled until mixed warehouse cargo departs', async () => {
const snapshot = value(false)
snapshot.stage = 'LOADING'
snapshot.items[0]!.canPickup = false
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('混排货物按原流程')
expect(wrapper.text()).toContain('上传取货照片')
expect(wrapper.text()).toContain('完成仓内拣货并确认装车后可上传')
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeDefined()
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
input.element.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
expect(uploadLooseboardPhoto).not.toHaveBeenCalled()
wrapper.unmount()
})
it('enables pickup upload for mixed cargo in delivery', async () => {
vi.mocked(getLooseboardExecution).mockResolvedValue(value(false))
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('enables mixed pickup from after loading using server eligibility', async () => {
const snapshot = value(false)
snapshot.stage = 'AFTER_LOADING'
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('keeps upload available after saving and appends multiple files to the current list', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old]
snapshot.items[0]!.canArrive = true
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
vi.mocked(uploadLooseboardPhoto)
.mockResolvedValueOnce({ ...old, fileId: '78', fileName: 'new1.png' })
.mockResolvedValueOnce({ ...old, fileId: '79', fileName: 'new2.png' })
vi.mocked(saveLooseboardPhotos).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeUndefined()
Object.defineProperty(input.element, 'files', { value: ['new1.png', 'new2.png'].map(name => new File(['test'], name, { type: 'image/png' })), configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledTimes(2)
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['77', '78', '79'] }))
wrapper.unmount()
})
it('deletes one photo while preserving the others in the versioned save', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old, { ...old, fileId: '78', fileName: 'keep.png' }]
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const updated = value(false)
updated.items[0]!.pickupPhotos = [snapshot.items[0]!.pickupPhotos[1]!]
vi.mocked(saveLooseboardPhotos).mockResolvedValue(updated)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('button[aria-label="删除取货照片old.png"]').trigger('click'); await flushPromises()
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['78'] }))
expect(wrapper.text()).not.toContain('old.png')
expect(wrapper.text()).not.toContain('keep.png')
expect(wrapper.find('button[aria-label="删除取货照片keep.png"]').exists()).toBe(true)
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: []; mode: [direct: boolean] }>()
const access = useAccessPreviewStore()
const snapshot = ref<LooseboardExecution | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const previews = ref<Record<string, string>>({})
const pending = ref<{ itemId: string; phase: LooseboardPhotoPhase; photos: LooseboardPhoto[] } | null>(null)
const dirty = computed(() => Boolean(pending.value?.photos.length))
const phases: { key: LooseboardPhotoPhase; label: string }[] = [{ key: 'PICKUP', label: '取货照片' }, { key: 'ARRIVAL', label: '送达照片' }]
function uploadReason(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase): string {
if (busy.value) return '正在处理,请稍候'
if (pending.value) return '请先保存已上传照片'
if ((phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length >= 20) return '每个节点最多20张照片'
if (phase === 'PICKUP' ? item.canPickup : item.canArrive) return ''
if (phase === 'ARRIVAL' && !item.pickupPhotos.length) return '请先上传取货照片'
if (phase === 'PICKUP' && !snapshot.value?.direct && snapshot.value?.stage === 'LOADING') return '完成仓内拣货并确认装车后可上传'
return '当前状态不可上传'
}
const number = props.dispatchNo
let active = true
function accept(value: LooseboardExecution) {
if (!active || number !== props.dispatchNo || value.dispatchNo !== number) throw new Error('排车身份已变化,请重新打开')
snapshot.value = value; emit('mode', value.direct)
}
async function images() {
for (const item of snapshot.value?.items || []) for (const phase of phases) {
const photos = phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
await Promise.allSettled(photos.map(async photo => {
if (previews.value[photo.fileId]) return
const blob = await readLooseboardPhoto(number, item.itemId, phase.key, photo)
if (active) previews.value[photo.fileId] = URL.createObjectURL(blob)
}))
}
}
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { accept(await getLooseboardExecution(number)); await images() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '散板履约资料读取失败') }
finally { busy.value = false }
}
async function save() {
if (!pending.value?.photos.length || !snapshot.value || busy.value) return
busy.value = true; error.value = null
const value = pending.value
try {
const item = snapshot.value.items.find(row => row.itemId === value.itemId)
if (!item) throw new Error('散板明细已变化,请重新打开')
const existing = value.phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, value.itemId, value.phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: [...existing, ...value.photos].map(photo => photo.fileId) }))
pending.value = null; await images(); ElMessage.success('散板照片已保存'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '保存失败,已上传照片保留,请核对当前状态后重试')
try {
accept(await getLooseboardExecution(number)); await images()
const item = snapshot.value?.items.find(row => row.itemId === value.itemId)
const bound = (value.phase === 'PICKUP' ? item?.pickupPhotos : item?.arrivalPhotos) || []
if (value.photos.every(photo => bound.some(file => file.fileId === photo.fileId))) { pending.value = null; emit('saved') }
} catch { /* 保留照片和当前错误,用户可重新读取。 */ }
}
} finally { busy.value = false }
}
async function upload(itemId: string, phase: LooseboardPhotoPhase, event: Event) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
const item = snapshot.value?.items.find(row => row.itemId === itemId)
if (!files.length || !item || uploadReason(item, phase) || !access.can(permission.dispatchEdit)) return
const existing = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
if (files.length + existing.length > 20 || files.some(file => !['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('每个节点最多20张JPG或PNG,每张不超过10MiB'); return }
busy.value = true; error.value = null; pending.value = { itemId, phase, photos: [] }; let failed = false
try { for (const file of files) { const photo = await uploadLooseboardPhoto(number, itemId, phase, file); if (!active) return; pending.value.photos.push(photo) } }
catch (cause) { failed = true; error.value = getApiErrorDetails(cause, '照片上传失败,已成功上传的照片可继续保存'); if (!pending.value?.photos.length) pending.value = null }
finally { busy.value = false }
if (!failed && pending.value?.photos.length) await save()
}
async function remove(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase, fileId: string) {
if (!snapshot.value || busy.value || pending.value || !access.can(permission.dispatchEdit) || !(phase === 'PICKUP' ? item.canPickup : item.canArrive)) return
busy.value = true; error.value = null
try {
const photos = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, item.itemId, phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: photos.filter(photo => photo.fileId !== fileId).map(photo => photo.fileId) }))
await images(); ElMessage.success('照片已删除'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '照片删除失败,请刷新后重试')
try { accept(await getLooseboardExecution(number)); await images() } catch { /* 保留原错误。 */ }
}
} finally { busy.value = false }
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(previews.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy, dirty })
</script>
<template>
<div v-loading="busy" class="looseboard-execution">
<el-button :disabled="busy" @click="load">刷新资料</el-button>
<LoadErrorState v-if="error" compact title="散板履约资料操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending"><span>已上传 {{ pending.photos.length }} 张,尚未保存。</span><el-button :disabled="busy" @click="save">保存已上传照片</el-button></div>
<section v-for="item in snapshot?.items || []" :key="item.itemId" class="looseboard-cargo">
<h3>{{ item.orderNo }} · {{ item.destination || '—' }} · {{ item.packages }} 箱</h3>
<div v-for="phase in phases" :key="phase.key" class="looseboard-node">
<h4>{{ phase.label }}</h4>
<label v-if="access.can(permission.dispatchEdit)" class="node-upload" :class="{ 'is-disabled': Boolean(uploadReason(item, phase.key)) }" :title="uploadReason(item, phase.key)">
上传{{ phase.label }}
<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`${item.orderNo}上传${phase.label}`" :disabled="Boolean(uploadReason(item, phase.key))" @change="upload(item.itemId, phase.key, $event)" />
</label>
<span v-if="access.can(permission.dispatchEdit) && uploadReason(item, phase.key)" class="upload-reason">{{ uploadReason(item, phase.key) }}</span>
<p v-if="!(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length">尚未上传{{ phase.label }}</p>
<div v-else class="node-photos">
<div v-for="photo in phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos" :key="photo.fileId" class="node-photo">
<el-image v-if="previews[photo.fileId]" :src="previews[photo.fileId]" :preview-src-list="[previews[photo.fileId]!]" preview-teleported fit="contain" class="node-image" :alt="phase.label" />
<button v-if="access.can(permission.dispatchEdit) && (phase.key === 'PICKUP' ? item.canPickup : item.canArrive)" type="button" class="node-photo-remove" :aria-label="`删除${phase.label}${photo.fileName}`" title="删除照片" :disabled="busy || Boolean(pending)" @click.stop="remove(item, phase.key, photo.fileId)"><span aria-hidden="true">×</span></button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.looseboard-execution { display: flex; flex-direction: column; gap: 12px; }
.looseboard-cargo { border: 1px solid var(--cc-border, var(--el-border-color)); }
.looseboard-cargo > h3 { margin: 0; padding: 8px 12px; font-size: 14px; background: var(--el-fill-color-light); }
.looseboard-node { padding: 8px 12px; border-top: 1px solid var(--el-border-color-lighter); }
.looseboard-node h4 { display: inline-block; margin: 0 16px 8px 0; font-size: 14px; }
.looseboard-node p { color: var(--el-text-color-secondary); }
.node-photos { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 4px; }
.node-photo { position: relative; flex: 0 0 160px; width: 160px; height: 100px; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); background: var(--el-fill-color-light); }
.node-image { display: block; width: 100%; height: 100%; border-radius: inherit; }
.node-photo-remove { position: absolute; top: 4px; right: 4px; z-index: 1; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: var(--el-color-danger); color: var(--el-color-white); font-size: 20px; line-height: 1; cursor: pointer; }
.node-photo-remove:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.node-photo-remove:disabled { opacity: .5; cursor: not-allowed; }
.node-upload { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; border: 1px solid var(--el-color-primary); border-radius: var(--el-border-radius-base); color: var(--el-color-primary); cursor: pointer; }
.node-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.node-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.upload-reason { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.node-upload.is-disabled input { cursor: not-allowed; }
.node-upload.is-disabled { border-color: var(--el-border-color); color: var(--el-text-color-disabled); cursor: not-allowed; }
</style>