Files
cma-management/frontend/src/views/TreasuryPayment.vue
T
wecom-fullstack f51fc72ac7
ci/woodpecker/push/main Pipeline failed
style(page-title): B 全站规范——页面内同名标题清零 + 增量副标题弱化 + 标题 emoji 换 Lucide
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处)
- 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标
- 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history)
- KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头
- 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
2026-10-18 19:33:11 +08:00

574 lines
26 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.
<template>
<div class="treasury-payment-page">
<div class="page-header">
<div class="header-right">
<el-button type="primary" size="small" @click="loadAll">刷新</el-button>
</div>
</div>
<el-tabs v-model="activeTab">
<!-- ══ Tab 1 支付单 ══ -->
<el-tab-pane label="支付单" name="payments">
<el-card shadow="never">
<div class="filter-bar">
<el-select v-model="payFilter.status" size="small" clearable placeholder="状态" style="width:120px">
<el-option value="draft" label="草稿" />
<el-option value="precheck" label="已拦截" />
<el-option value="pending_approval" label="待审批" />
<el-option value="approved" label="已放行" />
<el-option value="paid" label="已支付" />
<el-option value="rejected" label="已驳回" />
</el-select>
<el-select v-model="payFilter.check_result" size="small" clearable placeholder="预检结果" style="width:120px">
<el-option value="pass" label="通过" />
<el-option value="warn" label="警告" />
<el-option value="blocked" label="拦截" />
</el-select>
<el-button type="primary" size="small" @click="loadPayments">查询</el-button>
<el-button size="small" @click="payFilter = { status: '', check_result: '' }">重置</el-button>
<el-button type="success" size="small" @click="openPayDialog(null)">新建支付单</el-button>
</div>
<el-table :data="payments" size="small" v-loading="paymentsLoading" style="margin-top:8px">
<el-table-column prop="pay_no" label="单号" width="140" />
<el-table-column label="付款账户" min-width="170">
<template #default="{ row }">{{ accountLabel(row.from_account_id) }}</template>
</el-table-column>
<el-table-column prop="to_name" label="收款方" min-width="120" />
<el-table-column label="金额(万)" width="100">
<template #default="{ row }"><span style="font-weight:600">¥{{ (row.amount ?? 0).toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="purpose" label="用途" min-width="140" show-overflow-tooltip />
<el-table-column prop="pay_type" label="类型" width="80" />
<el-table-column prop="pay_date" label="付款日期" width="100" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="check_result" label="预检" width="80">
<template #default="{ row }">
<el-tag v-if="row.check_result" :type="checkType(row.check_result)" size="small">{{ checkLabel(row.check_result) }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="双签" width="90">
<template #default="{ row }">
<el-tag v-if="row.dual_sign_effective === 1" type="success" size="small">双签有效</el-tag>
<el-tag v-else-if="row.dual_sign_effective === 0" type="danger" size="small">形式双签</el-tag>
<el-tag v-else-if="(row.required_approvals ?? 0) >= 2" type="info" size="small">待双签</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="submitter" label="提交人" width="90" />
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'draft'">
<el-button type="primary" size="small" @click="doPrecheck(row)">预检</el-button>
<el-button size="small" @click="openPayDialog(row)">编辑</el-button>
</template>
<template v-else-if="row.status === 'precheck'">
<el-button type="primary" size="small" @click="doPrecheck(row)">预检</el-button>
<el-button v-if="row.check_result === 'blocked'" type="warning" size="small" @click="openWaive(row)">豁免</el-button>
</template>
<template v-else-if="row.status === 'pending_approval'">
<el-button type="primary" size="small" @click="openApprove(row)">审批</el-button>
</template>
<template v-else-if="row.status === 'approved'">
<el-button type="success" size="small" @click="doPay(row)">登记支付</el-button>
</template>
<span v-else-if="row.status === 'paid'" style="color:#bbb;font-size:12px">只读</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<!-- ══ Tab 2 规则命中留痕 ══ -->
<el-tab-pane label="规则命中留痕" name="hits">
<el-card shadow="never">
<div class="filter-bar">
<el-input v-model="hitsFilter.rule_code" size="small" clearable placeholder="规则码" style="width:120px" />
<el-select v-model="hitsFilter.verdict" size="small" clearable placeholder="判定" style="width:110px">
<el-option value="blocked" label="拦截" />
<el-option value="warn" label="警告" />
<el-option value="waived" label="豁免" />
<el-option value="pass" label="通过" />
</el-select>
<el-button type="primary" size="small" @click="loadHits">查询</el-button>
</div>
<el-table :data="hits" size="small" v-loading="hitsLoading" style="margin-top:8px">
<el-table-column type="expand">
<template #default="{ row }">
<div class="hit-detail">
<div v-for="(v, k) in row.detail || {}" :key="k" class="hit-detail-row">
<span class="hit-detail-key">{{ k }}</span>
<span class="hit-detail-val">{{ typeof v === 'object' ? JSON.stringify(v) : v }}</span>
</div>
<div v-if="!row.detail || !Object.keys(row.detail).length">无结构化明细</div>
</div>
</template>
</el-table-column>
<el-table-column label="时间" width="150">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="对象" width="110">
<template #default="{ row }">{{ row.target_type }}#{{ row.target_id }}</template>
</el-table-column>
<el-table-column prop="rule_code" label="规则码" width="100" />
<el-table-column prop="verdict" label="判定" width="80">
<template #default="{ row }">
<el-tag v-if="row.verdict === 'blocked'" type="danger" size="small">拦截</el-tag>
<el-tag v-else-if="row.verdict === 'warn'" type="warning" size="small">警告</el-tag>
<el-tag v-else-if="row.verdict === 'waived'" size="small" style="background:#f9f0ff;color:#722ed1;border-color:#d3adf7">豁免</el-tag>
<el-tag v-else-if="row.verdict === 'pass'" type="success" size="small">通过</el-tag>
<el-tag v-else type="info" size="small">{{ row.verdict }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="waived_by" label="豁免人" width="90" />
<el-table-column label="摘要" min-width="240">
<template #default="{ row }">
<span>{{ row.waived_reason || row.detail?.verdict_basis || '-' }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<!-- ══ Tab 3 规则库 / 授权矩阵 ══ -->
<el-tab-pane label="规则库 / 授权矩阵" name="rules">
<el-card shadow="never">
<template #header>规则库(启用/停用即时生效,params 原样回传)</template>
<el-table :data="rules" size="small" v-loading="rulesLoading">
<el-table-column prop="rule_code" label="规则码" width="90" />
<el-table-column prop="rule_name" label="规则名" min-width="160" />
<el-table-column prop="rule_type" label="类型" width="90" />
<el-table-column prop="severity" label="严重度" width="80" />
<el-table-column prop="rule_version" label="版本" width="70" />
<el-table-column label="参数" min-width="200">
<template #default="{ row }">
<el-tooltip :content="JSON.stringify(row.params || {})" placement="top">
<span class="params-preview">{{ JSON.stringify(row.params || {}) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column label="启用" width="80">
<template #default="{ row }">
<el-switch :model-value="row.enabled === 1" @change="(v: any) => toggleRule(row, v)" />
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" style="margin-top:16px">
<template #header>授权矩阵(金额段为左闭右开 [from, to),单位万元)</template>
<el-table :data="limits" size="small" v-loading="rulesLoading">
<el-table-column prop="scope_type" label="范围类型" width="100" />
<el-table-column prop="scope_value" label="范围值" width="100" />
<el-table-column label="金额段(万)" width="180">
<template #default="{ row }">
<span>{{ row.amount_to != null ? `[${(row.amount_from ?? 0).toFixed(2)}, ${(row.amount_to ?? 0).toFixed(2)})` : `${(row.amount_from ?? 0).toFixed(2)} 及以上` }}</span>
</template>
</el-table-column>
<el-table-column label="所需审批级数" width="110">
<template #default="{ row }">{{ row.required_approvals }} 级</template>
</el-table-column>
<el-table-column label="审批角色" min-width="130">
<template #default="{ row }">{{ (row.approver_roles || []).join('、') || '-' }}</template>
</el-table-column>
<el-table-column label="需预算依据" width="90">
<template #default="{ row }">{{ row.require_budget ? '是' : '否' }}</template>
</el-table-column>
<el-table-column label="日累计限额(万)" width="120">
<template #default="{ row }">{{ row.daily_cum_limit != null ? (row.daily_cum_limit ?? 0).toFixed(2) : '-' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- 新建/编辑支付单弹窗 -->
<MyDialog v-model="payDialog.visible" :title="payDialog.mode === 'create' ? '新建支付单' : '编辑支付单'" :width="520">
<el-form label-width="90px">
<el-form-item label="付款账户" required>
<el-select v-model="payForm.from_account_id" filterable style="width:100%" placeholder="仅列出启用账户">
<el-option v-for="a in activeAccounts" :key="a.id" :value="a.id" :label="`${a.account_no} ${a.account_name}`" />
</el-select>
</el-form-item>
<el-form-item label="收款方" required>
<el-input v-model="payForm.to_name" placeholder="收款单位/个人名称" />
</el-form-item>
<el-form-item label="收款账号">
<el-input v-model="payForm.to_account_no" />
</el-form-item>
<el-form-item label="金额" required>
<el-input-number v-model="payForm.amount" :min="0" :precision="2" style="width:160px" />
<span style="margin-left:6px;color:#888">万元</span>
</el-form-item>
<el-form-item label="用途" required>
<el-input v-model="payForm.purpose" type="textarea" :rows="2" placeholder="支付用途(必填)" />
</el-form-item>
<el-form-item label="付款类型">
<el-input v-model="payForm.pay_type" placeholder="如 货款/服务费/工资社保/税费/报销" />
</el-form-item>
<el-form-item label="付款日期">
<el-date-picker v-model="payForm.pay_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
</el-form-item>
<el-form-item label="提交人">
<el-input v-model="payForm.submitter" placeholder="默认当前用户" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="payDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="payDialog.saving" @click="savePayment">保存</el-button>
</template>
</MyDialog>
<!-- 审批弹窗 -->
<MyDialog v-model="approveVisible" title="审批支付单" :width="460">
<el-form label-width="90px">
<el-form-item label="单号">{{ currentRow?.pay_no }}</el-form-item>
<el-form-item label="收款方">{{ currentRow?.to_name }}</el-form-item>
<el-form-item label="金额">¥{{ (currentRow?.amount ?? 0).toFixed(2) }}万</el-form-item>
<el-form-item label="所需审批">{{ currentRow?.required_approvals ?? 1 }} 级(双签需不同自然人)</el-form-item>
<el-form-item label="审批备注">
<el-input v-model="approveComment" type="textarea" :rows="3" placeholder="审批意见" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="approveVisible = false">取消</el-button>
<el-button type="danger" :loading="approving" @click="submitApprove(false)">驳回</el-button>
<el-button type="primary" :loading="approving" @click="submitApprove(true)">通过</el-button>
</template>
</MyDialog>
<!-- 豁免弹窗 -->
<MyDialog v-model="waiveVisible" title="豁免放行" :width="460">
<el-alert type="warning" :closable="false" style="margin-bottom:12px"
title="豁免通道仅对被 block 规则拦截的支付单开放,全程留痕,豁免级别不得低于该笔所需审批级数。" />
<el-form label-width="90px">
<el-form-item label="单号">{{ currentRow?.pay_no }}</el-form-item>
<el-form-item label="金额">¥{{ (currentRow?.amount ?? 0).toFixed(2) }}万</el-form-item>
<el-form-item label="豁免审批人" required>
<el-input v-model="waivedBy" placeholder="系统用户姓名或账号(≥2 级审批需 ceo)" />
</el-form-item>
<el-form-item label="豁免事由" required>
<el-input v-model="waivedReason" type="textarea" :rows="3" placeholder="必填,不少于 4 字" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="waiveVisible = false">取消</el-button>
<el-button type="warning" :loading="waiving" @click="submitWaive">确认豁免</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import api from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('payments')
const payments = ref<any[]>([])
const paymentsLoading = ref(false)
const payFilter = ref<any>({ status: '', check_result: '' })
const accounts = ref<any[]>([])
const activeAccounts = computed(() => accounts.value.filter((a: any) => a.status === 'active'))
const hits = ref<any[]>([])
const hitsLoading = ref(false)
const hitsFilter = ref<any>({ rule_code: '', verdict: '' })
const rules = ref<any[]>([])
const limits = ref<any[]>([])
const rulesLoading = ref(false)
// 支付单弹窗
const payDialog = ref<any>({ visible: false, mode: 'create', saving: false })
const payForm = ref<any>({})
// 审批弹窗
const approveVisible = ref(false)
const approveComment = ref('')
const approving = ref(false)
const currentRow = ref<any>(null)
// 豁免弹窗
const waiveVisible = ref(false)
const waivedBy = ref('')
const waivedReason = ref('')
const waiving = ref(false)
function errDetail(e: any, fallback: string): string {
return e?.response?.data?.detail || fallback
}
function statusLabel(v: string): string {
return ({ draft: '草稿', precheck: '已拦截', pending_approval: '待审批', approved: '已放行', paid: '已支付', rejected: '已驳回', cancelled: '已取消' } as any)[v] || v || '-'
}
function statusType(v: string): any {
return ({ draft: 'info', precheck: 'danger', pending_approval: 'warning', approved: 'success', paid: 'success', rejected: 'info', cancelled: 'info' } as any)[v] || 'info'
}
function checkLabel(v: string): string {
return ({ pass: '通过', warn: '警告', blocked: '拦截' } as any)[v] || v || '-'
}
function checkType(v: string): any {
return ({ pass: 'success', warn: 'warning', blocked: 'danger' } as any)[v] || 'info'
}
function fmtTime(v: any): string {
if (!v) return '-'
return String(v).replace('T', ' ').slice(0, 19)
}
function accountLabel(id: number): string {
const a = accounts.value.find((x: any) => x.id === id)
return a ? `${a.account_no} ${a.account_name}` : `#${id}`
}
async function loadAccounts() {
try {
const r: any = await api.get('/treasury/accounts', { params: { status: 'active' } })
accounts.value = r.items || r || []
} catch (e: any) {
ElMessage.error(errDetail(e, '加载账户池失败'))
}
}
async function loadPayments() {
paymentsLoading.value = true
try {
const params: any = {}
if (payFilter.value.status) params.status = payFilter.value.status
if (payFilter.value.check_result) params.check_result = payFilter.value.check_result
const r: any = await api.get('/treasury/payments', { params })
payments.value = r.items || r || []
} catch (e: any) {
ElMessage.error(errDetail(e, '加载支付单失败'))
} finally {
paymentsLoading.value = false
}
}
async function loadHits() {
hitsLoading.value = true
try {
const params: any = {}
if (hitsFilter.value.rule_code) params.rule_code = hitsFilter.value.rule_code
if (hitsFilter.value.verdict) params.verdict = hitsFilter.value.verdict
const r: any = await api.get('/treasury/control-hits', { params })
hits.value = r.items || r || []
} catch (e: any) {
ElMessage.error(errDetail(e, '加载规则命中留痕失败'))
} finally {
hitsLoading.value = false
}
}
async function loadRulesAndLimits() {
rulesLoading.value = true
try {
const [r1, r2]: any[] = await Promise.all([
api.get('/treasury/control-rules'),
api.get('/treasury/limits'),
])
rules.value = r1.items || r1 || []
limits.value = r2.items || r2 || []
} catch (e: any) {
ElMessage.error(errDetail(e, '加载规则库失败'))
} finally {
rulesLoading.value = false
}
}
function loadAll() {
loadPayments()
if (activeTab.value === 'hits') loadHits()
if (activeTab.value === 'rules') loadRulesAndLimits()
}
// ── 预检 ──
async function doPrecheck(row: any) {
try {
const updated: any = await api.post(`/treasury/payments/${row.id}/precheck`)
Object.assign(row, updated)
const results = updated.check_detail?.results || []
const hitCodes = results.filter((r: any) => r.verdict !== 'pass').map((r: any) => r.rule_code).join('、')
if (updated.check_result === 'pass') {
ElMessage.success('预检通过:全部规则通过,已进入待审批')
} else if (updated.check_result === 'warn') {
ElMessage({ message: `预检警告:命中规则 ${hitCodes},请关注后审批`, type: 'warning', duration: 5000 })
} else {
ElMessage({ message: `预检拦截:命中规则 ${hitCodes},该单被 block,可走豁免通道放行`, type: 'error', duration: 6000 })
}
} catch (e: any) {
ElMessage.error(errDetail(e, '预检失败'))
}
}
// ── 审批 ──
function openApprove(row: any) {
currentRow.value = row
approveComment.value = ''
approveVisible.value = true
}
async function submitApprove(approve: boolean) {
if (!currentRow.value) return
approving.value = true
try {
const res: any = await api.post(`/treasury/payments/${currentRow.value.id}/approve`, {
approve, comment: approveComment.value,
})
if (!approve) {
Object.assign(currentRow.value, res)
ElMessage.success('已驳回')
} else if (res.approved === false) {
Object.assign(currentRow.value, res.payment)
ElMessage({ message: res.reason || '审批未通过(双签未满足)', type: 'warning', duration: 6000 })
} else {
Object.assign(currentRow.value, res.payment)
ElMessage.success('审批通过,已放行')
}
approveVisible.value = false
loadPayments()
} catch (e: any) {
ElMessage.error(errDetail(e, '审批操作失败'))
} finally {
approving.value = false
}
}
// ── 豁免 ──
function openWaive(row: any) {
currentRow.value = row
waivedBy.value = ''
waivedReason.value = ''
waiveVisible.value = true
}
async function submitWaive() {
if (!currentRow.value) return
if (!waivedBy.value.trim()) {
ElMessage.warning('请填写豁免审批人')
return
}
if ((waivedReason.value || '').trim().length < 4) {
ElMessage.warning('豁免事由必填且不少于 4 字')
return
}
waiving.value = true
try {
await api.post(`/treasury/payments/${currentRow.value.id}/waive`, {
waived_by: waivedBy.value.trim(),
waived_reason: waivedReason.value.trim(),
})
ElMessage.success('已豁免放行')
waiveVisible.value = false
loadPayments()
loadHits()
} catch (e: any) {
ElMessage.error(errDetail(e, '豁免失败'))
} finally {
waiving.value = false
}
}
// ── 登记支付 ──
async function doPay(row: any) {
try {
await ElMessageBox.confirm(`确认登记支付单 ${row.pay_no}(¥${(row.amount ?? 0).toFixed(2)}万)已实际支付?`, '登记支付', { type: 'warning' })
await api.post(`/treasury/payments/${row.id}/pay`)
ElMessage.success('已登记支付')
loadPayments()
} catch (e: any) {
if (e === 'cancel' || e === 'close') return
ElMessage.error(errDetail(e, '登记支付失败'))
}
}
// ── 新建/编辑支付单 ──
function openPayDialog(row: any) {
payDialog.value.mode = row ? 'edit' : 'create'
payForm.value = row
? {
id: row.id, from_account_id: row.from_account_id, to_name: row.to_name,
to_account_no: row.to_account_no, amount: row.amount, currency: row.currency || 'CNY',
purpose: row.purpose, pay_type: row.pay_type, pay_date: row.pay_date, submitter: row.submitter,
}
: { id: null, from_account_id: null, to_name: '', to_account_no: '', amount: 0, currency: 'CNY', purpose: '', pay_type: '', pay_date: '', submitter: '' }
payDialog.value.visible = true
}
async function savePayment() {
const f = payForm.value
if (!f.from_account_id) { ElMessage.warning('请选择付款账户'); return }
if (!f.to_name.trim()) { ElMessage.warning('请填写收款方'); return }
if (!(f.amount > 0)) { ElMessage.warning('金额须大于 0'); return }
if (!f.purpose.trim()) { ElMessage.warning('用途为必填项'); return }
payDialog.value.saving = true
try {
const payload = {
from_account_id: f.from_account_id, to_name: f.to_name.trim(), to_account_no: f.to_account_no,
amount: f.amount, currency: f.currency || 'CNY', purpose: f.purpose.trim(),
pay_type: f.pay_type, pay_date: f.pay_date || undefined, submitter: f.submitter || undefined,
}
if (payDialog.value.mode === 'edit') {
await api.put(`/treasury/payments/${f.id}`, payload)
ElMessage.success('支付单已更新')
} else {
await api.post('/treasury/payments', payload)
ElMessage.success('支付单已创建(草稿状态,请先预检)')
}
payDialog.value.visible = false
loadPayments()
} catch (e: any) {
ElMessage.error(errDetail(e, '保存支付单失败'))
} finally {
payDialog.value.saving = false
}
}
// ── 规则启用/停用 ──
async function toggleRule(row: any, val: any) {
try {
await api.put(`/treasury/control-rules/${row.id}`, {
rule_code: row.rule_code, rule_name: row.rule_name, rule_type: row.rule_type,
severity: row.severity, params: row.params, rule_version: row.rule_version,
enabled: val ? 1 : 0, remark: row.remark,
})
row.enabled = val ? 1 : 0
ElMessage.success(val ? `规则 ${row.rule_code} 已启用` : `规则 ${row.rule_code} 已停用`)
} catch (e: any) {
ElMessage.error(errDetail(e, '更新规则失败'))
}
}
onMounted(() => {
loadAccounts()
loadPayments()
loadHits()
loadRulesAndLimits()
})
</script>
<style scoped>
.treasury-payment-page { max-width: 1400px; margin: 0 auto; padding: 16px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.header-right { display: flex; gap: 8px; }
.filter-bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.params-preview { font-family: monospace; font-size: 12px; color: #666; cursor: pointer; }
.hit-detail { padding: 8px 16px; background: #fafafa; }
.hit-detail-row { display: flex; margin-bottom: 4px; font-size: 12px; }
.hit-detail-key { width: 140px; color: #888; flex-shrink: 0; }
.hit-detail-val { color: #333; word-break: break-all; }
</style>