- 新增 table-actions.css:操作列 flex 居中、间距交给 gap、破坏性动作前 加分组竖线、链接按钮悬停加深并加下划线(默认浅靛蓝在表格底色上对比度 不足);包装 span 改 flex,消除行内盒基线导致的约 1.7px 垂直错位 - 11 个列表页操作列套用统一容器与分隔线,列宽按动作自然宽度调整并居中 - 确认框不再传 type:warning(Element Plus 的实心黄圆图标与正文并排成 两列,标题行反被留空),图标改由全局样式画在标题行左侧,正文与标题 文字左对齐;图标为 CSS 伪元素,不进无障碍树 验证:pnpm type-check 绿 Co-Authored-By: Claude Code <noreply@anthropic.com>
506 lines
17 KiB
Vue
506 lines
17 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 receiptApi from '@/api/receipts'
|
||
import type { ReceiptItem } from '@/api/receipts'
|
||
import * as shipmentApi from '@/api/shipments'
|
||
import type { ShipmentItem } from '@/api/shipments'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { AMOUNT_PATTERN } from '@/utils/validate'
|
||
import { useEditingRow } from '@/composables/useEditingRow'
|
||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 回款管理(需求 2.2.3:回款登记 + 发货批次级联指定 + 派生回款天数)。
|
||
*
|
||
* 与后端约定对齐:
|
||
* - 业务必选、发货批次可暂不指定;选中业务后级联加载其发货单
|
||
* (shipment_id 须与业务同源,后端强校验),可清空
|
||
* - payment_days 为派生值(发货日 → 回款日,发货日计第 1 天),
|
||
* 未指定批次时为 null,列表显示「未指定批次」
|
||
* - 已核算(settlement_status=1)行禁改禁删(按钮 disabled + tooltip)
|
||
* - 金额字符串直出与提交(金额红线:不经 float、不 toFixed)
|
||
*/
|
||
const { confirmAndRun } = useConfirmAction()
|
||
|
||
// ==================== 列表 ====================
|
||
|
||
const filters = reactive({
|
||
business_id: undefined as number | undefined,
|
||
is_barter: undefined as number | undefined,
|
||
settlement_status: 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<ReceiptItem, receiptApi.ReceiptQueryParams>(
|
||
receiptApi.listReceipts,
|
||
() => ({
|
||
business_id: filters.business_id,
|
||
is_barter: filters.is_barter,
|
||
settlement_status: filters.settlement_status,
|
||
date_from: dateRange.value?.[0],
|
||
date_to: dateRange.value?.[1],
|
||
}),
|
||
)
|
||
|
||
function resetFilters(): void {
|
||
filters.business_id = undefined
|
||
filters.is_barter = undefined
|
||
filters.settlement_status = undefined
|
||
dateRange.value = null
|
||
search()
|
||
}
|
||
|
||
// ==================== 业务下拉数据源 ====================
|
||
|
||
interface BusinessOption {
|
||
id: number
|
||
no: string
|
||
name: string
|
||
}
|
||
|
||
// 业务下拉数据源:一页 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 ?? '',
|
||
}))
|
||
},
|
||
)
|
||
|
||
function businessLabel(option: BusinessOption): string {
|
||
return option.name ? `${option.no} · ${option.name}` : option.no
|
||
}
|
||
|
||
// ==================== 新增 / 编辑 ====================
|
||
|
||
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 form = reactive({
|
||
business_id: undefined as number | undefined,
|
||
shipment_id: undefined as number | undefined,
|
||
receipt_date: '',
|
||
amount: '',
|
||
is_barter: 0,
|
||
remark: '',
|
||
})
|
||
|
||
const rules = computed<FormRules>(() => ({
|
||
business_id: [{ required: true, message: '请选择业务', trigger: 'change' }],
|
||
receipt_date: [{ required: true, message: '请选择回款到账日', trigger: 'change' }],
|
||
amount: [
|
||
{ required: true, message: '请输入回款金额', trigger: 'blur' },
|
||
{
|
||
pattern: AMOUNT_PATTERN,
|
||
message: '金额须为非负数字,最多两位小数',
|
||
trigger: 'blur',
|
||
},
|
||
],
|
||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||
}))
|
||
|
||
// ---- 级联:业务 → 该业务的发货单(回款↔发货批次级联指定) ----
|
||
|
||
const shipmentOptions = ref<ShipmentItem[]>([])
|
||
const shipmentLoading = ref(false)
|
||
|
||
/** 编辑中的回款(回显业务编号/批次语义用) */
|
||
const editingRow = useEditingRow(records, editingId)
|
||
|
||
/** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */
|
||
function shipmentLabel(row: ShipmentItem): string {
|
||
return `${row.shipment_no}(${row.ship_date},合计 ${row.total_amount} 元)`
|
||
}
|
||
|
||
/** 按业务级联加载发货单(列表一页 200 条足够该业务规模) */
|
||
async function loadShipmentOptions(businessId: number): Promise<void> {
|
||
shipmentLoading.value = true
|
||
try {
|
||
const data = await shipmentApi.listShipments({
|
||
business_id: businessId,
|
||
pageNum: 1,
|
||
pageSize: 200,
|
||
})
|
||
shipmentOptions.value = data.list
|
||
} catch (error) {
|
||
shipmentOptions.value = []
|
||
handledError(error) // 失败提示由请求拦截器统一完成,下拉保持空
|
||
} finally {
|
||
shipmentLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 业务切换:清空已选发货批次并重新级联(原批次极可能不再法属于新业务) */
|
||
function handleBusinessChange(value: number | undefined): void {
|
||
form.shipment_id = undefined
|
||
shipmentOptions.value = []
|
||
if (value) void loadShipmentOptions(value)
|
||
}
|
||
|
||
function openCreate(): void {
|
||
editingId.value = null
|
||
Object.assign(form, {
|
||
business_id: undefined,
|
||
shipment_id: undefined,
|
||
receipt_date: '',
|
||
amount: '',
|
||
is_barter: 0,
|
||
remark: '',
|
||
})
|
||
shipmentOptions.value = []
|
||
dialogVisible.value = true
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
async function openEdit(row: ReceiptItem): Promise<void> {
|
||
editingId.value = row.id
|
||
Object.assign(form, {
|
||
business_id: row.business_id,
|
||
shipment_id: row.shipment_id ?? undefined,
|
||
receipt_date: row.receipt_date,
|
||
amount: row.amount,
|
||
is_barter: row.is_barter,
|
||
remark: row.remark ?? '',
|
||
})
|
||
dialogVisible.value = true
|
||
if (row.business_id) await loadShipmentOptions(row.business_id)
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
async function handleSubmit(): Promise<void> {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
|
||
submitting.value = true
|
||
try {
|
||
if (editingId.value !== null) {
|
||
// 编辑全量替换:shipment_id 传 null = 清除既有指定(回到「暂未指定」)
|
||
await receiptApi.updateReceipt(editingId.value, {
|
||
shipment_id: form.shipment_id ?? null,
|
||
receipt_date: form.receipt_date,
|
||
amount: form.amount.trim(),
|
||
is_barter: form.is_barter,
|
||
remark: form.remark.trim() || null,
|
||
})
|
||
ElMessage.success('已保存')
|
||
} else {
|
||
await receiptApi.createReceipt({
|
||
business_id: form.business_id as number,
|
||
shipment_id: form.shipment_id ?? null,
|
||
receipt_date: form.receipt_date,
|
||
amount: form.amount.trim(),
|
||
is_barter: form.is_barter,
|
||
remark: form.remark.trim() || null,
|
||
})
|
||
ElMessage.success('回款已登记')
|
||
}
|
||
dialogVisible.value = false
|
||
await load()
|
||
} catch (error) {
|
||
handledError(error) // 失败提示由请求拦截器统一完成
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// ==================== 删除 ====================
|
||
|
||
async function handleDelete(row: ReceiptItem): Promise<void> {
|
||
await confirmAndRun({
|
||
confirmText: `确定删除回款「${row.receipt_batch_no}」?删除为物理删除、不可恢复;删除后业务回款合计将自动回退。`,
|
||
title: '删除确认',
|
||
confirmButtonText: '删除',
|
||
successText: '回款已删除',
|
||
run: () => receiptApi.deleteReceipt(row.id),
|
||
after: reloadAfterDelete,
|
||
})
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
onMounted(() => {
|
||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||
void load()
|
||
void loadBusinessOptions()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<el-card shadow="never">
|
||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||
<el-form-item label="业务">
|
||
<el-select
|
||
v-model="filters.business_id"
|
||
placeholder="全部"
|
||
clearable
|
||
filterable
|
||
style="width: 230px"
|
||
>
|
||
<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>
|
||
<el-form-item label="以物抵顶">
|
||
<el-select v-model="filters.is_barter" placeholder="全部" clearable style="width: 120px">
|
||
<el-option
|
||
v-for="item in receiptApi.BARTER_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="核算状态">
|
||
<el-select
|
||
v-model="filters.settlement_status"
|
||
placeholder="全部"
|
||
clearable
|
||
style="width: 110px"
|
||
>
|
||
<el-option
|
||
v-for="item in receiptApi.SETTLEMENT_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div class="toolbar">
|
||
<el-button type="primary" @click="openCreate">新增回款</el-button>
|
||
</div>
|
||
|
||
<el-table v-loading="loading" :data="records" stripe>
|
||
<el-table-column prop="receipt_batch_no" label="批次号" min-width="150" />
|
||
<el-table-column prop="business_no" label="业务编号" min-width="140">
|
||
<template #default="{ row }">{{ row.business_no }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="发货单号" min-width="140">
|
||
<template #default="{ row }">{{ row.shipment_no ?? '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="receipt_date" label="回款日期" min-width="110" />
|
||
<el-table-column label="金额(元)" min-width="120" align="right">
|
||
<template #default="{ row }">
|
||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||
<span class="cell-amount">{{ row.amount }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="以物抵顶" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.is_barter === 1" type="warning" size="small" disable-transitions>
|
||
以物抵顶
|
||
</el-tag>
|
||
<span v-else class="cell-muted">否</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="回款天数" width="120" align="center">
|
||
<template #default="{ row }">
|
||
<span v-if="row.payment_days !== null" class="cell-amount">{{ row.payment_days }} 天</span>
|
||
<span v-else class="cell-muted">未指定批次</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="核算状态" width="90" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag
|
||
:type="row.settlement_status === 1 ? 'success' : 'info'"
|
||
size="small"
|
||
disable-transitions
|
||
>
|
||
{{ row.settlement_status === 1 ? '已核算' : '未核算' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<div class="table-actions">
|
||
<el-tooltip
|
||
content="已核算回款不可变更"
|
||
:disabled="row.settlement_status !== 1"
|
||
placement="top"
|
||
>
|
||
<span class="op-wrap">
|
||
<el-button
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
:disabled="row.settlement_status === 1"
|
||
@click="openEdit(row)"
|
||
>
|
||
编辑
|
||
</el-button>
|
||
</span>
|
||
</el-tooltip>
|
||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||
<el-tooltip
|
||
content="已核算回款不可变更"
|
||
:disabled="row.settlement_status !== 1"
|
||
placement="top"
|
||
>
|
||
<span class="op-wrap">
|
||
<el-button
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
:disabled="row.settlement_status === 1"
|
||
@click="handleDelete(row)"
|
||
>
|
||
删除
|
||
</el-button>
|
||
</span>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</el-card>
|
||
|
||
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="isEdit ? '编辑回款' : '新增回款'"
|
||
width="620px"
|
||
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%"
|
||
@change="handleBusinessChange"
|
||
>
|
||
<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="shipment_id">
|
||
<el-select
|
||
v-model="form.shipment_id"
|
||
placeholder="选业务后级联加载该业务发货单"
|
||
clearable
|
||
filterable
|
||
:loading="shipmentLoading"
|
||
:disabled="!form.business_id"
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="item in shipmentOptions"
|
||
:key="item.id"
|
||
:label="shipmentLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
<span class="field-hint">可清空(暂不指定);指定后回款天数方可派生(发货日计第 1 天)</span>
|
||
</el-form-item>
|
||
<el-form-item label="回款到账日" prop="receipt_date">
|
||
<el-date-picker
|
||
v-model="form.receipt_date"
|
||
type="date"
|
||
value-format="YYYY-MM-DD"
|
||
placeholder="M3 核算按此日匹配参数版本"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="金额(元)" prop="amount">
|
||
<el-input v-model="form.amount" placeholder="非负数字,最多两位小数" />
|
||
</el-form-item>
|
||
<el-form-item label="以物抵顶" prop="is_barter">
|
||
<el-switch v-model="form.is_barter" :active-value="1" :inactive-value="0" />
|
||
<span class="switch-hint">绩效按 80% 折算,比例见参数配置</span>
|
||
</el-form-item>
|
||
<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-muted {
|
||
color: var(--ys-ink-3);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.cell-amount {
|
||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-2);
|
||
}
|
||
|
||
.op-wrap {
|
||
display: inline-block;
|
||
}
|
||
|
||
.switch-hint {
|
||
margin-left: 10px;
|
||
color: var(--ys-ink-3);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.field-hint {
|
||
display: block;
|
||
width: 100%;
|
||
color: var(--ys-ink-3);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
</style>
|