564 lines
18 KiB
Vue
564 lines
18 KiB
Vue
<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>
|