feat: 费用审核智能体—规则+报销流程+看板
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
<template>
|
||||
<div class="expense-page">
|
||||
<!-- 页头 -->
|
||||
<div class="expense-header">
|
||||
<h3 class="page-title">🧾 费用审核智能体</h3>
|
||||
<div class="header-right">
|
||||
<el-button size="small" @click="seedRules">预置规则</el-button>
|
||||
<el-button size="small" @click="loadAll">刷新</el-button>
|
||||
<el-button size="small" @click="activeTab = 'rules'">规则配置</el-button>
|
||||
<el-button type="primary" size="small" @click="openSubmitDialog">提交报销单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card" @click="filterByStatus('pending')">
|
||||
<div class="stat-num warn">{{ stats.counts?.pending ?? 0 }}</div>
|
||||
<div class="stat-label">待人工审批</div>
|
||||
</div>
|
||||
<div class="stat-card" @click="filterByStatus('approved')">
|
||||
<div class="stat-num success">{{ stats.counts?.approved ?? 0 }}</div>
|
||||
<div class="stat-label">已通过</div>
|
||||
</div>
|
||||
<div class="stat-card" @click="filterByStatus('returned')">
|
||||
<div class="stat-num danger">{{ stats.counts?.returned ?? 0 }}</div>
|
||||
<div class="stat-label">自动打回</div>
|
||||
</div>
|
||||
<div class="stat-card" @click="filterByStatus('rejected')">
|
||||
<div class="stat-num gray">{{ stats.counts?.rejected ?? 0 }}</div>
|
||||
<div class="stat-label">已拒绝</div>
|
||||
</div>
|
||||
<div class="stat-card highlight">
|
||||
<div class="stat-num">¥{{ fmt(stats.monthly_total) }}</div>
|
||||
<div class="stat-label">本月费用总额 ({{ stats.period }})</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- ── 报销单列表 ── -->
|
||||
<el-tab-pane label="报销单" name="list">
|
||||
<div class="filter-row">
|
||||
<el-select v-model="filters.status" placeholder="状态" clearable size="small" style="width:130px;" @change="loadReimbursements">
|
||||
<el-option label="待审批" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
<el-option label="已打回" value="returned" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.expense_type" placeholder="费用类型" clearable size="small" style="width:130px;" @change="loadReimbursements">
|
||||
<el-option v-for="(label, key) in EXPENSE_TYPES" :key="key" :label="label" :value="key" />
|
||||
</el-select>
|
||||
<el-input v-model="filters.keyword" placeholder="单号/事由/申请人" clearable size="small" style="width:220px;" @change="loadReimbursements" />
|
||||
<span class="filter-info">共 {{ reimbursements.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="reimbursements" border stripe size="small" style="width:100%;margin-top:8px;" v-loading="loadingList">
|
||||
<el-table-column prop="reimb_no" label="单号" width="150" />
|
||||
<el-table-column prop="applicant" label="申请人" width="90" />
|
||||
<el-table-column prop="department" label="部门" width="110" />
|
||||
<el-table-column label="费用类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="typeTag(row.expense_type)">{{ row.expense_type_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="事由" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'amount-over': row.check_result === 'fail' }">¥{{ fmt(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expense_date" label="费用日期" width="100" />
|
||||
<el-table-column label="自动校验" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.check_result === 'pass' ? 'success' : 'danger'">
|
||||
{{ row.check_result === 'pass' ? '通过' : '超限' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<template v-if="canReview && ['pending', 'returned'].includes(row.status)">
|
||||
<el-button size="small" link type="success" @click="openReview(row, 'approve')">通过</el-button>
|
||||
<el-button size="small" link type="danger" @click="openReview(row, 'reject')">拒绝</el-button>
|
||||
</template>
|
||||
<el-button v-if="['returned', 'rejected'].includes(row.status)" size="small" link type="warning" @click="resubmit(row)">重提</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ── 超限预警 ── -->
|
||||
<el-tab-pane label="超限预警" name="overlimit">
|
||||
<el-alert
|
||||
type="warning" :closable="false" show-icon
|
||||
:title="`共 ${stats.over_limit_count ?? 0} 笔报销单被自动打回,请关注以下超标准支出`"
|
||||
style="margin-bottom:8px;"
|
||||
/>
|
||||
<el-table :data="stats.over_limit || []" border stripe size="small" style="width:100%;" v-loading="loadingStats">
|
||||
<el-table-column prop="reimb_no" label="单号" width="150" />
|
||||
<el-table-column prop="applicant" label="申请人" width="90" />
|
||||
<el-table-column prop="department" label="部门" width="110" />
|
||||
<el-table-column label="类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="typeTag(row.expense_type)">{{ row.expense_type_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="事由" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="110" align="right">
|
||||
<template #default="{ row }"><span class="amount-over">¥{{ fmt(row.amount) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="check_reason" label="超限原因" min-width="280" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ── 费用统计 ── -->
|
||||
<el-tab-pane label="费用统计" name="stats">
|
||||
<div class="stats-grid">
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header><span>本月费用类型统计 ({{ stats.period }})</span></template>
|
||||
<div ref="typeChartRef" style="width:100%;height:300px;"></div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="chart-card">
|
||||
<template #header><span>预算使用率</span></template>
|
||||
<div v-for="b in stats.budget_usage || []" :key="b.expense_type" class="budget-item">
|
||||
<div class="budget-head">
|
||||
<span>{{ b.label }}</span>
|
||||
<span>¥{{ fmt(b.used) }} / ¥{{ fmt(b.budget) }} ({{ b.usage_rate }}%)</span>
|
||||
</div>
|
||||
<el-progress :percentage="Math.min(b.usage_rate, 100)" :status="b.usage_rate > 90 ? 'exception' : (b.usage_rate > 70 ? 'warning' : 'success')" :stroke-width="14" />
|
||||
</div>
|
||||
<div class="no-data" v-if="!(stats.budget_usage || []).length">暂无预算数据</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ── 规则配置 ── -->
|
||||
<el-tab-pane label="规则配置" name="rules">
|
||||
<div class="filter-row">
|
||||
<span class="filter-info">自动校验规则 — 超限报销单将自动打回并标注原因</span>
|
||||
<span style="margin-left:auto;">
|
||||
<el-button v-if="canReview" size="small" type="success" @click="openRuleDialog()">新增规则</el-button>
|
||||
<el-button v-if="canReview" size="small" @click="seedRules">预置默认规则</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="rules" border stripe size="small" style="width:100%;margin-top:8px;" v-loading="loadingRules">
|
||||
<el-table-column prop="rule_name" label="规则名称" min-width="160" />
|
||||
<el-table-column label="维度" width="100">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="dimension_value" label="维度值" width="110">
|
||||
<template #default="{ row }">{{ row.dimension_value || '全部' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="费用类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="typeTag(row.expense_type)">{{ row.expense_type_label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="限额类型" width="100">
|
||||
<template #default="{ row }">{{ limitTypeLabel(row.limit_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="限额" width="110" align="right">
|
||||
<template #default="{ row }">¥{{ fmt(row.limit_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">{{ row.status === 'active' ? '启用' : '停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" v-if="canReview">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openRuleDialog(row)">编辑</el-button>
|
||||
<el-button size="small" link :type="row.status === 'active' ? 'warning' : 'success'" @click="toggleRule(row)">
|
||||
{{ row.status === 'active' ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button size="small" link type="danger" @click="deleteRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 提交报销单对话框 -->
|
||||
<MyDialog v-model="submitVisible" title="提交报销单" :width="560">
|
||||
<el-form :model="submitForm" label-width="90px" size="small">
|
||||
<el-form-item label="申请人" required>
|
||||
<el-input v-model="submitForm.applicant" placeholder="报销申请人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="部门">
|
||||
<el-input v-model="submitForm.department" placeholder="所属部门" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用类型" required>
|
||||
<el-select v-model="submitForm.expense_type" placeholder="选择费用类型" style="width:100%;">
|
||||
<el-option v-for="(label, key) in EXPENSE_TYPES" :key="key" :label="label" :value="key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="事由" required>
|
||||
<el-input v-model="submitForm.title" placeholder="报销事由/摘要" />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额" required>
|
||||
<el-input-number v-model="submitForm.amount" :min="0.01" :precision="2" :step="100" style="width:100%;" placeholder="报销金额(元)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用日期">
|
||||
<el-date-picker v-model="submitForm.expense_date" type="date" value-format="YYYY-MM-DD" placeholder="费用发生日期" style="width:100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="附件">
|
||||
<el-input v-model="submitForm.attachment" placeholder="附件文件名(如: 发票.jpg)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="submitVisible = false">取消</el-button>
|
||||
<el-button type="primary" size="small" :loading="submitting" @click="doSubmit">提交并自动校验</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 规则编辑对话框 -->
|
||||
<MyDialog v-model="ruleDialogVisible" :title="ruleForm.id ? '编辑规则' : '新增规则'" :width="520">
|
||||
<el-form :model="ruleForm" label-width="90px" size="small">
|
||||
<el-form-item label="规则名称" required>
|
||||
<el-input v-model="ruleForm.rule_name" placeholder="如: 招待费单笔限额" />
|
||||
</el-form-item>
|
||||
<el-form-item label="维度">
|
||||
<el-select v-model="ruleForm.dimension" style="width:100%;">
|
||||
<el-option label="费用类型(全局)" value="expense_type" />
|
||||
<el-option label="部门" value="department" />
|
||||
<el-option label="人员" value="person" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="维度值">
|
||||
<el-input v-model="ruleForm.dimension_value" placeholder="部门名/人员名,留空=全部" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用类型" required>
|
||||
<el-select v-model="ruleForm.expense_type" style="width:100%;">
|
||||
<el-option v-for="(label, key) in EXPENSE_TYPES" :key="key" :label="label" :value="key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="限额类型">
|
||||
<el-select v-model="ruleForm.limit_type" style="width:100%;">
|
||||
<el-option label="单笔限额" value="single" />
|
||||
<el-option label="月度累计限额" value="monthly" />
|
||||
<el-option label="年度累计限额" value="yearly" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="限额金额" required>
|
||||
<el-input-number v-model="ruleForm.limit_amount" :min="0" :precision="2" style="width:100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="ruleForm.remark" type="textarea" :rows="2" placeholder="规则说明" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="ruleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" size="small" :loading="savingRule" @click="saveRule">{{ ruleForm.id ? '保存' : '创建' }}</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 报销单详情对话框 -->
|
||||
<MyDialog v-model="detailVisible" title="报销单详情" :width="720">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="单号">{{ detail.reimb_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申请人">{{ detail.applicant }}</el-descriptions-item>
|
||||
<el-descriptions-item label="部门">{{ detail.department || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类型">
|
||||
<el-tag size="small" :type="typeTag(detail.expense_type)">{{ detail.expense_type_label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="金额">
|
||||
<span :class="{ 'amount-over': detail.check_result === 'fail' }">¥{{ fmt(detail.amount) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="费用日期">{{ detail.expense_date || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="事由" :span="3">{{ detail.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="附件" :span="3">{{ detail.attachment || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自动校验">
|
||||
<el-tag size="small" :type="detail.check_result === 'pass' ? 'success' : 'danger'">
|
||||
{{ detail.check_result === 'pass' ? '通过' : '超限打回' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(detail.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审批人">{{ detail.approver || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间" :span="2">{{ detail.created_at }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审批意见" :span="3">{{ detail.approve_comment || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="check-block" v-if="detail.check_detail && detail.check_detail.length">
|
||||
<div class="check-title">校验明细</div>
|
||||
<div v-for="(c, i) in detail.check_detail" :key="i" class="check-item">
|
||||
<el-tag size="small" :type="c.passed ? 'success' : 'danger'" style="width:80px;text-align:center;">
|
||||
{{ c.passed ? '通过' : '超限' }}
|
||||
</el-tag>
|
||||
<span class="check-name">{{ c.rule_name }} ({{ c.rule_type }})</span>
|
||||
<span class="check-detail">{{ c.detail }}</span>
|
||||
</div>
|
||||
<div v-if="detail.check_reason" class="check-reason">⚠ 超限原因:{{ detail.check_reason }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canReview && ['pending', 'returned'].includes(detail.status)" class="detail-actions">
|
||||
<el-button size="small" type="success" @click="openReview(detail, 'approve')">通过</el-button>
|
||||
<el-button size="small" type="danger" @click="openReview(detail, 'reject')">拒绝</el-button>
|
||||
<el-button size="small" @click="openReview(detail, 'return')">人工打回</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</MyDialog>
|
||||
|
||||
<!-- 审批操作对话框 -->
|
||||
<MyDialog v-model="reviewVisible" :title="reviewTitle" :width="460">
|
||||
<el-form label-width="80px" size="small">
|
||||
<el-form-item label="报销单">{{ review.reimb?.reimb_no }} — ¥{{ fmt(review.reimb?.amount) }}</el-form-item>
|
||||
<el-form-item label="审批意见" :required="review.mode === 'reject'">
|
||||
<el-input v-model="review.comment" type="textarea" :rows="3" :placeholder="review.mode === 'reject' ? '拒绝时必须填写意见' : '选填'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="reviewVisible = false">取消</el-button>
|
||||
<el-button size="small" :type="review.mode === 'approve' ? 'success' : (review.mode === 'reject' ? 'danger' : 'warning')" :loading="reviewing" @click="doReview">
|
||||
{{ review.mode === 'approve' ? '确认通过' : (review.mode === 'reject' ? '确认拒绝' : '确认打回') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</MyDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { expenseApi } from '../api/index'
|
||||
import MyDialog from '../components/MyDialog.vue'
|
||||
|
||||
const EXPENSE_TYPES: Record<string, string> = {
|
||||
entertainment: '招待费',
|
||||
travel: '差旅费',
|
||||
office: '办公费',
|
||||
management: '管理费',
|
||||
}
|
||||
|
||||
const userStr = localStorage.getItem('cma_user') || '{}'
|
||||
const user = JSON.parse(userStr)
|
||||
const role: string = user.role || ''
|
||||
const canReview = ['ceo', 'finance'].includes(role)
|
||||
|
||||
const activeTab = ref('list')
|
||||
const loadingList = ref(false)
|
||||
const loadingRules = ref(false)
|
||||
const loadingStats = ref(false)
|
||||
|
||||
// ── 统计 ──
|
||||
const stats = ref<any>({ counts: {}, over_limit: [], budget_usage: [], amounts_by_type: [] })
|
||||
|
||||
// ── 报销单 ──
|
||||
const filters = reactive({ status: '', expense_type: '', keyword: '' })
|
||||
const reimbursements = ref<any[]>([])
|
||||
function filterByStatus(st: string) {
|
||||
filters.status = st
|
||||
activeTab.value = 'list'
|
||||
loadReimbursements()
|
||||
}
|
||||
|
||||
// ── 规则 ──
|
||||
const rules = ref<any[]>([])
|
||||
|
||||
// ── 对话框 ──
|
||||
const submitVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submitForm = reactive({
|
||||
applicant: user.name || user.username || '',
|
||||
department: '',
|
||||
expense_type: '',
|
||||
title: '',
|
||||
amount: undefined as number | undefined,
|
||||
expense_date: '',
|
||||
attachment: '',
|
||||
})
|
||||
|
||||
const ruleDialogVisible = ref(false)
|
||||
const savingRule = ref(false)
|
||||
const ruleForm = reactive({
|
||||
id: 0,
|
||||
rule_name: '',
|
||||
dimension: 'expense_type',
|
||||
dimension_value: '',
|
||||
expense_type: 'entertainment',
|
||||
limit_type: 'single',
|
||||
limit_amount: 1000,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref<any>(null)
|
||||
|
||||
const reviewVisible = ref(false)
|
||||
const reviewing = ref(false)
|
||||
const review = reactive({ mode: 'approve' as 'approve' | 'reject' | 'return', reimb: null as any, comment: '' })
|
||||
const reviewTitle = computed(() => {
|
||||
const m: Record<string, string> = { approve: '审批通过', reject: '审批拒绝', return: '人工打回' }
|
||||
return m[review.mode]
|
||||
})
|
||||
|
||||
// ── 图表 ──
|
||||
const typeChartRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── 工具 ──
|
||||
function fmt(v: any): string {
|
||||
if (v === null || v === undefined) return '0'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function statusLabel(s: string): string {
|
||||
const m: Record<string, string> = { pending: '待审批', approved: '已通过', rejected: '已拒绝', returned: '已打回' }
|
||||
return m[s] || s
|
||||
}
|
||||
function statusTag(s: string): string {
|
||||
const m: Record<string, string> = { pending: 'warning', approved: 'success', rejected: 'danger', returned: 'info' }
|
||||
return m[s] || 'info'
|
||||
}
|
||||
function typeTag(t: string): string {
|
||||
const m: Record<string, string> = { entertainment: 'danger', travel: 'warning', office: 'primary', management: 'info' }
|
||||
return m[t] || 'info'
|
||||
}
|
||||
function dimensionLabel(d: string): string {
|
||||
const m: Record<string, string> = { expense_type: '费用类型', department: '部门', person: '人员' }
|
||||
return m[d] || d
|
||||
}
|
||||
function limitTypeLabel(l: string): string {
|
||||
const m: Record<string, string> = { single: '单笔', monthly: '月度累计', yearly: '年度累计' }
|
||||
return m[l] || l
|
||||
}
|
||||
|
||||
// ── 加载 ──
|
||||
async function loadStats() {
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const r: any = await expenseApi.stats()
|
||||
stats.value = r || {}
|
||||
renderChart()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('统计加载失败: ' + (e?.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReimbursements() {
|
||||
loadingList.value = true
|
||||
try {
|
||||
const r: any = await expenseApi.listReimbursements({
|
||||
status: filters.status || undefined,
|
||||
expense_type: filters.expense_type || undefined,
|
||||
keyword: filters.keyword || undefined,
|
||||
})
|
||||
reimbursements.value = r.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error('报销单加载失败: ' + (e?.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
loadingList.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
loadingRules.value = true
|
||||
try {
|
||||
const r: any = await expenseApi.listRules()
|
||||
rules.value = r.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error('规则加载失败: ' + (e?.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
loadingRules.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadAll() {
|
||||
loadStats()
|
||||
loadReimbursements()
|
||||
loadRules()
|
||||
}
|
||||
|
||||
// ── 图表渲染 ──
|
||||
async function renderChart() {
|
||||
await nextTick()
|
||||
if (!typeChartRef.value) return
|
||||
const echarts: any = await import('echarts')
|
||||
const el = typeChartRef.value
|
||||
const chart = echarts.getInstanceByDom(el) || echarts.init(el)
|
||||
const items = stats.value.amounts_by_type || []
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 60, right: 20, top: 30, bottom: 40 },
|
||||
xAxis: { type: 'category', data: items.map((i: any) => i.label) },
|
||||
yAxis: { type: 'value', name: '元' },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: items.map((i: any) => i.amount),
|
||||
barWidth: 48,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: (p: any) => {
|
||||
const colors = ['#F56C6C', '#E6A23C', '#409EFF', '#909399']
|
||||
return colors[p.dataIndex % colors.length]
|
||||
},
|
||||
},
|
||||
label: { show: true, position: 'top', formatter: (p: any) => '¥' + Number(p.value).toLocaleString() },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
// ── 提交报销 ──
|
||||
function openSubmitDialog() {
|
||||
submitForm.applicant = user.name || user.username || ''
|
||||
submitForm.department = ''
|
||||
submitForm.expense_type = ''
|
||||
submitForm.title = ''
|
||||
submitForm.amount = undefined
|
||||
submitForm.expense_date = ''
|
||||
submitForm.attachment = ''
|
||||
submitVisible.value = true
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
if (!submitForm.applicant || !submitForm.expense_type || !submitForm.title || !submitForm.amount) {
|
||||
ElMessage.warning('请填写申请人、费用类型、事由和金额')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const r: any = await expenseApi.submitReimbursement({ ...submitForm })
|
||||
submitVisible.value = false
|
||||
ElMessage.success(r.message || '已提交')
|
||||
if (r.data && r.data.check_result === 'fail') {
|
||||
ElMessageBox.alert(`该报销单超限,已自动打回:\n${r.data.check_reason || ''}`, '自动校验未通过', { confirmButtonText: '知道了' })
|
||||
}
|
||||
loadAll()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('提交失败: ' + (e?.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 审批 ──
|
||||
function openReview(row: any, mode: 'approve' | 'reject' | 'return') {
|
||||
review.mode = mode
|
||||
review.reimb = row
|
||||
review.comment = ''
|
||||
reviewVisible.value = true
|
||||
}
|
||||
|
||||
async function doReview() {
|
||||
if (!review.reimb) return
|
||||
if (review.mode === 'reject' && !review.comment.trim()) {
|
||||
ElMessage.warning('拒绝时必须填写审批意见')
|
||||
return
|
||||
}
|
||||
reviewing.value = true
|
||||
try {
|
||||
const id = review.reimb.id
|
||||
if (review.mode === 'approve') await expenseApi.approveReimbursement(id, review.comment)
|
||||
else if (review.mode === 'reject') await expenseApi.rejectReimbursement(id, review.comment)
|
||||
else await expenseApi.returnReimbursement(id, review.comment)
|
||||
reviewVisible.value = false
|
||||
detailVisible.value = false
|
||||
ElMessage.success('操作成功')
|
||||
loadAll()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('操作失败: ' + (e?.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resubmit(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认重新提交「${row.reimb_no}」?将重新执行自动校验。`, '重新提交', { confirmButtonText: '重新提交', cancelButtonText: '取消' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r: any = await expenseApi.resubmitReimbursement(row.id)
|
||||
ElMessage.success(r.message || '已重新提交')
|
||||
loadAll()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('重提失败: ' + (e?.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 详情 ──
|
||||
async function openDetail(row: any) {
|
||||
try {
|
||||
const r: any = await expenseApi.getReimbursement(row.id)
|
||||
detail.value = r
|
||||
detailVisible.value = true
|
||||
} catch (e: any) {
|
||||
ElMessage.error('详情加载失败: ' + (e?.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 规则 CRUD ──
|
||||
function openRuleDialog(row?: any) {
|
||||
if (row) {
|
||||
Object.assign(ruleForm, {
|
||||
id: row.id, rule_name: row.rule_name, dimension: row.dimension,
|
||||
dimension_value: row.dimension_value || '', expense_type: row.expense_type,
|
||||
limit_type: row.limit_type, limit_amount: row.limit_amount, remark: row.remark || '',
|
||||
})
|
||||
} else {
|
||||
Object.assign(ruleForm, {
|
||||
id: 0, rule_name: '', dimension: 'expense_type', dimension_value: '',
|
||||
expense_type: 'entertainment', limit_type: 'single', limit_amount: 1000, remark: '',
|
||||
})
|
||||
}
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveRule() {
|
||||
if (!ruleForm.rule_name || !ruleForm.expense_type || ruleForm.limit_amount === null || ruleForm.limit_amount === undefined) {
|
||||
ElMessage.warning('请填写规则名称、费用类型和限额金额')
|
||||
return
|
||||
}
|
||||
savingRule.value = true
|
||||
try {
|
||||
if (ruleForm.id) {
|
||||
await expenseApi.updateRule(ruleForm.id, { ...ruleForm })
|
||||
} else {
|
||||
await expenseApi.createRule({ ...ruleForm })
|
||||
}
|
||||
ruleDialogVisible.value = false
|
||||
ElMessage.success('规则已保存')
|
||||
loadRules()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('保存失败: ' + (e?.response?.data?.detail || e.message))
|
||||
} finally {
|
||||
savingRule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRule(row: any) {
|
||||
try {
|
||||
await expenseApi.updateRule(row.id, { status: row.status === 'active' ? 'inactive' : 'active' })
|
||||
ElMessage.success(row.status === 'active' ? '已停用' : '已启用')
|
||||
loadRules()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('操作失败: ' + (e?.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRule(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除规则「${row.rule_name}」?`, '删除规则', { confirmButtonText: '删除', cancelButtonText: '取消' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await expenseApi.deleteRule(row.id)
|
||||
ElMessage.success('已删除')
|
||||
loadRules()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('删除失败: ' + (e?.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRules() {
|
||||
try {
|
||||
await ElMessageBox.confirm('将预置默认费用规则(招待费/差旅/办公/管理费标准),已有同名规则跳过。', '预置规则', { confirmButtonText: '预置', cancelButtonText: '取消' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const r: any = await expenseApi.seedRules()
|
||||
ElMessage.success(r.message || '预置完成')
|
||||
loadRules()
|
||||
} catch (e: any) {
|
||||
ElMessage.error('预置失败: ' + (e?.response?.data?.detail || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.expense-page { padding: 4px; }
|
||||
.expense-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.expense-header .page-title { margin: 0; font-size: 18px; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
|
||||
.stat-cards { display: flex; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.stat-card {
|
||||
flex: 1; min-width: 140px; background: #fff; border-radius: 8px; padding: 14px 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: transform .15s;
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); }
|
||||
.stat-card.highlight { background: linear-gradient(135deg, #409EFF, #337ecc); color: #fff; cursor: default; }
|
||||
.stat-num { font-size: 24px; font-weight: 700; }
|
||||
.stat-num.warn { color: #E6A23C; }
|
||||
.stat-num.success { color: #67C23A; }
|
||||
.stat-num.danger { color: #F56C6C; }
|
||||
.stat-num.gray { color: #909399; }
|
||||
.stat-card.highlight .stat-num { color: #fff; }
|
||||
.stat-label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.stat-card.highlight .stat-label { color: rgba(255,255,255,0.85); }
|
||||
|
||||
.filter-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.filter-info { font-size: 12px; color: #909399; }
|
||||
|
||||
.amount-over { color: #F56C6C; font-weight: 600; }
|
||||
|
||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media screen and (max-width: 900px) { .stats-grid { grid-template-columns: 1fr; } }
|
||||
.budget-item { margin-bottom: 16px; }
|
||||
.budget-head { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; color: #606266; }
|
||||
.no-data { color: #909399; font-size: 13px; padding: 20px 0; text-align: center; }
|
||||
|
||||
.check-block { margin-top: 14px; background: #f8f9fb; border-radius: 6px; padding: 12px; }
|
||||
.check-title { font-weight: 600; font-size: 13px; margin-bottom: 8px; color: #303133; }
|
||||
.check-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
|
||||
.check-name { font-weight: 500; white-space: nowrap; color: #303133; }
|
||||
.check-detail { color: #606266; }
|
||||
.check-reason { margin-top: 8px; color: #F56C6C; font-size: 13px; font-weight: 500; }
|
||||
.detail-actions { margin-top: 14px; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user