Files
admin-web/src/modules/warehouse-resources/pallets/pages/PalletsView.vue
T
2026-08-10 15:36:53 +08:00

603 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Boxes, Eye, Pencil, Plus, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
createPlatformPallet,
deletePlatformPallet,
getPlatformPallet,
listPlatformPallets,
updatePlatformPallet,
type PlatformPalletResource,
type SavePlatformPalletRequest,
} from '../api/platformPallets'
const access = useAccessPreviewStore()
const warehouses = ref<PlatformWarehouseResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const rows = ref<PlatformPalletResource[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const editorOpen = ref(false)
const detailOpen = ref(false)
const detail = ref<PlatformPalletResource | null>(null)
const detailLoading = ref(false)
const saving = ref(false)
const deletingIds = ref(new Set<string>())
const formRef = ref<FormInstance>()
let warehouseSequence = 0
let palletSequence = 0
let detailSequence = 0
let editorSequence = 0
type PalletFilters = QueryModel & { warehouseId: string; palletCode: string; palletType: string }
type AppliedPalletFilters = Pick<PalletFilters, 'warehouseId' | 'palletCode' | 'palletType'>
const filters = ref<PalletFilters>({ warehouseId: '', palletCode: '', palletType: '' })
const appliedFilters = ref<AppliedPalletFilters>({ warehouseId: '', palletCode: '', palletType: '' })
const palletTypeOptions = ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸托盘']
const queryFields = computed<QueryField[]>(() => [
{
key: 'warehouseId',
type: 'select',
label: '所属仓库',
width: 300,
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
options: domesticWarehouses.value.map((warehouse) => ({
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
value: String(warehouse.id),
})),
props: {
filterable: true,
disabled: contextLocked.value || warehouseLoading.value || Boolean(warehouseError.value),
'aria-label': '所属仓库',
},
},
{ key: 'palletCode', type: 'input', width: 240, label: '托盘编码', placeholder: '托盘编码(模糊查询)', props: { maxlength: 50, 'aria-label': '托盘编码' } },
{
key: 'palletType',
type: 'select',
label: '托盘类型',
width: 180,
placeholder: '全部托盘类型',
options: palletTypeOptions.map((value) => ({ label: value, value })),
props: { filterable: true, allowCreate: true, defaultFirstOption: true, 'aria-label': '托盘类型' },
},
])
const emptyForm = () => ({ id: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, weightKg: null as number | null, color: '', remark: '' })
const form = reactive(emptyForm())
const rules: FormRules<typeof form> = {
warehouseId: [{ required: true, message: '请选择国内仓', trigger: 'change' }],
palletType: [
{ required: true, message: '请输入托盘类型', trigger: 'blur' },
{ max: 32, message: '托盘类型不能超过 32 个字符', trigger: 'blur' },
],
capacityCbm: [{
validator: (_rule, value, callback) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) callback(new Error('请输入大于 0 的托盘容积'))
else callback()
},
trigger: 'change',
}],
weightKg: [{
validator: (_rule, value, callback) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) callback(new Error('请输入大于 0 的托盘重量'))
else callback()
},
trigger: 'change',
}],
color: [
{ required: true, message: '请输入托盘颜色', trigger: 'blur' },
{ max: 32, message: '托盘颜色不能超过 32 个字符', trigger: 'blur' },
],
remark: [{ max: 500, message: '备注不能超过 500 个字符', trigger: 'blur' }],
}
const editorWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) || null)
const domesticWarehouses = computed(() => warehouses.value.filter((warehouse) => (
warehouse.warehouseType === 'DOMESTIC' && warehouse.status === 'ACTIVE'
)))
const editorTitle = computed(() => form.id ? '编辑托盘' : '新增托盘')
const editorPermission = computed(() => form.id ? 'platform:pallet:edit' : 'platform:pallet:create')
const contextLocked = computed(() => editorOpen.value || detailOpen.value || saving.value || deletingIds.value.size > 0)
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'pallet-editor',
label: editorTitle,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
{ key: 'pallet', label: '托盘编码', minWidth: 190, slotName: 'pallet' },
{ key: 'palletType', prop: 'palletType', label: '托盘类型', minWidth: 140 },
{ key: 'capacity', label: '托盘容积(m³)', minWidth: 160, slotName: 'capacity' },
{ key: 'weight', label: '托盘重量(kg)', minWidth: 160, slotName: 'weight' },
{ key: 'color', prop: 'color', label: '颜色', minWidth: 120 },
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
]
function apiId(value: string) {
const numeric = Number(value)
return Number.isSafeInteger(numeric) ? numeric : value
}
function formatDateTime(value?: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function formatCapacity(value: number | string) {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return String(value)
return `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 6 })} m³`
}
function formatWeight(value: number | string | null) {
if (value === null) return '—'
const numeric = Number(value)
if (!Number.isFinite(numeric)) return String(value)
return `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })} kg`
}
function warehouseFor(warehouseId: number | string) {
return warehouses.value.find((item) => String(item.id) === String(warehouseId)) || null
}
function isDomesticWarehouse(warehouseId: number | string) {
return warehouseFor(warehouseId)?.warehouseType === 'DOMESTIC'
}
function isActiveDomesticWarehouse(warehouseId: number | string) {
return domesticWarehouses.value.some((warehouse) => String(warehouse.id) === String(warehouseId))
}
function errorMessage(details: ApiErrorDetails) {
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
}
async function loadWarehouses() {
const sequence = ++warehouseSequence
warehouses.value = []
warehouseError.value = null
warehouseLoading.value = true
try {
const records = await listAllPlatformWarehouses()
if (sequence !== warehouseSequence) return
warehouses.value = records
} catch (error) {
if (sequence !== warehouseSequence) return
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
} finally {
if (sequence === warehouseSequence) warehouseLoading.value = false
}
}
async function loadPallets() {
const sequence = ++palletSequence
rows.value = []
total.value = 0
loadError.value = null
loading.value = true
try {
const result = await listPlatformPallets({
page: page.value,
size: pageSize.value,
...(appliedFilters.value.warehouseId ? { warehouseId: apiId(appliedFilters.value.warehouseId) } : {}),
...(appliedFilters.value.palletCode ? { palletCode: appliedFilters.value.palletCode } : {}),
...(appliedFilters.value.palletType ? { palletType: appliedFilters.value.palletType } : {}),
})
if (sequence !== palletSequence) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (sequence !== palletSequence) return
loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请稍后重试')
} finally {
if (sequence === palletSequence) loading.value = false
}
}
function applyFilters(model: QueryModel) {
appliedFilters.value = {
warehouseId: String(model.warehouseId || ''),
palletCode: String(model.palletCode || ''),
palletType: String(model.palletType || ''),
}
if (page.value === 1) void loadPallets()
else page.value = 1
}
function resetFilters() {
appliedFilters.value = { warehouseId: '', palletCode: '', palletType: '' }
if (page.value === 1) void loadPallets()
else page.value = 1
}
function openCreate() {
editorSequence += 1
Object.assign(form, emptyForm())
form.warehouseId = isActiveDomesticWarehouse(filters.value.warehouseId) ? filters.value.warehouseId : ''
editorOpen.value = true
captureBaseline()
void formRef.value?.clearValidate()
}
function assignForm(resource: PlatformPalletResource) {
Object.assign(form, {
id: String(resource.id),
warehouseId: String(resource.warehouseId),
palletCode: resource.palletCode,
palletType: resource.palletType,
capacityCbm: Number(resource.capacityCbm),
weightKg: resource.weightKg === null ? null : Number(resource.weightKg),
color: resource.color || '',
remark: resource.remark || '',
})
editorOpen.value = true
captureBaseline()
void formRef.value?.clearValidate()
}
async function fetchDetail(row: PlatformPalletResource) {
return getPlatformPallet(apiId(String(row.warehouseId)), row.id)
}
async function openDetail(row: PlatformPalletResource) {
const sequence = ++detailSequence
detailLoading.value = true
detailOpen.value = true
detail.value = null
try {
const result = await fetchDetail(row)
if (sequence !== detailSequence || !detailOpen.value) return
detail.value = result
} catch (error) {
if (sequence !== detailSequence) return
detailOpen.value = false
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
} finally {
if (sequence === detailSequence) detailLoading.value = false
}
}
async function openEdit(row: PlatformPalletResource) {
if (!isDomesticWarehouse(row.warehouseId)) {
ElMessage.warning('仅支持编辑国内仓托盘')
return
}
const sequence = ++editorSequence
try {
const result = await fetchDetail(row)
if (sequence !== editorSequence) return
assignForm(result)
} catch (error) {
if (sequence !== editorSequence) return
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
}
}
async function savePallet() {
if (!await formRef.value?.validate().catch(() => false) || form.capacityCbm === null || form.weightKg === null) return
const warehouse = editorWarehouse.value
const warehouseAllowed = form.id
? warehouse?.warehouseType === 'DOMESTIC'
: warehouse?.warehouseType === 'DOMESTIC' && warehouse.status === 'ACTIVE'
if (!warehouse || !warehouseAllowed) {
ElMessage.error(form.id ? '托盘仅支持归属国内仓' : '新增托盘仅支持选择启用的国内仓')
return
}
saving.value = true
const payload: SavePlatformPalletRequest = {
warehouseId: apiId(form.warehouseId),
palletType: form.palletType.trim(),
capacityCbm: form.capacityCbm,
weightKg: form.weightKg,
color: form.color.trim(),
remark: form.remark.trim() || null,
}
try {
if (form.id) await updatePlatformPallet(apiId(form.warehouseId), form.id, payload)
else await createPlatformPallet(payload)
ElMessage.success(form.id ? '托盘已更新' : '托盘已新增')
editorOpen.value = false
await loadPallets()
} catch (error) {
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘保存失败,请稍后重试')))
} finally {
saving.value = false
}
}
async function removePallet(row: PlatformPalletResource) {
try {
await ElMessageBox.confirm(
`确定删除托盘“${row.palletCode}”吗?已预占或已占用的托盘无法删除。`,
'删除托盘',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'el-button--danger' },
)
} catch {
return
}
const id = String(row.id)
deletingIds.value = new Set([...deletingIds.value, id])
try {
await deletePlatformPallet(apiId(String(row.warehouseId)), row.id)
ElMessage.success('托盘已删除')
if (rows.value.length === 1 && page.value > 1) page.value -= 1
else await loadPallets()
} catch (error) {
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘删除失败,请稍后重试')))
} finally {
const next = new Set(deletingIds.value)
next.delete(id)
deletingIds.value = next
}
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage === previousPage && nextSize === previousSize) return
void loadPallets()
})
watch(detailOpen, (open) => {
if (open) return
detailSequence += 1
detailLoading.value = false
detail.value = null
})
watch(() => access.can(editorPermission.value), (canWrite) => {
if (!canWrite && editorOpen.value) editorOpen.value = false
}, { flush: 'sync' })
onMounted(() => {
void loadWarehouses()
void loadPallets()
})
</script>
<template>
<section class="pallet-page">
<QueryToolbar
v-model="filters"
class="surface-panel pallet-filter-toolbar"
ariaLabel="托盘筛选"
:fields="queryFields"
preference-key="pallets-query"
:search-on-reset="false"
@search="applyFilters"
@reset="resetFilters"
/>
<LoadErrorState
v-if="warehouseError"
title="仓库列表暂时无法加载"
:message="warehouseError.message"
:loading="warehouseLoading"
@retry="loadWarehouses"
/>
<LoadErrorState
v-if="loadError"
title="托盘列表暂时无法加载"
:message="loadError.message"
:loading="loading"
@retry="loadPallets"
/>
<section class="table-panel surface-panel" aria-label="托盘列表区域">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
preference-key="pallets-table"
:loading="loading || warehouseLoading"
:total="total"
row-key="id"
:empty-text="filtered ? '暂无符合筛选条件的托盘' : '暂无托盘数据'"
aria-label="托盘列表"
>
<template #toolbar-start><div><h2>托盘列表</h2><p>共 {{ total }} 个托盘</p></div></template>
<template #toolbar-actions><el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }">
<div class="table-actions pallet-row-actions">
<el-button link type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
<el-button v-if="access.can('platform:pallet:edit') && isDomesticWarehouse((row as PlatformPalletResource).warehouseId)" link type="primary" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:pallet:delete')"
link
type="danger"
title="删除托盘"
:loading="deletingIds.has(String((row as PlatformPalletResource).id))"
@click="removePallet(row as PlatformPalletResource)"
><Trash2 :size="15" />删除</el-button>
</div>
</template>
<template #pallet="{ row }">
<div class="pallet-cell"><span><Boxes :size="18" /></span><strong>{{ (row as PlatformPalletResource).palletCode }}</strong></div>
</template>
<template #capacity="{ row }"><strong>{{ formatCapacity((row as PlatformPalletResource).capacityCbm) }}</strong></template>
<template #weight="{ row }"><strong>{{ formatWeight((row as PlatformPalletResource).weightKg) }}</strong></template>
<template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template>
</DataTable>
</section>
<el-drawer v-model="detailOpen" title="托盘详情" size="520px" class="business-drawer">
<div v-loading="detailLoading" class="detail-body">
<template v-if="detail">
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
<h3 class="detail-section-title">归属信息</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
<el-descriptions-item label="所属仓库">{{ warehouseFor(detail.warehouseId)?.warehouseName || '—' }}({{ warehouseFor(detail.warehouseId)?.warehouseCode || '—' }})</el-descriptions-item>
</el-descriptions>
<h3 class="detail-section-title">托盘规格</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="托盘编码">{{ detail.palletCode }}</el-descriptions-item>
<el-descriptions-item label="托盘类型">{{ detail.palletType }}</el-descriptions-item>
<el-descriptions-item label="托盘容积">{{ formatCapacity(detail.capacityCbm) }}</el-descriptions-item>
<el-descriptions-item label="托盘重量">{{ formatWeight(detail.weightKg) }}</el-descriptions-item>
<el-descriptions-item label="颜色">{{ detail.color || '—' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item>
</el-descriptions>
<h3 class="detail-section-title">记录信息</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.updatedAt) }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
</el-drawer>
<el-drawer v-model="editorOpen" size="560px" class="business-drawer pallet-editor-drawer" :close-on-click-modal="false">
<template #header="{ titleId, titleClass }">
<div class="pallet-editor-header">
<span aria-hidden="true"><Boxes :size="20" /></span>
<div>
<h2 :id="titleId" :class="titleClass">{{ editorTitle }}</h2>
<p>{{ form.id ? '调整托盘规格与现场识别信息' : '录入归属仓库与基础规格,创建后即可用于仓储作业' }}</p>
</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
<section class="editor-section" aria-labelledby="pallet-ownership-heading">
<div class="editor-section-heading">
<WarehouseIcon :size="18" aria-hidden="true" />
<div><h3 id="pallet-ownership-heading">归属仓库</h3><p>托盘创建后固定归属于所选仓库,不支持跨仓移动。</p></div>
</div>
<div v-if="form.id" class="editor-context">
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 平台公共资源</span></div>
</div>
<el-form-item v-else label="所属仓库" prop="warehouseId" required>
<el-select v-model="form.warehouseId" filterable placeholder="请选择国内仓" :loading="warehouseLoading">
<el-option
v-for="warehouse in domesticWarehouses"
:key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
:value="String(warehouse.id)"
/>
</el-select>
</el-form-item>
</section>
<section class="editor-section" aria-labelledby="pallet-spec-heading">
<div class="editor-section-heading">
<Boxes :size="18" aria-hidden="true" />
<div><h3 id="pallet-spec-heading">托盘规格</h3><p>编码由系统自动生成,请维护类型、容积、重量和颜色。</p></div>
</div>
<div class="editor-field-grid">
<el-form-item label="托盘编码">
<el-input :model-value="form.palletCode || '保存后自动生成'" disabled aria-label="托盘编码(系统生成)" />
</el-form-item>
<el-form-item label="托盘类型" prop="palletType" required>
<el-select
v-model="form.palletType"
filterable
allow-create
default-first-option
placeholder="请选择或输入托盘类型"
aria-label="编辑托盘类型"
>
<el-option v-for="type in palletTypeOptions" :key="type" :label="type" :value="type" />
</el-select>
</el-form-item>
</div>
<div class="editor-field-grid">
<el-form-item label="托盘容积(m³)" prop="capacityCbm" required>
<el-input-number v-model="form.capacityCbm" :min="0.000001" :max="999999999999.999999" :precision="6" :step="0.1" controls-position="right" aria-label="托盘容积" />
</el-form-item>
<el-form-item label="托盘重量(kg)" prop="weightKg" required>
<el-input-number v-model="form.weightKg" :min="0.001" :max="999999999999999.999" :precision="3" :step="0.5" controls-position="right" aria-label="托盘重量" />
</el-form-item>
</div>
<el-form-item label="颜色" prop="color" required>
<el-input v-model="form.color" maxlength="32" show-word-limit placeholder="例如 黄色" />
</el-form-item>
<el-form-item label="备注" prop="remark" class="editor-remark-field">
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写其他说明" />
</el-form-item>
</section>
</el-form>
<template #footer>
<div class="drawer-footer pallet-editor-footer">
<span><i aria-hidden="true">*</i> 为必填项</span>
<div><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet">保存托盘</el-button></div>
</div>
</template>
</el-drawer>
</section>
</template>
<style scoped>
.pallet-page { display: grid; min-width: 0; gap: 16px; }
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.table-panel { min-width: 0; }
.context-empty { padding: 56px 20px; color: var(--cc-muted); text-align: center; }
.pallet-row-actions { gap: 8px; white-space: nowrap; }
.pallet-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.pallet-cell { display: flex; align-items: center; gap: 10px; }
.pallet-cell > span, .detail-hero > span { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
.pallet-cell > span { width: 34px; height: 34px; }
.remark-text { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.55; }
.detail-body { min-height: 180px; padding: 24px; box-sizing: border-box; overflow-x: hidden; }
.detail-hero { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.detail-hero > span { width: 46px; height: 46px; }
.detail-hero h2, .detail-hero p { margin: 0; }
.detail-hero h2 { font-size: 18px; }
.detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; }
.detail-section-title { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; }
.detail-section-title:first-of-type { margin-top: 0; }
.pallet-editor-header { display: flex; min-width: 0; align-items: center; gap: 12px; }
.pallet-editor-header > span { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
.pallet-editor-header h2 { margin: 0; color: #172033; font-size: 17px; line-height: 1.4; }
.pallet-editor-header p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.editor-form { padding: 4px 24px 28px; }
.editor-section { padding: 20px 0 4px; border-bottom: 1px solid var(--cc-border); }
.editor-section:last-child { padding-bottom: 0; border-bottom: 0; }
.editor-section-heading { display: flex; align-items: flex-start; gap: 9px; margin-bottom: 18px; }
.editor-section-heading > svg { margin-top: 1px; flex: 0 0 auto; color: var(--cc-primary); }
.editor-section-heading h3 { margin: 0; color: #172033; font-size: 14px; line-height: 1.4; }
.editor-section-heading p { margin: 3px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
.editor-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.editor-context { margin-bottom: 18px; padding: 12px 14px; border-radius: 6px; background: #f8fafc; }
.editor-context div { display: grid; gap: 3px; }
.editor-context strong { color: #172033; font-size: 13px; }
.editor-context span { color: var(--cc-muted); font-size: 11px; }
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
.editor-form :deep(.el-form-item) { margin-bottom: 18px; }
.editor-form :deep(.el-form-item__label) { padding-bottom: 7px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
.editor-remark-field { margin-bottom: 0 !important; }
.field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 8px; }
.drawer-footer :deep(.el-button + .el-button) { margin-left: 0; }
.pallet-editor-footer { align-items: center; justify-content: space-between; }
.pallet-editor-footer > span { color: var(--cc-muted); font-size: 11px; }
.pallet-editor-footer > span i { color: var(--el-color-danger); font-style: normal; }
.pallet-editor-footer > div { display: flex; gap: 8px; }
.pallet-editor-footer > div :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.detail-body { padding: 16px; }
.pallet-row-actions :deep(.el-button) { min-height: 36px; padding-inline: 5px; }
.editor-form { padding-right: 16px; padding-left: 16px; }
.editor-field-grid { grid-template-columns: 1fr; }
.pallet-editor-footer { display: block; }
.pallet-editor-footer > span { display: none; }
.pallet-editor-footer > div { display: grid; grid-template-columns: 1fr 1fr; }
.pallet-editor-footer :deep(.el-button) { min-height: 44px; margin-left: 0; }
}
</style>