calc_settlement_flow e2e 首跑即暴露三处真实缺陷(三页自 2026-10-17 交付
起一直「暂无数据」且翻页失效,此前无 e2e 覆盖):
- api/{calc,settlements,approvals}.ts 误用默认导出 `request`(axios 实例,
返回 AxiosResponse 未解包),usePagedList 拿到的 data.list 恒 undefined
→ 三页永远「暂无数据」;改为具名导入 `{ request }`(统一信封解包版)
- 三页均无 onMounted 初始加载 → 进页不取数;补 `onMounted(() => void load())`
- 三页 AppPagination 用 `v-model:page-num` + `@change`(组件无该事件、
也未实现 update:*)→ 翻页失效;统一为 `:page-num/:page-size` +
`@page-change/@size-change`
- 并入并行会话的操作列 align="center" 样式与 M4 报表页 README 段落
验证:e2e calc_settlement_flow 8 步全过(API 预置→台账筛选→重算对话框
按回款 ID→版本三态筛选→月结生成→提交锁定→详情抽屉);type-check 绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
425 lines
12 KiB
Vue
425 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
import {
|
||
actApproval,
|
||
approvalStatusLabel,
|
||
approvalStatusTagType,
|
||
getApproval,
|
||
listApprovals,
|
||
stepActionLabel,
|
||
stepNodeLabel,
|
||
type ApprovalOrder,
|
||
type ApprovalOrderDetail,
|
||
} from '@/api/approvals'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { handledError } from '@/utils/error'
|
||
import { formatDateTime } from '@/utils/format'
|
||
|
||
/**
|
||
* 审批管理(2026-10-15 复核审批补充需求)。
|
||
*
|
||
* 提单不在此页:创建/编辑业务带二次分配方案时由后端自动生成审批单
|
||
* (开关关闭=自动通过留痕,开启=待财务复核)。本页负责查看与两级
|
||
* 节点动作;节点准入由后端按角色与单据状态双重校验。
|
||
*
|
||
* 签名元素是抽屉里的竖排节点时间线 —— 财务复核 → 领导审核的处理链
|
||
* 一眼可读(节点/动作/操作人/意见按发生顺序排列)。
|
||
*/
|
||
const { confirmAndRun } = useConfirmAction()
|
||
|
||
const filters = reactive({
|
||
keyword: '',
|
||
status: undefined as number | undefined,
|
||
order_type: undefined as number | undefined,
|
||
})
|
||
|
||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange } =
|
||
usePagedList<ApprovalOrder, { keyword?: string; status?: number; order_type?: number }>(
|
||
listApprovals,
|
||
() => ({
|
||
keyword: filters.keyword.trim() || undefined,
|
||
status: filters.status,
|
||
order_type: filters.order_type,
|
||
}),
|
||
)
|
||
|
||
onMounted(() => {
|
||
void load()
|
||
})
|
||
|
||
function resetFilters(): void {
|
||
filters.keyword = ''
|
||
filters.status = undefined
|
||
filters.order_type = undefined
|
||
search()
|
||
}
|
||
|
||
// ==================== 详情抽屉(节点时间线) ====================
|
||
|
||
const detailVisible = ref(false)
|
||
const detailLoading = ref(false)
|
||
const detail = ref<ApprovalOrderDetail | null>(null)
|
||
|
||
async function openDetail(row: ApprovalOrder): Promise<void> {
|
||
detailVisible.value = true
|
||
detailLoading.value = true
|
||
detail.value = null
|
||
try {
|
||
detail.value = await getApproval(row.id)
|
||
} catch (error) {
|
||
handledError(error)
|
||
detailVisible.value = false
|
||
} finally {
|
||
detailLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ==================== 节点动作 ====================
|
||
|
||
const actingId = ref<number | null>(null)
|
||
const actionSubmitting = ref(false)
|
||
const actionForm = reactive({
|
||
action: 1 as 1 | 2,
|
||
opinion: '',
|
||
})
|
||
|
||
/** 仅在途单展示动作表单;动作随单据状态定(1 待财务 / 2 待领导) */
|
||
const canAct = (order: ApprovalOrderDetail): boolean =>
|
||
order.status === 1 || order.status === 2
|
||
|
||
function openAction(order: ApprovalOrderDetail, action: 1 | 2): void {
|
||
actingId.value = order.id
|
||
actionForm.action = action
|
||
actionForm.opinion = ''
|
||
}
|
||
|
||
async function submitAction(): Promise<void> {
|
||
if (actingId.value === null) return
|
||
if (actionForm.action === 2 && !actionForm.opinion.trim()) {
|
||
ElMessage.warning('驳回必须填写审批意见')
|
||
return
|
||
}
|
||
actionSubmitting.value = true
|
||
try {
|
||
const ok = await confirmAndRun({
|
||
confirmText:
|
||
actionForm.action === 1
|
||
? `确认通过审批单 ${actingId.value}?通过后进入下一节点。`
|
||
: `确认驳回审批单 ${actingId.value}?驳回后该单终止。`,
|
||
run: () =>
|
||
actApproval(actingId.value as number, {
|
||
action: actionForm.action,
|
||
opinion: actionForm.opinion.trim() || null,
|
||
}),
|
||
successText: actionForm.action === 1 ? '已通过' : '已驳回',
|
||
})
|
||
if (!ok) return
|
||
// 动作后刷新抽屉与列表(状态可能已流转或终态)
|
||
detail.value = await getApproval(actingId.value)
|
||
actingId.value = null
|
||
await load()
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
actionSubmitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<el-form inline class="filters" @submit.prevent>
|
||
<el-form-item label="关键词">
|
||
<el-input
|
||
v-model="filters.keyword"
|
||
placeholder="审批单号 / 业务编号"
|
||
clearable
|
||
style="width: 200px"
|
||
@keyup.enter="search"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="状态">
|
||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 150px">
|
||
<el-option label="待财务复核" :value="1" />
|
||
<el-option label="待领导审核" :value="2" />
|
||
<el-option label="已通过" :value="3" />
|
||
<el-option label="已驳回" :value="4" />
|
||
<el-option label="已撤回" :value="5" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="类型">
|
||
<el-select v-model="filters.order_type" placeholder="全部" clearable style="width: 150px">
|
||
<el-option label="二次分配方案" :value="1" />
|
||
</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>
|
||
|
||
<el-table v-loading="loading" :data="records" stripe>
|
||
<el-table-column prop="order_no" label="审批单号" min-width="150" />
|
||
<el-table-column label="类型" width="120">
|
||
<template #default="{ row }">
|
||
{{ row.order_type === 1 ? '二次分配方案' : `类型${row.order_type}` }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="refer_no" label="业务编号" min-width="140" />
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag :type="approvalStatusTagType(row.status)" size="small" disable-transitions>
|
||
{{ approvalStatusLabel(row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="submitter_name" label="提交人" width="110" />
|
||
<el-table-column label="提交时间" min-width="150">
|
||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
|
||
<el-drawer v-model="detailVisible" title="审批详情" size="480px">
|
||
<div v-loading="detailLoading">
|
||
<template v-if="detail">
|
||
<div class="detail-head">
|
||
<span class="detail-no">{{ detail.order_no }}</span>
|
||
<el-tag
|
||
:type="approvalStatusTagType(detail.status)"
|
||
size="small"
|
||
disable-transitions
|
||
>
|
||
{{ approvalStatusLabel(detail.status) }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="detail-meta">
|
||
<span>业务编号:{{ detail.refer_no }}</span>
|
||
<span>提交人:{{ detail.submitter_name }}</span>
|
||
<span>提交时间:{{ formatDateTime(detail.create_time) }}</span>
|
||
</div>
|
||
|
||
<!-- 签名元素:竖排节点时间线(财务复核 → 领导审核的处理链) -->
|
||
<div class="timeline">
|
||
<div
|
||
v-for="step in detail.steps"
|
||
:key="step.id"
|
||
class="timeline-row"
|
||
:class="{ 'is-withdraw': step.node === 0 }"
|
||
>
|
||
<div class="timeline-rail">
|
||
<span
|
||
class="timeline-dot"
|
||
:class="{ 'is-auto': step.action === 3, 'is-reject': step.action === 2 }"
|
||
></span>
|
||
<span v-if="step.id !== detail.steps[detail.steps.length - 1].id" class="timeline-line"></span>
|
||
</div>
|
||
<div class="timeline-body">
|
||
<div class="timeline-head">
|
||
<span class="timeline-node">{{ stepNodeLabel(step.node) }}</span>
|
||
<span class="timeline-action" :class="{ 'is-reject-text': step.action === 2 }">
|
||
{{ stepActionLabel(step.action) }}
|
||
</span>
|
||
</div>
|
||
<div class="timeline-meta">
|
||
{{ step.operator_name }} · {{ formatDateTime(step.create_time) }}
|
||
</div>
|
||
<p v-if="step.opinion" class="timeline-opinion">「{{ step.opinion }}」</p>
|
||
</div>
|
||
</div>
|
||
<p v-if="detail.steps.length === 0" class="timeline-empty">暂无节点记录</p>
|
||
</div>
|
||
|
||
<!-- 节点动作:仅在途单展示 -->
|
||
<template v-if="canAct(detail)">
|
||
<div class="action-bar">
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="actionSubmitting && actionForm.action === 1"
|
||
@click="openAction(detail, 1)"
|
||
>
|
||
通过
|
||
</el-button>
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
size="small"
|
||
:loading="actionSubmitting && actionForm.action === 2"
|
||
@click="openAction(detail, 2)"
|
||
>
|
||
驳回
|
||
</el-button>
|
||
</div>
|
||
<el-form v-if="actingId === detail.id" label-position="top" class="action-form">
|
||
<el-form-item :label="actionForm.action === 2 ? '驳回意见(必填)' : '审批意见(选填)'">
|
||
<el-input
|
||
v-model="actionForm.opinion"
|
||
type="textarea"
|
||
:rows="2"
|
||
maxlength="255"
|
||
placeholder="审批意见将随节点留痕"
|
||
/>
|
||
</el-form-item>
|
||
<el-button type="primary" :loading="actionSubmitting" @click="submitAction">
|
||
确认{{ actionForm.action === 1 ? '通过' : '驳回' }}
|
||
</el-button>
|
||
</el-form>
|
||
</template>
|
||
<p v-else class="readonly-note">该单已结束,节点记录只读。</p>
|
||
</template>
|
||
</div>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filters {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.detail-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.detail-no {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.detail-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 10px 0 4px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
/* 竖排时间线:左侧轨道 + 节点圆点,动作色区分人工/自动/驳回 */
|
||
.timeline {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.timeline-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.timeline-rail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
width: 14px;
|
||
}
|
||
|
||
.timeline-dot {
|
||
flex-shrink: 0;
|
||
width: 10px;
|
||
height: 10px;
|
||
margin-top: 5px;
|
||
background: var(--ys-indigo);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.timeline-dot.is-auto {
|
||
background: var(--ys-ink-3);
|
||
}
|
||
|
||
.timeline-dot.is-reject {
|
||
background: #c0392b;
|
||
}
|
||
|
||
.timeline-line {
|
||
flex: 1;
|
||
width: 1px;
|
||
min-height: 26px;
|
||
background: rgba(28, 35, 51, 0.14);
|
||
}
|
||
|
||
.timeline-body {
|
||
flex: 1;
|
||
padding-bottom: 16px;
|
||
}
|
||
|
||
.timeline-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
}
|
||
|
||
.timeline-node {
|
||
font-weight: 600;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.timeline-action {
|
||
font-size: 12px;
|
||
color: var(--ys-indigo);
|
||
}
|
||
|
||
.timeline-action.is-reject-text {
|
||
color: #c0392b;
|
||
}
|
||
|
||
.timeline-meta {
|
||
margin-top: 2px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.timeline-opinion {
|
||
margin: 6px 0 0;
|
||
padding: 6px 10px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink);
|
||
background: #f4f5f8;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.timeline-empty {
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.is-withdraw .timeline-node {
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.action-bar {
|
||
display: flex;
|
||
gap: 10px;
|
||
padding-top: 6px;
|
||
border-top: 1px solid var(--ys-line);
|
||
}
|
||
|
||
.action-form {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.readonly-note {
|
||
margin-top: 14px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
</style>
|