PayrollManagementSystem/frontend/src/views/business/receipts/index.vue
liuwenxin cfd2a196f2 style(frontend): 表格操作列统一样式与确认框视觉收敛
- 新增 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>
2026-10-19 16:33:06 +08:00

506 lines
17 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 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>