Files
admin-web/src/modules/customers/components/CustomerBillingRulesPanel.vue
T

300 lines
13 KiB
Vue
Raw Normal View History

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>