Files
PayrollManagementSystem/frontend/src/views/business/shipments/index.vue
T
liuwenxinandClaude Code e61800dcdc feat(frontend): 菜单图标与视觉设计系统落地
- 引入 @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>
2026-10-22 19:45:54 +08:00

514 lines
18 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 } 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>