feat: 费用审核智能体—规则+报销流程+看板

This commit is contained in:
Hermes CI Fix
2026-08-04 14:40:03 +08:00
parent e3e8bad3da
commit 555d3b621a
7 changed files with 1423 additions and 5 deletions
+722
View File
@@ -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>