Files
PayrollManagementSystem/frontend/src/views/system/approvals/index.vue
T
liuwenxinandClaude Code cc4c51359c fix(frontend): M3 台账/月结/审批三页数据加载与分页——e2e 首跑抓出
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>
2026-10-19 16:24:18 +08:00

425 lines
12 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 { 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>