Files
hfb_sys/frontend/src/features/admin/views/AdminOperatingExpensesView.vue
T
2026-08-20 16:06:05 +08:00

412 lines
15 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, Plus, Search, UploadFilled, View } 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) {
return formatCentWithSymbol(Number(value || 0))
}
function statusLabel(status: string) {
return status === 'paid' ? '已记账' : '已作废'
}
function statusType(status: string) {
return status === 'paid' ? 'success' : 'danger'
}
</script>
<template>
<section class="page operating-expenses-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Operating Expenses</p>
<h1>运营开支</h1>
<p>独立记录日常运营费用并在财务仪表盘按发生日期汇总</p>
</div>
<div class="toolbar-actions">
<el-button v-if="canManage" type="success" :icon="Plus" @click="openCreate">
新增运营开支
</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="search">查询</el-button>
</div>
</div>
<div class="metric-grid operating-metrics">
<div class="metric-card">
<span>筛选记录</span>
<strong>{{ summary.record_count }} </strong>
<small>当前筛选条件</small>
</div>
<div class="metric-card operating-metric">
<span>有效运营开支</span>
<strong>{{ money(summary.paid_amount_cent) }}</strong>
<small>{{ summary.paid_count }} 笔已记账开支</small>
</div>
</div>
<el-form class="table-panel operating-filters" label-position="top">
<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" />
</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" />
</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="单号、收款方或用途" @keyup.enter="search" />
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="items">
<el-table-column label="开支单号" min-width="190">
<template #default="{ row }">{{ row.expense_no }}</template>
</el-table-column>
<el-table-column prop="category" label="开支类别" min-width="130" />
<el-table-column label="收款方" min-width="150">
<template #default="{ row }">
<strong>{{ row.payee_name }}</strong>
</template>
</el-table-column>
<el-table-column label="开支金额" min-width="135" align="right">
<template #default="{ row }"><strong>{{ money(row.amount_cent) }}</strong></template>
</el-table-column>
<el-table-column label="开支时间" min-width="175">
<template #default="{ row }">{{ formatDateTime(row.occurred_at) }}</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 }">{{ row.created_by_name || '-' }}</template>
</el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark }}</template>
</el-table-column>
<el-table-column label="操作" width="145" fixed="right">
<template #default="{ row }">
<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>
</template>
</el-table-column>
</el-table>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:loading="loading"
@page-change="loadItems"
/>
<el-dialog v-model="createVisible" title="新增运营开支" width="560px" :close-on-click-modal="false">
<el-form class="expense-form" label-position="top" @submit.prevent="submitExpense">
<el-form-item label="开支类别" required>
<el-input v-model="createForm.category" maxlength="50" placeholder="例如:推广投放、办公采购" />
</el-form-item>
<el-form-item label="收款方" required>
<el-input v-model="createForm.payee_name" maxlength="100" placeholder="姓名或单位名称" />
</el-form-item>
<el-form-item label="开支金额" required>
<el-input-number v-model="createForm.amount_yuan" class="full-control" :min="0.01" :precision="2" :step="100" controls-position="right" />
</el-form-item>
<el-form-item label="开支时间" required>
<el-date-picker v-model="createForm.occurred_at" class="full-control" type="datetime" placeholder="选择开支时间" />
</el-form-item>
<el-form-item class="expense-form-wide" label="开支用途" required>
<el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="填写开支用途" />
</el-form-item>
<el-form-item class="expense-form-wide" label="开支凭证(选填)">
<div v-if="createForm.voucher_url" class="voucher-preview">
<AuthImage :source="createForm.voucher_url" :admin="true" fit="cover" :preview-src-list="[createForm.voucher_url]" :image-style="{ width: '104px', height: '104px', borderRadius: '6px' }" />
<el-button class="voucher-remove" circle plain type="danger" :icon="CircleClose" title="移除凭证" @click="createForm.voucher_url = ''" />
</div>
<el-upload v-else :show-file-list="false" :http-request="uploadVoucher" accept="image/jpeg,image/png,image/webp">
<el-button :icon="UploadFilled" :loading="voucherUploading">上传凭证</el-button>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="createSaving" @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createSaving" :disabled="voucherUploading" @click="submitExpense">确认记账</el-button>
</template>
</el-dialog>
<el-dialog :model-value="!!selectedExpense" title="运营开支详情" width="620px" @update:model-value="selectedExpense = null">
<el-descriptions v-if="selectedExpense" :column="2" border>
<el-descriptions-item label="开支单号">{{ selectedExpense.expense_no }}</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="statusType(selectedExpense.status)">{{ statusLabel(selectedExpense.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="开支类别">{{ selectedExpense.category }}</el-descriptions-item>
<el-descriptions-item label="收款方">{{ selectedExpense.payee_name }}</el-descriptions-item>
<el-descriptions-item label="开支金额"><strong>{{ money(selectedExpense.amount_cent) }}</strong></el-descriptions-item>
<el-descriptions-item label="开支时间">{{ formatDateTime(selectedExpense.occurred_at) }}</el-descriptions-item>
<el-descriptions-item label="录入人">{{ selectedExpense.created_by_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="录入时间">{{ formatDateTime(selectedExpense.created_at) }}</el-descriptions-item>
<el-descriptions-item label="开支用途" :span="2">{{ selectedExpense.remark }}</el-descriptions-item>
<el-descriptions-item v-if="selectedExpense.voucher_url" label="开支凭证" :span="2">
<AuthImage :source="selectedExpense.voucher_url" :admin="true" fit="cover" :preview-src-list="[selectedExpense.voucher_url]" :image-style="{ width: '140px', height: '140px', borderRadius: '6px' }" />
</el-descriptions-item>
<el-descriptions-item v-if="selectedExpense.status === 'voided'" label="作废信息" :span="2">
{{ selectedExpense.voided_by_name || '-' }} · {{ formatDateTime(selectedExpense.voided_at) }} · {{ selectedExpense.void_reason || '-' }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="selectedExpense = null">关闭</el-button>
<el-button v-if="selectedExpense?.status === 'paid' && canManage" type="danger" :icon="CircleClose" :loading="actionID === selectedExpense.id" @click="handleVoid(selectedExpense)">作废记录</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.operating-metrics {
grid-template-columns: repeat(2, minmax(170px, 1fr));
margin-bottom: 16px;
}
.operating-metric {
border-left: 3px solid #b45309;
}
.operating-filters {
display: grid;
grid-template-columns: repeat(6, minmax(130px, 1fr));
gap: 10px;
margin-bottom: 16px;
padding: 14px 16px;
}
.expense-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.expense-form-wide {
grid-column: 1 / -1;
}
.voucher-preview {
position: relative;
width: 104px;
height: 104px;
}
.voucher-remove {
position: absolute;
top: -10px;
right: -10px;
}
@media (max-width: 1100px) {
.operating-filters {
grid-template-columns: repeat(3, minmax(140px, 1fr));
}
}
</style>