- 引入 @element-plus/icons-vue 官方图标库,菜单项按模块配图标,消除 纯文字导航的辨识成本(新增依赖已评估:Element Plus 官方同源维护) - AppMenu/BasicLayout/router 同步:图标渲染、菜单结构与路由 meta 对齐 - index.css 视觉系统化:品牌色、语义三色(ok/warn/danger 及其 wash 层次)、 形体与材质(圆角、缓动、阴影、等宽字体)统一为 CSS 变量,并覆盖 Element Plus 主色 - 登录页、业务页、结算页、工作台、审批页等视图随新变量适配 验证:pnpm type-check 与 pnpm build 均通过;后端接口契约未变。 Co-Authored-By: Claude Code <noreply@anthropic.com>
514 lines
18 KiB
Vue
514 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||
|
||
import * as businessApi from '@/api/businesses'
|
||
import * as shipmentApi from '@/api/shipments'
|
||
import type { ShipmentDetail, ShipmentItem } from '@/api/shipments'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
||
import { useEditingRow } from '@/composables/useEditingRow'
|
||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 发货管理(需求 2.2.2:发货单 + 明细行 + 运费 + 汇总回写)。
|
||
*
|
||
* 与后端约定对齐:
|
||
* - 发货单挂靠执行中的业务(后端校验状态门槛);创建绑业务后不可改挂,
|
||
* 编辑端点不含 business_id(保证已指定本单的回款语义不断裂)
|
||
* - ship_date 是回款天数计算基准日(发货日计第 1 天)—— 表单旁注提示
|
||
* - 明细行金额 = 数量 × 单价由后端固化;单价可空 = 待人工补价(行金额记 0)
|
||
* - 删除连带明细并回写业务汇总;被回款批次指定时后端拒绝
|
||
*/
|
||
const { confirmAndRun } = useConfirmAction()
|
||
|
||
// ==================== 列表 ====================
|
||
|
||
const filters = reactive({
|
||
business_id: undefined as number | undefined,
|
||
})
|
||
/** 发货日期范围单独存(el-date-picker daterange 回参是 [起, 止]) */
|
||
const dateRange = ref<[string, string] | null>(null)
|
||
|
||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||
usePagedList<ShipmentItem, shipmentApi.ShipmentQueryParams>(
|
||
shipmentApi.listShipments,
|
||
() => ({
|
||
business_id: filters.business_id,
|
||
ship_date_from: dateRange.value?.[0],
|
||
ship_date_to: dateRange.value?.[1],
|
||
}),
|
||
)
|
||
|
||
function resetFilters(): void {
|
||
filters.business_id = undefined
|
||
dateRange.value = null
|
||
search()
|
||
}
|
||
|
||
// ==================== 业务下拉数据源(可搜索过滤) ====================
|
||
|
||
interface BusinessOption {
|
||
id: number
|
||
no: string
|
||
name: string
|
||
status: number
|
||
}
|
||
|
||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||
async () => {
|
||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||
return data.list.map((b) => ({
|
||
id: b.id,
|
||
no: b.business_no,
|
||
name: b.customer_name ?? '',
|
||
status: b.status,
|
||
}))
|
||
},
|
||
)
|
||
|
||
/** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */
|
||
function businessLabel(option: BusinessOption): string {
|
||
const state = option.status === 1 ? '' : option.status === 2 ? '(已完成)' : '(已终止)'
|
||
return option.name ? `${option.no} · ${option.name}${state}` : `${option.no}${state}`
|
||
}
|
||
|
||
// ==================== 新增 / 编辑(含明细行编辑器) ====================
|
||
|
||
const dialogVisible = ref(false)
|
||
const editingId = ref<number | null>(null)
|
||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||
const isEdit = computed(() => editingId.value !== null)
|
||
const formRef = ref<FormInstance>()
|
||
const submitting = ref(false)
|
||
|
||
/** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */
|
||
const editingRow = useEditingRow(records, editingId)
|
||
|
||
/** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */
|
||
interface ItemDraft {
|
||
product_name: string
|
||
spec_model: string
|
||
unit: string
|
||
quantity: string
|
||
sale_price: string
|
||
}
|
||
|
||
const form = reactive({
|
||
business_id: undefined as number | undefined,
|
||
ship_date: '',
|
||
freight_amount: '',
|
||
remark: '',
|
||
})
|
||
|
||
function emptyItem(): ItemDraft {
|
||
return { product_name: '', spec_model: '', unit: '', quantity: '', sale_price: '' }
|
||
}
|
||
|
||
const itemRows = ref<ItemDraft[]>([])
|
||
|
||
const rules = computed<FormRules>(() => ({
|
||
business_id: [{ required: true, message: '请选择业务', trigger: 'change' }],
|
||
ship_date: [{ required: true, message: '请选择发货日期', trigger: 'change' }],
|
||
freight_amount: [
|
||
{ pattern: AMOUNT_PATTERN, message: '运费须为非负数字,最多两位小数', trigger: 'blur' },
|
||
],
|
||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||
}))
|
||
|
||
function openCreate(): void {
|
||
editingId.value = null
|
||
Object.assign(form, {
|
||
business_id: undefined,
|
||
ship_date: '',
|
||
freight_amount: '',
|
||
remark: '',
|
||
})
|
||
itemRows.value = [emptyItem()] // 预置一空行,用户可直接填写
|
||
dialogVisible.value = true
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
async function openEdit(row: ShipmentItem): Promise<void> {
|
||
editingId.value = row.id
|
||
dialogVisible.value = true
|
||
const detail: ShipmentDetail | null = await shipmentApi.getShipmentDetail(row.id).catch((error) => {
|
||
handledError(error) // 失败提示由请求拦截器统一完成,弹窗保持空表
|
||
return null
|
||
})
|
||
if (!detail) return
|
||
Object.assign(form, {
|
||
business_id: detail.business_id,
|
||
ship_date: detail.ship_date,
|
||
freight_amount: detail.freight_amount ?? '',
|
||
remark: detail.remark ?? '',
|
||
})
|
||
itemRows.value = detail.items.map((it) => ({
|
||
product_name: it.product_name,
|
||
spec_model: it.spec_model ?? '',
|
||
unit: it.unit ?? '',
|
||
quantity: it.quantity,
|
||
sale_price: it.sale_price ?? '',
|
||
}))
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
function addItemRow(): void {
|
||
itemRows.value.push(emptyItem())
|
||
}
|
||
|
||
function removeItemRow(index: number): void {
|
||
itemRows.value.splice(index, 1)
|
||
}
|
||
|
||
/** 明细行校验:返回第一处问题的提示文案,全部通过返回 null */
|
||
function validateItemRows(rows: ItemDraft[]): string | null {
|
||
if (rows.length === 0) return '请至少录入一行发货明细'
|
||
for (let i = 0; i < rows.length; i += 1) {
|
||
const row = rows[i]
|
||
const no = `第 ${i + 1} 行`
|
||
if (!row.product_name.trim()) return `${no}的产品名称未填写`
|
||
if (!AMOUNT_PATTERN.test(row.quantity) || ZERO_PATTERN.test(row.quantity)) {
|
||
return `${no}的数量须为大于 0 的数字,最多两位小数`
|
||
}
|
||
if (row.sale_price.trim() && !PRICE_PATTERN.test(row.sale_price.trim())) {
|
||
return `${no}的发货单价须为非负数字,最多四位小数`
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/** 明细草稿 → 提交行(空串归一为 null,与后端「空串归一 NULL」口径一致) */
|
||
function buildItemParams(): shipmentApi.ShipmentItemParams[] {
|
||
return itemRows.value.map((row) => ({
|
||
product_name: row.product_name.trim(),
|
||
spec_model: row.spec_model.trim() || null,
|
||
unit: row.unit.trim() || null,
|
||
quantity: row.quantity.trim(),
|
||
sale_price: row.sale_price.trim() || null,
|
||
}))
|
||
}
|
||
|
||
async function doSubmit(): Promise<void> {
|
||
const items = buildItemParams()
|
||
submitting.value = true
|
||
try {
|
||
if (editingId.value !== null) {
|
||
// 编辑不含 business_id:发货单挂靠的业务创建时定死
|
||
await shipmentApi.updateShipment(editingId.value, {
|
||
ship_date: form.ship_date,
|
||
freight_amount: form.freight_amount.trim() || null,
|
||
items,
|
||
remark: form.remark.trim() || null,
|
||
})
|
||
ElMessage.success('已保存')
|
||
} else {
|
||
await shipmentApi.createShipment({
|
||
business_id: form.business_id as number,
|
||
ship_date: form.ship_date,
|
||
freight_amount: form.freight_amount.trim() || null,
|
||
items,
|
||
remark: form.remark.trim() || null,
|
||
})
|
||
ElMessage.success('发货单已创建')
|
||
}
|
||
dialogVisible.value = false
|
||
await load()
|
||
} catch (error) {
|
||
handledError(error) // 失败提示由请求拦截器统一完成
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleSubmit(): Promise<void> {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
|
||
const rowError = validateItemRows(itemRows.value)
|
||
if (rowError) {
|
||
ElMessage.warning(rowError)
|
||
return
|
||
}
|
||
|
||
if (isEdit.value) {
|
||
await doSubmit() // 编辑全量替换无破坏性语义变化,直接提交
|
||
return
|
||
}
|
||
// 创建走 confirm 二次确认(提交回写入业务发货额汇总)
|
||
await confirmAndRun({
|
||
confirmText: '确定创建发货单?提交后业务发货额将同步更新。',
|
||
title: '创建确认',
|
||
confirmButtonText: '确认创建',
|
||
successText: '发货单已创建',
|
||
run: doSubmit,
|
||
})
|
||
}
|
||
|
||
// ==================== 删除 ====================
|
||
|
||
async function handleDelete(row: ShipmentItem): Promise<void> {
|
||
await confirmAndRun({
|
||
confirmText: `确定删除发货单「${row.shipment_no}」?删除将连带明细行并回写业务汇总;被回款批次指定时不可删除。`,
|
||
title: '删除确认',
|
||
confirmButtonText: '删除',
|
||
successText: '发货单已删除',
|
||
run: () => shipmentApi.deleteShipment(row.id),
|
||
after: reloadAfterDelete,
|
||
})
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
onMounted(() => {
|
||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||
void load()
|
||
void loadBusinessOptions()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<!-- 页面头:标题 + 一句口径说明(发货日是回款天数的起算点)+ 签名水线 -->
|
||
<div class="page-head">
|
||
<h1 class="page-title">发货管理</h1>
|
||
<p class="page-desc">
|
||
发货单挂靠执行中的业务,发货日就是回款天数的起算点(发货日计第 1 天);批次与金额经回款指定后决定计提比例与绩效
|
||
</p>
|
||
<span class="waterline"></span>
|
||
</div>
|
||
|
||
<!--
|
||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||
-->
|
||
<el-form class="filter-bar" @submit.prevent>
|
||
<el-form-item label="业务">
|
||
<el-select
|
||
v-model="filters.business_id"
|
||
placeholder="全部"
|
||
clearable
|
||
filterable
|
||
style="width: 240px"
|
||
>
|
||
<el-option
|
||
v-for="item in businessOptions"
|
||
:key="item.id"
|
||
:label="businessLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="发货日期">
|
||
<el-date-picker
|
||
v-model="dateRange"
|
||
type="daterange"
|
||
value-format="YYYY-MM-DD"
|
||
range-separator="至"
|
||
start-placeholder="起"
|
||
end-placeholder="止"
|
||
style="width: 240px"
|
||
/>
|
||
</el-form-item>
|
||
<div class="filter-bar__actions">
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</div>
|
||
</el-form>
|
||
|
||
<div class="toolbar">
|
||
<el-button type="primary" @click="openCreate">新增发货单</el-button>
|
||
<span class="toolbar-note">发货明细与运费在弹窗内维护,提交后自动回写业务发货额</span>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="records"
|
||
stripe
|
||
empty-text="还没有发货单 —— 点「新增发货单」登记第一批,或换个业务再查"
|
||
>
|
||
<el-table-column label="发货单号" min-width="150">
|
||
<template #default="{ row }">
|
||
<span class="cell-code">{{ row.shipment_no }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="业务编号" min-width="150">
|
||
<template #default="{ row }">
|
||
<span v-if="row.business_no" class="cell-code">{{ row.business_no }}</span>
|
||
<span v-else class="cell-muted">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="发货日期" min-width="140">
|
||
<template #default="{ row }">
|
||
<span>{{ row.ship_date }}</span>
|
||
<!-- 回款天数计算基准日提示:发货日计第 1 天 -->
|
||
<el-tooltip content="本单发货日期是回款天数计算基准日(发货日计第 1 天)" placement="top">
|
||
<span class="cell-muted">(基准日)</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="运费(元)" min-width="110" align="right">
|
||
<template #default="{ row }">
|
||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||
<span class="cell-num">{{ row.freight_amount }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="发货总额(元)" min-width="130" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.total_amount }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 分页落进 card-foot:与表体同一张卡,卡底微渐变收边 -->
|
||
<div class="card-foot">
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="isEdit ? '编辑发货单' : '新增发货单'"
|
||
width="820px"
|
||
destroy-on-close
|
||
>
|
||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||
<el-form-item v-if="!isEdit" label="业务" prop="business_id">
|
||
<el-select
|
||
v-model="form.business_id"
|
||
placeholder="搜索或选择业务(筛选后按编号 / 客户名匹配)"
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="item in businessOptions"
|
||
:key="item.id"
|
||
:label="businessLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-else label="业务">
|
||
<!-- 挂靠业务创建时定死,编辑时只读展示 -->
|
||
<span>{{ editingRow?.business_no ?? '—' }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="发货日期" prop="ship_date">
|
||
<el-date-picker
|
||
v-model="form.ship_date"
|
||
type="date"
|
||
value-format="YYYY-MM-DD"
|
||
placeholder="选择日期"
|
||
style="width: 100%"
|
||
/>
|
||
<span class="field-hint">回款天数计算基准日(发货日计第 1 天)</span>
|
||
</el-form-item>
|
||
<el-form-item label="运费(元)" prop="freight_amount">
|
||
<el-input v-model="form.freight_amount" placeholder="选填;仅记录展示,不计入毛利润口径" />
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div class="items-head">
|
||
<span class="items-title">发货明细(至少 1 行,编辑为全量替换)</span>
|
||
<el-button size="small" @click="addItemRow">添加一行</el-button>
|
||
</div>
|
||
<el-table :data="itemRows" stripe size="small" class="items-table">
|
||
<el-table-column label="产品名称" min-width="180">
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.product_name" maxlength="128" placeholder="产品名称,可手填" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="规格型号" min-width="130">
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.spec_model" maxlength="128" placeholder="选填" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="单位" min-width="90">
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.unit" maxlength="16" placeholder="如 台" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="数量" min-width="110">
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.quantity" placeholder=">0" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="单价(元)" min-width="130">
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.sale_price" placeholder="可空=待人工补价" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="70" align="center">
|
||
<template #default="{ $index }">
|
||
<el-button link type="danger" size="small" @click="removeItemRow($index)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<el-form label-width="110px" class="tail-form" @submit.prevent>
|
||
<el-form-item label="备注" prop="remark">
|
||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 单元格口径(.cell-code / .cell-num / .cell-muted)由全局 index.css 提供,此处不再重复定义 */
|
||
|
||
.field-hint {
|
||
display: block;
|
||
width: 100%;
|
||
color: var(--ys-ink-3);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.items-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.items-title {
|
||
font-weight: 600;
|
||
color: var(--ys-ink);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.items-table {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.tail-form {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
</style>
|