2026-08-05 15:54:59 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
2026-08-07 19:23:43 +08:00
|
|
|
|
import { Calculator, Pencil, RefreshCw, Ruler } from '@lucide/vue'
|
2026-08-05 15:54:59 +08:00
|
|
|
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
|
|
|
|
|
import {
|
2026-08-07 19:23:43 +08:00
|
|
|
|
getCustomerDimensionConfig,
|
|
|
|
|
|
updateCustomerDimensionConfig,
|
|
|
|
|
|
type CustomerDimensionConfig,
|
|
|
|
|
|
type CustomerDimensionConfigFormModel,
|
|
|
|
|
|
} from '@/modules/dimension-config'
|
2026-08-05 15:54:59 +08:00
|
|
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
2026-08-07 19:23:43 +08:00
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-08-05 15:54:59 +08:00
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
customerId: string
|
|
|
|
|
|
customerName: string
|
|
|
|
|
|
customerCode?: string
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
2026-08-07 19:23:43 +08:00
|
|
|
|
const canView = computed(() => access.can('firstleg:dimension-config:view'))
|
|
|
|
|
|
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
|
|
|
|
|
|
const rule = ref<CustomerDimensionConfig | null>(null)
|
2026-08-05 15:54:59 +08:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const editorVisible = ref(false)
|
|
|
|
|
|
const editorSaving = ref(false)
|
|
|
|
|
|
const editorDirty = ref(false)
|
|
|
|
|
|
const editorForm = ref<FormInstance>()
|
2026-08-07 19:23:43 +08:00
|
|
|
|
const form = reactive<CustomerDimensionConfigFormModel>({ edgeAdditionCm: 2 })
|
2026-08-05 15:54:59 +08:00
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
const formRules: FormRules<CustomerDimensionConfigFormModel> = {
|
|
|
|
|
|
edgeAdditionCm: [{
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
type: 'number',
|
|
|
|
|
|
min: 0,
|
|
|
|
|
|
max: 100,
|
|
|
|
|
|
message: '请输入 0 至 100 之间的每侧加宽值',
|
|
|
|
|
|
trigger: ['blur', 'change'],
|
|
|
|
|
|
}],
|
2026-08-05 15:54:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
const preview = computed(() => {
|
|
|
|
|
|
const addition = Number.isFinite(form.edgeAdditionCm) ? form.edgeAdditionCm : 0
|
|
|
|
|
|
const length = 30 + addition * 2
|
|
|
|
|
|
const width = 20 + addition * 2
|
|
|
|
|
|
const height = 20 + addition * 2
|
|
|
|
|
|
return {
|
|
|
|
|
|
length,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
volume: (length * width * height / 1_000_000).toFixed(3),
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-05 15:54:59 +08:00
|
|
|
|
const { captureBaseline } = useUnsavedWorkRegistration({
|
2026-08-07 19:23:43 +08:00
|
|
|
|
key: `customer-dimension-rule-${props.customerId}`,
|
|
|
|
|
|
label: computed(() => `${props.customerName}的尺寸计算规则`),
|
2026-08-05 15:54:59 +08:00
|
|
|
|
isOpen: editorVisible,
|
|
|
|
|
|
model: form,
|
2026-08-07 19:23:43 +08:00
|
|
|
|
requiredPermission: computed(() => 'firstleg:dimension-config:edit'),
|
2026-08-05 15:54:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(form, () => {
|
|
|
|
|
|
if (editorVisible.value) editorDirty.value = true
|
|
|
|
|
|
}, { deep: true })
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
watch(() => props.customerId, () => {
|
|
|
|
|
|
editorVisible.value = false
|
|
|
|
|
|
void loadRule()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRule() {
|
2026-08-05 15:54:59 +08:00
|
|
|
|
if (!canView.value) return
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = null
|
|
|
|
|
|
try {
|
2026-08-07 19:23:43 +08:00
|
|
|
|
rule.value = await getCustomerDimensionConfig(props.customerId)
|
2026-08-05 15:54:59 +08:00
|
|
|
|
} catch (error) {
|
2026-08-07 19:23:43 +08:00
|
|
|
|
rule.value = null
|
|
|
|
|
|
loadError.value = getApiErrorDetails(error, '尺寸计算规则加载失败,请稍后重试')
|
2026-08-05 15:54:59 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
async function openEditor() {
|
|
|
|
|
|
if (!rule.value) return
|
|
|
|
|
|
form.edgeAdditionCm = rule.value.edgeAdditionCm
|
|
|
|
|
|
await nextTick()
|
2026-08-05 15:54:59 +08:00
|
|
|
|
editorDirty.value = false
|
|
|
|
|
|
editorVisible.value = true
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
captureBaseline()
|
|
|
|
|
|
editorForm.value?.clearValidate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmAbandon(): Promise<boolean> {
|
|
|
|
|
|
try {
|
2026-08-07 19:23:43 +08:00
|
|
|
|
await ElMessageBox.confirm('每侧加宽值尚未保存,确定放弃修改吗?', '放弃编辑', {
|
|
|
|
|
|
confirmButtonText: '放弃',
|
|
|
|
|
|
cancelButtonText: '继续编辑',
|
|
|
|
|
|
type: 'warning',
|
2026-08-05 15:54:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
return true
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function beforeEditorClose(done: () => void) {
|
|
|
|
|
|
if (editorSaving.value) return
|
|
|
|
|
|
if (!editorDirty.value || await confirmAbandon()) done()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function closeEditor() {
|
2026-08-07 19:23:43 +08:00
|
|
|
|
if (editorSaving.value) return
|
|
|
|
|
|
if (editorDirty.value && !await confirmAbandon()) return
|
2026-08-05 15:54:59 +08:00
|
|
|
|
editorVisible.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
async function saveRule() {
|
|
|
|
|
|
if (!rule.value || !await editorForm.value?.validate()) return
|
2026-08-05 15:54:59 +08:00
|
|
|
|
editorSaving.value = true
|
|
|
|
|
|
try {
|
2026-08-07 19:23:43 +08:00
|
|
|
|
rule.value = await updateCustomerDimensionConfig(props.customerId, {
|
|
|
|
|
|
edgeAdditionCm: form.edgeAdditionCm,
|
|
|
|
|
|
version: rule.value.version,
|
2026-08-05 15:54:59 +08:00
|
|
|
|
})
|
|
|
|
|
|
editorDirty.value = false
|
|
|
|
|
|
editorVisible.value = false
|
2026-08-07 19:23:43 +08:00
|
|
|
|
ElMessage.success('尺寸计算规则已保存')
|
2026-08-05 15:54:59 +08:00
|
|
|
|
} catch (error) {
|
2026-08-07 19:23:43 +08:00
|
|
|
|
ElMessage.error(getApiErrorDetails(error, '尺寸计算规则保存失败,请刷新后重试').message)
|
2026-08-05 15:54:59 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
editorSaving.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
function formatCm(value: number) {
|
|
|
|
|
|
return Number(value.toFixed(2)).toString()
|
2026-08-05 15:54:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
function formatDate(value: string | null) {
|
|
|
|
|
|
return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录'
|
2026-08-05 15:54:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
onMounted(loadRule)
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-08-07 19:23:43 +08:00
|
|
|
|
<div class="dimension-rule-panel">
|
|
|
|
|
|
<header class="rule-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>尺寸计算规则</h2>
|
|
|
|
|
|
<p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的外箱尺寸加边配置</p>
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</div>
|
2026-08-07 19:23:43 +08:00
|
|
|
|
<div class="heading-actions">
|
|
|
|
|
|
<el-button v-if="canView" :loading="loading" aria-label="刷新尺寸计算规则" @click="loadRule">
|
|
|
|
|
|
<RefreshCw :size="16" />刷新
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button v-if="canEdit && rule" type="primary" :disabled="loading" @click="openEditor">
|
|
|
|
|
|
<Pencil :size="16" />编辑规则
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="!canView" class="rule-state">
|
|
|
|
|
|
<Ruler :size="30" />
|
|
|
|
|
|
<strong>暂无计费规则查看权限</strong>
|
|
|
|
|
|
<p>需要权限 firstleg:dimension-config:view。</p>
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-07 19:23:43 +08:00
|
|
|
|
<div v-else-if="loadError" class="rule-error" role="alert">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>尺寸计算规则加载失败</strong>
|
|
|
|
|
|
<p>{{ loadError.message }}</p>
|
|
|
|
|
|
<ApiErrorMeta :details="loadError" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<el-button type="primary" plain :loading="loading" @click="loadRule">重新加载</el-button>
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</div>
|
2026-08-07 19:23:43 +08:00
|
|
|
|
|
|
|
|
|
|
<section v-else v-loading="loading" class="rule-content" aria-live="polite">
|
|
|
|
|
|
<div class="formula-band">
|
|
|
|
|
|
<div><span>外径计算</span><strong>实际内径 + 每侧加宽 × 2</strong></div>
|
|
|
|
|
|
<div><span>体积计算</span><strong>外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</strong></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="rule" class="rule-summary">
|
|
|
|
|
|
<div class="addition-value">
|
|
|
|
|
|
<span>当前每侧加宽</span>
|
|
|
|
|
|
<strong>+{{ formatCm(rule.edgeAdditionCm) }}<small>cm</small></strong>
|
|
|
|
|
|
<p>长度、宽度和高度的两侧均使用该加宽值。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="example-preview">
|
|
|
|
|
|
<div class="example-title"><Calculator :size="18" /><strong>计算示例</strong></div>
|
|
|
|
|
|
<dl>
|
|
|
|
|
|
<div><dt>客户填写的实际内径</dt><dd>30 × 20 × 20 cm</dd></div>
|
|
|
|
|
|
<div><dt>计算后的外径</dt><dd>{{ formatCm(30 + rule.edgeAdditionCm * 2) }} × {{ formatCm(20 + rule.edgeAdditionCm * 2) }} × {{ formatCm(20 + rule.edgeAdditionCm * 2) }} cm</dd></div>
|
|
|
|
|
|
<div><dt>外箱体积</dt><dd>{{ (((30 + rule.edgeAdditionCm * 2) * (20 + rule.edgeAdditionCm * 2) * (20 + rule.edgeAdditionCm * 2)) / 1_000_000).toFixed(3) }} CBM</dd></div>
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-if="rule" class="updated-at">最后更新:{{ formatDate(rule.updatedAt) }}</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<AppDialog
|
|
|
|
|
|
v-model="editorVisible"
|
|
|
|
|
|
title="编辑尺寸计算规则"
|
|
|
|
|
|
width="min(720px, calc(100vw - 28px))"
|
|
|
|
|
|
:confirm-loading="editorSaving"
|
|
|
|
|
|
:show-cancel="false"
|
|
|
|
|
|
:before-close="beforeEditorClose"
|
|
|
|
|
|
@confirm="saveRule"
|
2026-08-05 15:54:59 +08:00
|
|
|
|
>
|
2026-08-07 19:23:43 +08:00
|
|
|
|
<div class="editor-content">
|
|
|
|
|
|
<div class="customer-context"><span>适用客户</span><strong>{{ customerName }}</strong><small>{{ customerCode || customerId }}</small></div>
|
|
|
|
|
|
<p class="editor-formula">外径 = 实际内径 + 每侧加宽 × 2;体积 = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
|
|
|
|
|
|
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top">
|
|
|
|
|
|
<el-form-item label="每侧加宽(cm)" prop="edgeAdditionCm">
|
|
|
|
|
|
<el-input-number v-model="form.edgeAdditionCm" :min="0" :max="100" :precision="2" :step="0.5" controls-position="right" />
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
<div class="live-preview">
|
|
|
|
|
|
<span>示例:实际内径 30 × 20 × 20 cm</span>
|
|
|
|
|
|
<strong>外径 {{ formatCm(preview.length) }} × {{ formatCm(preview.width) }} × {{ formatCm(preview.height) }} cm</strong>
|
|
|
|
|
|
<small>体积 {{ preview.volume }} CBM</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<div class="dialog-actions">
|
|
|
|
|
|
<el-button :disabled="editorSaving" @click="closeEditor">取消</el-button>
|
|
|
|
|
|
<el-button type="primary" :loading="editorSaving" @click="saveRule">保存规则</el-button>
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-08-07 19:23:43 +08:00
|
|
|
|
</AppDialog>
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-08-07 19:23:43 +08:00
|
|
|
|
.dimension-rule-panel { min-width: 0; }
|
|
|
|
|
|
.rule-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
|
|
|
|
|
.rule-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }
|
|
|
|
|
|
.rule-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; }
|
|
|
|
|
|
.heading-actions { display: flex; align-items: center; gap: 8px; }
|
|
|
|
|
|
.heading-actions :deep(.el-button) { margin-left: 0; }
|
|
|
|
|
|
.rule-content { min-height: 260px; }
|
|
|
|
|
|
.formula-band { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(360px, 1.2fr); gap: 1px; margin: 0 -20px; background: var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
|
|
|
|
|
.formula-band > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 14px 20px; background: #f8fafc; }
|
|
|
|
|
|
.formula-band span { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.formula-band strong { color: #344054; font-size: 13px; font-weight: 600; }
|
|
|
|
|
|
.rule-summary { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr); gap: 32px; padding: 28px 0 22px; }
|
|
|
|
|
|
.addition-value { display: flex; min-width: 0; flex-direction: column; justify-content: center; padding: 6px 24px; border-right: 1px solid var(--cc-border); }
|
|
|
|
|
|
.addition-value > span { color: var(--cc-muted); font-size: 13px; }
|
|
|
|
|
|
.addition-value > strong { margin-top: 8px; color: #6941c6; font-size: 38px; line-height: 1; }
|
|
|
|
|
|
.addition-value small { margin-left: 5px; font-size: 15px; font-weight: 600; }
|
|
|
|
|
|
.addition-value p { margin: 12px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.7; }
|
|
|
|
|
|
.example-preview { min-width: 0; }
|
|
|
|
|
|
.example-title { display: flex; align-items: center; gap: 8px; color: #344054; }
|
|
|
|
|
|
.example-preview dl { display: grid; gap: 12px; margin: 18px 0 0; }
|
|
|
|
|
|
.example-preview dl > div { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1.2fr); gap: 16px; }
|
|
|
|
|
|
.example-preview dt { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.example-preview dd { margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
|
|
|
|
|
|
.updated-at { margin: 0; padding-top: 16px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.rule-state { display: flex; min-height: 260px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #8797b2; text-align: center; }
|
|
|
|
|
|
.rule-state strong { color: #475467; font-size: 13px; }
|
|
|
|
|
|
.rule-state p { margin: 0; color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.rule-error { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px; background: #fff4f3; }
|
|
|
|
|
|
.rule-error strong { color: #7a271a; }
|
|
|
|
|
|
.rule-error p { margin: 5px 0; color: #b42318; font-size: 12px; }
|
|
|
|
|
|
.editor-content { display: grid; gap: 20px; }
|
|
|
|
|
|
.customer-context { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 8px; background: #f5f7ff; }
|
|
|
|
|
|
.customer-context span,.customer-context small { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.customer-context strong { color: #25324a; }
|
|
|
|
|
|
.editor-formula { margin: 0; padding: 13px 15px; background: #f8fafc; color: #475467; font-size: 13px; line-height: 1.7; }
|
|
|
|
|
|
.editor-content :deep(.el-input-number) { width: min(260px, 100%); }
|
|
|
|
|
|
.live-preview { display: grid; gap: 6px; padding: 16px; border: 1px solid #d9d6fe; border-radius: 8px; background: #f8f7ff; }
|
|
|
|
|
|
.live-preview span,.live-preview small { color: var(--cc-muted); font-size: 12px; }
|
|
|
|
|
|
.live-preview strong { color: #42307d; }
|
|
|
|
|
|
.dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
|
|
|
|
|
.dialog-actions :deep(.el-button) { margin-left: 0; }
|
|
|
|
|
|
@media (max-width: 760px) {
|
|
|
|
|
|
.rule-heading { align-items: stretch; flex-direction: column; }
|
|
|
|
|
|
.heading-actions { width: 100%; }
|
|
|
|
|
|
.heading-actions :deep(.el-button) { min-height: 44px; flex: 1; }
|
|
|
|
|
|
.formula-band,.rule-summary { grid-template-columns: 1fr; }
|
|
|
|
|
|
.formula-band { margin: 0; }
|
|
|
|
|
|
.addition-value { padding: 4px 0 20px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
|
|
|
|
|
.rule-summary { gap: 22px; }
|
|
|
|
|
|
.rule-error { align-items: stretch; flex-direction: column; }
|
|
|
|
|
|
.customer-context { grid-template-columns: 1fr; gap: 4px; }
|
|
|
|
|
|
.example-preview dl > div { grid-template-columns: 1fr; gap: 4px; }
|
|
|
|
|
|
.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; }
|
|
|
|
|
|
}
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</style>
|