@@ -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 : 4 px ; }
. expense - header { display : flex ; align - items : center ; justify - content : space - between ; margin - bottom : 12 px ; }
. expense - header . page - title { margin : 0 ; font - size : 18 px ; }
. header - right { display : flex ; gap : 8 px ; }
. stat - cards { display : flex ; gap : 12 px ; margin - bottom : 12 px ; flex - wrap : wrap ; }
. stat - card {
flex : 1 ; min - width : 140 px ; background : # fff ; border - radius : 8 px ; padding : 14 px 16 px ;
box - shadow : 0 1 px 4 px rgba ( 0 , 0 , 0 , 0.06 ) ; cursor : pointer ; transition : transform .15 s ;
}
. stat - card : hover { transform : translateY ( - 2 px ) ; }
. stat - card . highlight { background : linear - gradient ( 135 deg , # 409 EFF , # 337 ecc ) ; color : # fff ; cursor : default ; }
. stat - num { font - size : 24 px ; font - weight : 700 ; }
. stat - num . warn { color : # E6A23C ; }
. stat - num . success { color : # 67 C23A ; }
. stat - num . danger { color : # F56C6C ; }
. stat - num . gray { color : # 909399 ; }
. stat - card . highlight . stat - num { color : # fff ; }
. stat - label { font - size : 12 px ; color : # 909399 ; margin - top : 4 px ; }
. stat - card . highlight . stat - label { color : rgba ( 255 , 255 , 255 , 0.85 ) ; }
. filter - row { display : flex ; align - items : center ; gap : 8 px ; flex - wrap : wrap ; }
. filter - info { font - size : 12 px ; color : # 909399 ; }
. amount - over { color : # F56C6C ; font - weight : 600 ; }
. stats - grid { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 12 px ; }
@ media screen and ( max - width : 900 px ) { . stats - grid { grid - template - columns : 1 fr ; } }
. budget - item { margin - bottom : 16 px ; }
. budget - head { display : flex ; justify - content : space - between ; font - size : 13 px ; margin - bottom : 6 px ; color : # 606266 ; }
. no - data { color : # 909399 ; font - size : 13 px ; padding : 20 px 0 ; text - align : center ; }
. check - block { margin - top : 14 px ; background : # f8f9fb ; border - radius : 6 px ; padding : 12 px ; }
. check - title { font - weight : 600 ; font - size : 13 px ; margin - bottom : 8 px ; color : # 303133 ; }
. check - item { display : flex ; align - items : center ; gap : 8 px ; padding : 4 px 0 ; font - size : 13 px ; }
. check - name { font - weight : 500 ; white - space : nowrap ; color : # 303133 ; }
. check - detail { color : # 606266 ; }
. check - reason { margin - top : 8 px ; color : # F56C6C ; font - size : 13 px ; font - weight : 500 ; }
. detail - actions { margin - top : 14 px ; display : flex ; gap : 8 px ; justify - content : flex - end ; }
< / style >