This commit is contained in:
2026-08-07 20:07:37 +08:00
parent 28d0b81c5a
commit 99413ee155
27 changed files with 1065 additions and 595 deletions
@@ -2,25 +2,28 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
type CustomerDimensionConfig,
} from '@/modules/dimension-config'
import { getCustomerDimensionConfig, updateCustomerDimensionConfig, type CustomerDimensionConfig } from '@/modules/dimension-config'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
vi.mock('@/modules/dimension-config', () => ({
getCustomerDimensionConfig: vi.fn(),
updateCustomerDimensionConfig: vi.fn(),
}))
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn() }))
const rule: CustomerDimensionConfig = {
const boxes = [
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
['MEDIUM', '中箱', 40, 30, 30, '标准中箱'],
['LARGE', '大箱', 50, 40, 40, '标准大箱'],
['EXTRA_LARGE', '特大箱', 60, 50, 50, '特大箱'],
] as const
const config: CustomerDimensionConfig = {
tenantId: '8',
edgeAdditionCm: 2,
version: 3,
updatedAt: '2026-08-07T10:20:00',
rules: boxes.map(([boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, remark], index) => ({
id: String(100 + index),
boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 2,
outerLengthCm: innerLengthCm + 4, outerWidthCm: innerWidthCm + 4, outerHeightCm: innerHeightCm + 4,
volumeCbm: Number(((innerLengthCm + 4) * (innerWidthCm + 4) * (innerHeightCm + 4) / 1_000_000).toFixed(3)),
remark, version: 3, createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:20:00',
})),
}
enableAutoUnmount(afterEach)
@@ -29,79 +32,47 @@ describe('CustomerBillingRulesPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['firstleg:dimension-config:view', 'firstleg:dimension-config:edit']
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['firstleg:dimension-config:view', 'firstleg:dimension-config:edit']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(rule)
vi.mocked(updateCustomerDimensionConfig).mockResolvedValue({ ...rule, version: 4 })
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(config)
vi.mocked(updateCustomerDimensionConfig).mockResolvedValue(config)
})
it('shows one customer dimension rule instead of the old pricing versions', async () => {
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流', customerCode: 'TEN8' },
global: { plugins: [ElementPlus] },
})
it('renders the four fixed carton dimension rows and calculated values', async () => {
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getCustomerDimensionConfig).toHaveBeenCalledWith('8')
expect(document.body.textContent).toContain('尺寸计算规则')
expect(document.body.textContent).toContain('当前每侧加宽')
expect(document.body.textContent).toContain('+2cm')
expect(document.body.textContent).toContain('34 × 24 × 24 cm')
expect(document.body.textContent).toContain('0.020 CBM')
expect(document.body.textContent).not.toContain('规则版本')
expect(document.body.textContent).not.toContain('小箱')
expect(document.body.textContent).toContain('按箱规尺寸计算')
expect(document.body.textContent).toContain('小箱')
expect(document.body.textContent).toContain('特大箱')
expect(document.body.textContent).toContain('34 × 24 × 24')
expect(document.body.textContent).toContain('0.020')
expect(document.body.textContent).toContain('标准中箱')
})
it('edits the single value and submits the optimistic lock version', async () => {
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流' },
global: { plugins: [ElementPlus] },
})
it('submits all four rows in one update request', async () => {
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const editButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '编辑规则')
editButton?.click()
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click()
await flushPromises()
expect(document.body.textContent).toContain('编辑尺寸计算规则')
expect(document.body.textContent).toContain('实际内径 30 × 20 × 20 cm')
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '保存规则')
saveButton?.click()
await flushPromises()
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', {
edgeAdditionCm: 2,
version: 3,
})
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存规则')
saveButton?.click(); await flushPromises()
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', { rules: expect.arrayContaining([expect.objectContaining({ boxType: 'SMALL', version: 3 }), expect.objectContaining({ boxType: 'EXTRA_LARGE', version: 3 })]) })
const submittedRules = vi.mocked(updateCustomerDimensionConfig).mock.calls[0][1].rules
expect(submittedRules).toHaveLength(4)
expect(Object.keys(submittedRules[0]).sort()).toEqual([
'boxType', 'edgeAdditionCm', 'innerHeightCm', 'innerLengthCm', 'innerWidthCm', 'remark', 'version',
])
})
it('keeps the editor open when the versioned save fails', async () => {
vi.mocked(updateCustomerDimensionConfig).mockRejectedValueOnce(new Error('规则已被其他人更新'))
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流' },
global: { plugins: [ElementPlus] },
})
it('keeps the editor open when the whole-set save fails', async () => {
vi.mocked(updateCustomerDimensionConfig).mockRejectedValueOnce(new Error('规则版本冲突'))
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const editButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '编辑规则')
editButton?.click()
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click(); await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存规则')?.click()
await flushPromises()
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '保存规则')
saveButton?.click()
await flushPromises()
expect(document.body.textContent).toContain('编辑尺寸计算规则')
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', expect.objectContaining({ version: 3 }))
expect(document.body.textContent).toContain('编辑箱规尺寸')
})
})
@@ -1,64 +1,52 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { Calculator, Pencil, RefreshCw, Ruler } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Pencil, Ruler } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
type CustomerDimensionBoxType,
type CustomerDimensionConfig,
type CustomerDimensionConfigFormModel,
type CustomerDimensionRule,
} from '@/modules/dimension-config'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
const props = defineProps<{
customerId: string
customerName: string
customerCode?: string
}>()
const props = defineProps<{ customerId: string; customerName: string; customerCode?: string }>()
const access = useAccessPreviewStore()
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)
const config = ref<CustomerDimensionConfig | null>(null)
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>()
const form = reactive<CustomerDimensionConfigFormModel>({ edgeAdditionCm: 2 })
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
const formRules: FormRules<CustomerDimensionConfigFormModel> = {
edgeAdditionCm: [{
required: true,
type: 'number',
min: 0,
max: 100,
message: '请输入 0 至 100 之间的每侧加宽值',
trigger: ['blur', 'change'],
}],
const BOX_ORDER: readonly CustomerDimensionBoxType[] = ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']
const BOX_NAMES: Record<CustomerDimensionBoxType, string> = {
SMALL: '小箱', MEDIUM: '中箱', LARGE: '大箱', EXTRA_LARGE: '特大箱',
}
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),
}
})
const columns: DataTableColumn[] = [
{ key: 'boxTypeName', label: '箱规类型', minWidth: 110, slotName: 'boxTypeName' },
{ key: 'inner', label: '内径尺寸(长×宽×高 cm)', minWidth: 210, slotName: 'inner' },
{ key: 'edgeAdditionCm', label: '外边加宽(cm)', width: 150, slotName: 'edgeAdditionCm' },
{ key: 'outer', label: '外径尺寸(长×宽×高 cm)', minWidth: 210, slotName: 'outer' },
{ key: 'volumeCbm', label: '体积(CBM)', width: 130, slotName: 'volumeCbm' },
{ key: 'remark', label: '备注', minWidth: 140, prop: 'remark' },
]
const { captureBaseline } = useUnsavedWorkRegistration({
key: `customer-dimension-rule-${props.customerId}`,
label: computed(() => `${props.customerName}的尺寸计算规则`),
key: `customer-dimension-config-${props.customerId}`,
label: computed(() => `${props.customerName}的箱规尺寸规则`),
isOpen: editorVisible,
model: form,
requiredPermission: computed(() => 'firstleg:dimension-config:edit'),
@@ -70,26 +58,36 @@ watch(form, () => {
watch(() => props.customerId, () => {
editorVisible.value = false
void loadRule()
void loadConfig()
})
async function loadRule() {
function sortRules(rules: CustomerDimensionRule[]) {
return [...rules].sort((a, b) => BOX_ORDER.indexOf(a.boxType) - BOX_ORDER.indexOf(b.boxType))
.map((rule) => ({ ...rule, boxTypeName: BOX_NAMES[rule.boxType] || rule.boxTypeName }))
}
async function loadConfig() {
if (!canView.value) return
loading.value = true
loadError.value = null
try {
rule.value = await getCustomerDimensionConfig(props.customerId)
config.value = await getCustomerDimensionConfig(props.customerId)
config.value.rules = sortRules(config.value.rules)
} catch (error) {
rule.value = null
loadError.value = getApiErrorDetails(error, '尺寸计算规则加载失败,请稍后重试')
config.value = null
loadError.value = getApiErrorDetails(error, '箱规尺寸规则加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function cloneRules(rules: CustomerDimensionRule[]) {
return sortRules(rules).map((rule) => ({ ...rule }))
}
async function openEditor() {
if (!rule.value) return
form.edgeAdditionCm = rule.value.edgeAdditionCm
if (!config.value) return
form.rules = cloneRules(config.value.rules)
await nextTick()
editorDirty.value = false
editorVisible.value = true
@@ -100,10 +98,8 @@ async function openEditor() {
async function confirmAbandon(): Promise<boolean> {
try {
await ElMessageBox.confirm('每侧加宽值尚未保存,确定放弃修改吗?', '放弃编辑', {
confirmButtonText: '放弃',
cancelButtonText: '继续编辑',
type: 'warning',
await ElMessageBox.confirm('箱规尺寸尚未保存,确定放弃修改吗?', '放弃编辑', {
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
})
return true
} catch {
@@ -122,178 +118,111 @@ async function closeEditor() {
editorVisible.value = false
}
async function saveRule() {
if (!rule.value || !await editorForm.value?.validate()) return
function validateRules() {
if (form.rules.length !== BOX_ORDER.length || form.rules.some((rule) => !BOX_ORDER.includes(rule.boxType))) {
ElMessage.error('必须完整保存小箱、中箱、大箱、特大箱四条规则')
return false
}
if (new Set(form.rules.map((rule) => rule.boxType)).size !== BOX_ORDER.length) {
ElMessage.error('箱规类型不能重复')
return false
}
if (form.rules.some((rule) => ![rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm].every((value) => Number.isFinite(value) && value > 0))) {
ElMessage.error('内径长、宽、高必须大于 0')
return false
}
if (form.rules.some((rule) => !Number.isFinite(rule.edgeAdditionCm) || rule.edgeAdditionCm < 0.001 || rule.edgeAdditionCm > 100)) {
ElMessage.error('外边加宽必须在 0.001 至 100cm 之间')
return false
}
if (form.rules.some((rule) => (rule.remark || '').length > 500)) {
ElMessage.error('备注不能超过 500 个字符')
return false
}
return true
}
async function saveConfig() {
if (!config.value || !validateRules()) return
editorSaving.value = true
try {
rule.value = await updateCustomerDimensionConfig(props.customerId, {
edgeAdditionCm: form.edgeAdditionCm,
version: rule.value.version,
})
const rules = form.rules.map((rule) => ({
boxType: rule.boxType,
innerLengthCm: rule.innerLengthCm,
innerWidthCm: rule.innerWidthCm,
innerHeightCm: rule.innerHeightCm,
edgeAdditionCm: rule.edgeAdditionCm,
remark: rule.remark,
version: rule.version,
}))
config.value = await updateCustomerDimensionConfig(props.customerId, { rules })
config.value.rules = sortRules(config.value.rules)
editorDirty.value = false
editorVisible.value = false
ElMessage.success('尺寸计算规则已保存')
ElMessage.success('箱规尺寸规则已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '尺寸计算规则保存失败,请刷新后重试').message)
ElMessage.error(getApiErrorDetails(error, '箱规尺寸规则保存失败,请刷新后重试').message)
} finally {
editorSaving.value = false
}
}
function formatCm(value: number) {
return Number(value.toFixed(2)).toString()
function formatDimension(...values: number[]) {
return values.map((value) => Number(value.toFixed(2)).toString()).join(' × ')
}
function formatDate(value: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录'
}
onMounted(loadRule)
onMounted(loadConfig)
</script>
<template>
<div class="dimension-rule-panel">
<header class="rule-heading">
<div>
<h2>尺寸计算规则</h2>
<p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的外箱尺寸加边配置</p>
</div>
<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>
<div v-if="!canView || loadError" class="panel-title rule-heading">
<div><h2>按箱规尺寸计算</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的尺寸计算规则</p></div>
</div>
<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>
</div>
<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>
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>需要权限 firstleg:dimension-config:view。</p></div>
<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="loadConfig">重新加载</el-button></div>
<section v-else-if="config" class="rule-card">
<header class="rule-card-heading"><h2>按箱规尺寸计算</h2><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button></header>
<div class="formula-band"><span>计算方式:<strong>内径尺寸 + 外边加宽 = 外径尺寸</strong></span><span>体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</span><span>默认加边:2cm(每侧)</span></div>
<DataTable :data="config.rules" :columns="columns" :loading="loading" row-key="boxType" empty-text="当前客户暂无箱规尺寸规则">
<template #boxTypeName="{ row }"><strong>{{ row.boxTypeName }}</strong></template>
<template #inner="{ row }">{{ formatDimension(row.innerLengthCm, row.innerWidthCm, row.innerHeightCm) }}</template>
<template #edgeAdditionCm="{ row }"><el-tag effect="light">+{{ row.edgeAdditionCm }}cm</el-tag></template>
<template #outer="{ row }">{{ formatDimension(row.outerLengthCm, row.outerWidthCm, row.outerHeightCm) }}</template>
<template #volumeCbm="{ row }"><strong class="volume-value">{{ Number(row.volumeCbm).toFixed(3) }}</strong></template>
<template #empty><div class="rule-state"><Ruler :size="28" /><strong>当前客户暂无箱规尺寸规则</strong></div></template>
</DataTable>
</section>
<div v-else v-loading="loading" class="loading-state" />
<AppDialog
v-model="editorVisible"
title="编辑尺寸计算规则"
width="min(720px, calc(100vw - 28px))"
:confirm-loading="editorSaving"
:show-cancel="false"
:before-close="beforeEditorClose"
@confirm="saveRule"
>
<AppDialog v-model="editorVisible" title="编辑箱规尺寸" width="min(1100px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
<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>
<p class="editor-formula">计算公式:外径 = 内径 + 加宽 × 2(每侧加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
<el-form ref="editorForm" :model="form" label-position="top">
<div class="editor-table-wrap">
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>外边加宽(cm)</th><th>备注</th></tr></thead><tbody>
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr>
</tbody></table>
</div>
</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>
</div>
</template>
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div></template>
</AppDialog>
</div>
</template>
<style scoped>
.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; }
}
.rule-heading { align-items: center; }
.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,.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.heading-actions :deep(.el-button),.dialog-actions :deep(.el-button) { margin-left: 0; }
.rule-card { min-width: 0; }.rule-card-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.formula-band { display: flex; min-height: 52px; align-items: center; flex-wrap: wrap; gap: 12px 30px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 13px; }.formula-band strong { color: #344054; }.loading-state { min-height: 280px; }
.rule-state { display: flex; min-height: 220px; 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; }
.volume-value { color: #1570ef; }.editor-content { display: grid; gap: 20px; }.editor-formula { margin: 0; padding: 13px 15px; color: #475467; font-size: 13px; line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th,.editor-table td { padding: 10px 12px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: 12px; }.editor-table tbody th { color: var(--cc-text); font-size: 13px; }.editor-table :deep(.el-input-number),.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; }
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; }.dialog-actions { width: 100%; }.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; } }
</style>