Files
hfb_sys/frontend/src/features/admin/views/AdminOperatingExpensesView.vue
T

564 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
CircleClose,
Money,
Plus,
Refresh,
Search,
Tickets,
UploadFilled,
View,
Wallet,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import {
createOperatingExpense,
fetchOperatingExpenses,
voidOperatingExpense,
type OperatingExpense,
type OperatingExpenseListSummary,
} from '@/features/admin/api/adminFinance'
import AuthImage from '@/shared/components/business/AuthImage.vue'
import { uploadAdminFile } from '@/shared/api/files'
import { useAdminSessionStore } from '@/stores/adminSession'
import { readError } from '@/shared/utils/error'
import { formatCentWithSymbol, yuanToCent } from '@/shared/utils/money'
import { formatDateTime, formatInputDate } from '@/shared/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const adminSession = useAdminSessionStore()
const loading = ref(false)
const createVisible = ref(false)
const createSaving = ref(false)
const voucherUploading = ref(false)
const actionID = ref(0)
const selectedExpense = ref<OperatingExpense | null>(null)
const items = ref<OperatingExpense[]>([])
const total = ref(0)
const currentPage = ref(1)
const currentPageSize = ref(20)
const summary = ref<OperatingExpenseListSummary>(emptySummary())
const canManage = computed(() => adminSession.hasPermission('finance:operating_expense'))
const filters = reactive({
date_type: 'created' as 'created' | 'occurred',
start_date: defaultStartDate(),
end_date: formatInputDate(new Date()),
status: '',
category: '',
keyword: '',
})
const createForm = reactive({
category: '',
payee_name: '',
amount_yuan: 0,
occurred_at: new Date(),
remark: '',
voucher_url: '',
})
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const result = await fetchOperatingExpenses({
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
})
items.value = result.items
total.value = result.total
summary.value = result.summary
} catch (error) {
items.value = []
total.value = 0
summary.value = emptySummary()
ElMessage.error(readError(error, '运营开支记录加载失败'))
} finally {
loading.value = false
}
}
function search() {
currentPage.value = 1
void loadItems()
}
function resetFilters() {
filters.date_type = 'created'
filters.start_date = defaultStartDate()
filters.end_date = formatInputDate(new Date())
filters.status = ''
filters.category = ''
filters.keyword = ''
currentPage.value = 1
void loadItems()
}
function openCreate() {
createForm.category = ''
createForm.payee_name = ''
createForm.amount_yuan = 0
createForm.occurred_at = new Date()
createForm.remark = ''
createForm.voucher_url = ''
createVisible.value = true
}
async function uploadVoucher(options: { file: File }) {
if (!options.file.type.startsWith('image/')) {
ElMessage.warning('开支凭证仅支持图片')
return
}
voucherUploading.value = true
try {
const uploaded = await uploadAdminFile(options.file, 'operating-expense')
createForm.voucher_url = uploaded.url
ElMessage.success('凭证已上传')
} catch (error) {
ElMessage.error(readError(error, '凭证上传失败'))
} finally {
voucherUploading.value = false
}
}
async function submitExpense() {
const amountCent = yuanToCent(createForm.amount_yuan)
if (
!createForm.category.trim() ||
!createForm.payee_name.trim() ||
amountCent <= 0 ||
!createForm.occurred_at ||
!createForm.remark.trim()
) {
ElMessage.warning('请填写完整的开支信息')
return
}
createSaving.value = true
try {
await createOperatingExpense({
category: createForm.category.trim(),
payee_name: createForm.payee_name.trim(),
amount_cent: amountCent,
occurred_at: createForm.occurred_at.toISOString(),
remark: createForm.remark.trim(),
voucher_url: createForm.voucher_url || undefined,
})
createVisible.value = false
ElMessage.success('运营开支已记账')
await loadItems()
} catch (error) {
ElMessage.error(readError(error, '运营开支记账失败'))
} finally {
createSaving.value = false
}
}
async function handleVoid(item: OperatingExpense) {
if (actionID.value) return
try {
const { value } = await ElMessageBox.prompt(
`确认作废开支单 ${item.expense_no}?作废后将从运营开支统计中排除。`,
'作废运营开支',
{
confirmButtonText: '确认作废',
cancelButtonText: '取消',
inputPlaceholder: '请输入作废原因',
inputValidator: value => !!value.trim() || '请输入作废原因',
type: 'warning',
}
)
actionID.value = item.id
await voidOperatingExpense(item.id, value.trim())
selectedExpense.value = null
ElMessage.success('运营开支已作废')
await loadItems()
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(readError(error, '运营开支作废失败'))
} finally {
actionID.value = 0
}
}
function emptySummary(): OperatingExpenseListSummary {
return { record_count: 0, paid_amount_cent: 0, paid_count: 0 }
}
function defaultStartDate() {
const date = new Date()
date.setDate(date.getDate() - 29)
return formatInputDate(date)
}
function money(value: number | undefined | null) {
return formatCentWithSymbol(Number(value || 0))
}
function statusLabel(status: string) {
return status === 'paid' ? '已记账' : '已作废'
}
function statusType(status: string): 'success' | 'danger' {
return status === 'paid' ? 'success' : 'danger'
}
</script>
<template>
<div class="finance-page-container">
<!-- 头部卡片 -->
<header class="page-header-card">
<div class="header-main">
<div class="title-group">
<div class="eyebrow-tag">
<el-icon><Tickets /></el-icon>
<span>OPERATING EXPENSES</span>
</div>
<h1 class="page-title">运营开支</h1>
<p class="page-subtitle">独立记录日常运营支出费用,并在财务总览中按发生日期汇总统计。</p>
</div>
<div class="toolbar-actions">
<el-button
v-if="canManage"
type="primary"
:icon="Plus"
class="action-btn-primary"
@click="openCreate"
>
新增运营开支
</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="search">
查询
</el-button>
</div>
</div>
</header>
<!-- 汇总指标卡片 -->
<section class="summary-metric-grid">
<div class="metric-card metric-card--paid">
<div class="metric-icon-wrap amber">
<el-icon><Money /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">有效运营开支</span>
<strong class="metric-val amber-val">{{ money(summary.paid_amount_cent) }}</strong>
<span class="metric-sub-pill warning">{{ summary.paid_count }} 笔有效记账</span>
</div>
</div>
<div class="metric-card metric-card--total">
<div class="metric-icon-wrap slate">
<el-icon><Wallet /></el-icon>
</div>
<div class="metric-info">
<span class="metric-label">当前筛选总计</span>
<strong class="metric-val">{{ summary.record_count }} 笔</strong>
<span class="metric-sub-text">包含作废与有效记录</span>
</div>
</div>
</section>
<!-- 筛选卡片 -->
<section class="section-card filter-card">
<el-form class="filter-grid" label-position="top" @keyup.enter="search">
<el-form-item label="日期类型">
<el-select v-model="filters.date_type" class="full-control">
<el-option label="录入日期" value="created" />
<el-option label="实际开支日期" value="occurred" />
</el-select>
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker
v-model="filters.start_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="filters.end_date"
class="full-control"
type="date"
value-format="YYYY-MM-DD"
:clearable="false"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部状态" class="full-control">
<el-option label="已记账" value="paid" />
<el-option label="已作废" value="voided" />
</el-select>
</el-form-item>
<el-form-item label="开支类别">
<el-input v-model="filters.category" clearable placeholder="按类别精确筛选" />
</el-form-item>
<el-form-item label="关键词检索">
<el-input v-model="filters.keyword" clearable placeholder="单号、收款方或用途" />
</el-form-item>
</el-form>
</section>
<!-- 开支数据表格 -->
<section class="section-card table-section-card">
<div class="table-card-header">
<div class="table-header-title">
<el-icon class="header-icon"><Tickets /></el-icon>
<span>运营开支明细账目</span>
</div>
<span class="count-badge">共 {{ total }} 条记账</span>
</div>
<div class="table-container">
<el-table
v-loading="loading"
class="custom-enterprise-table"
:data="items"
stripe
empty-text="暂无运营开支记录"
>
<el-table-column label="开支单号" min-width="190" fixed="left">
<template #default="{ row }">
<span class="mono-code">{{ row.expense_no }}</span>
</template>
</el-table-column>
<el-table-column label="开支类别" min-width="130">
<template #default="{ row }">
<el-tag size="small" effect="plain" class="category-tag">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column label="收款方" min-width="150">
<template #default="{ row }">
<strong class="payee-name">{{ row.payee_name }}</strong>
</template>
</el-table-column>
<el-table-column label="开支金额" min-width="140" align="right">
<template #default="{ row }">
<span class="amount-val orange-val">{{ money(row.amount_cent) }}</span>
</template>
</el-table-column>
<el-table-column label="实际开支时间" min-width="165">
<template #default="{ row }">
<span class="time-cell">{{ formatDateTime(row.occurred_at) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="105" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="录入管理员" min-width="120">
<template #default="{ row }">
<span class="operator-name">{{ row.created_by_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="开支备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="remark-text">{{ row.remark }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right" align="center">
<template #default="{ row }">
<div class="action-cell">
<el-button link type="primary" :icon="View" @click="selectedExpense = row">
详情
</el-button>
<el-button
v-if="row.status === 'paid' && canManage"
link
type="danger"
:icon="CircleClose"
:loading="actionID === row.id"
@click="handleVoid(row)"
>
作废
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<AdminTablePagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
@page-change="loadItems"
/>
</section>
<!-- 新增开支弹窗 -->
<el-dialog
v-model="createVisible"
title="新增运营开支记账"
width="540px"
destroy-on-close
class="custom-modal"
>
<el-form label-position="top" class="modal-form">
<el-form-item label="开支类别" required>
<el-input
v-model="createForm.category"
placeholder="例如:服务器宽带、日常采购、软件授权"
/>
</el-form-item>
<el-form-item label="收款方姓名 / 单位" required>
<el-input v-model="createForm.payee_name" placeholder="请输入实际收款方或单位名称" />
</el-form-item>
<el-form-item label="开支金额(元)" required>
<el-input-number
v-model="createForm.amount_yuan"
:min="0.01"
:precision="2"
:step="10"
class="full-control"
/>
</el-form-item>
<el-form-item label="开支发生时间" required>
<el-date-picker
v-model="createForm.occurred_at"
type="datetime"
placeholder="选择实际支付发生时间"
class="full-control"
/>
</el-form-item>
<el-form-item label="开支用途 / 详细备注" required>
<el-input
v-model="createForm.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="详细记录运营开支用途"
/>
</el-form-item>
<el-form-item label="开支凭证 / 发票截图(可选)">
<div class="voucher-upload-box">
<el-upload
action="#"
:http-request="uploadVoucher"
:show-file-list="false"
accept="image/*"
>
<el-button :loading="voucherUploading" :icon="UploadFilled">
{{ createForm.voucher_url ? '重新上传凭证' : '上传开支凭证' }}
</el-button>
</el-upload>
<div v-if="createForm.voucher_url" class="voucher-preview-wrap">
<AuthImage
:source="createForm.voucher_url"
alt="凭证预览"
class="voucher-img-preview"
/>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="modal-footer">
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" @click="submitExpense">
确认记账
</el-button>
</div>
</template>
</el-dialog>
<!-- 运营开支详情弹窗 -->
<el-dialog
:model-value="!!selectedExpense"
title="运营开支详情"
width="520px"
@update:model-value="(val: boolean) => (!val ? (selectedExpense = null) : null)"
>
<div v-if="selectedExpense" class="detail-content-wrap">
<div class="detail-row">
<span class="detail-label">开支单号</span>
<span class="detail-val font-mono">{{ selectedExpense.expense_no }}</span>
</div>
<div class="detail-row">
<span class="detail-label">开支类别</span>
<span class="detail-val">{{ selectedExpense.category }}</span>
</div>
<div class="detail-row">
<span class="detail-label">收款方</span>
<span class="detail-val">{{ selectedExpense.payee_name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">开支金额</span>
<span class="detail-val text-amber font-bold text-lg">
{{ money(selectedExpense.amount_cent) }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">状态</span>
<el-tag :type="statusType(selectedExpense.status)">
{{ statusLabel(selectedExpense.status) }}
</el-tag>
</div>
<div class="detail-row">
<span class="detail-label">发生时间</span>
<span class="detail-val">{{ formatDateTime(selectedExpense.occurred_at) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">录入人</span>
<span class="detail-val">{{ selectedExpense.created_by_name || '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">用途备注</span>
<span class="detail-val">{{ selectedExpense.remark }}</span>
</div>
<div v-if="selectedExpense.voucher_url" class="detail-row column-row">
<span class="detail-label">开支凭证</span>
<div class="detail-img-container">
<AuthImage
:source="selectedExpense.voucher_url"
alt="凭证"
class="detail-voucher-img"
/>
</div>
</div>
<div v-if="selectedExpense.status === 'voided'" class="void-info-box">
<div class="void-title">
<el-icon><WarningFilled /></el-icon>
<span>作废记录</span>
</div>
<p>作废时间:{{ formatDateTime(selectedExpense.voided_at) }}</p>
<p>作废人:{{ selectedExpense.voided_by_name || '-' }}</p>
<p>作废原因:{{ selectedExpense.void_reason || '-' }}</p>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped src="./AdminOperatingExpensesView.css"></style>