新增财务仪表盘并统一金额到角

This commit is contained in:
yml
2026-06-09 00:13:03 +08:00
parent a2a0489158
commit 2dfa2611fd
40 changed files with 1324 additions and 70 deletions
@@ -0,0 +1,116 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
export interface FinanceSummary {
total_flow_amount_cent: number
total_refund_amount_cent: number
pending_refund_amount_cent: number
channel_net_amount_cent: number
platform_income_amount: number
owner_should_income_amount: number
owner_wallet_income_amount: number
settlement_diff_amount: number
successful_pay_count: number
successful_refund_count: number
pending_refund_count: number
settled_order_count: number
financial_exception_count: number
}
export interface FinanceDailyItem {
date: string
total_flow_amount_cent: number
total_refund_amount_cent: number
pending_refund_amount_cent: number
channel_net_amount_cent: number
platform_income_amount: number
owner_should_income_amount: number
owner_wallet_income_amount: number
settlement_diff_amount: number
successful_pay_count: number
successful_refund_count: number
pending_refund_count: number
settled_order_count: number
}
export interface FinanceDashboard {
summary: FinanceSummary
daily_items: FinanceDailyItem[]
generated_at: string
}
export interface FinanceDetail {
order_id: number
order_no: string
order_status: string
settlement_status: string
refund_status: string
renter_id: number
renter_phone: string
renter_nickname: string
owner_id: number
owner_phone: string
owner_nickname: string
order_rent_amount: number
order_deposit_amount: number
checkout_rent_amount: number
checkout_renter_refund: number
checkout_owner_income: number
checkout_platform_fee: number
owner_wallet_income_amount: number
paid_amount_cent: number
refunded_amount_cent: number
refunding_amount_cent: number
failed_refund_amount_cent: number
channel_net_amount_cent: number
platform_net_amount: number
settlement_diff_amount: number
finance_status: string
created_at: string
settled_at?: string
}
export interface FinanceDateQuery {
start_date?: string
end_date?: string
}
export interface FinanceDetailQuery extends FinanceDateQuery {
order_no?: string
user_id?: string
order_status?: string
settlement_status?: string
date_type?: string
page?: number
page_size?: number
}
function cleanParams(query: object) {
return Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
)
}
export async function fetchFinanceDashboard(query: FinanceDateQuery = {}) {
const { data } = await apiClient.get<ApiResponse<FinanceDashboard>>('/admin/finance/dashboard', {
params: cleanParams(query),
})
return {
...data.data,
daily_items: Array.isArray(data.data?.daily_items) ? data.data.daily_items : [],
}
}
export async function fetchFinanceDetails(query: FinanceDetailQuery = {}) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<FinanceDetail>>>(
'/admin/finance/details',
{ params: cleanParams(query) }
)
const result = data.data
return {
items: Array.isArray(result?.items) ? result.items : [],
total: Number(result?.total ?? 0),
page: Number(result?.page ?? query.page ?? 1),
page_size: Number(result?.page_size ?? query.page_size ?? 20),
}
}
@@ -4,6 +4,7 @@ import { ref } from 'vue'
import type { WithdrawalDetail } from '@/features/admin/api/adminWithdrawal'
import { reviewWithdrawal, confirmPayment } from '@/features/admin/api/adminWithdrawal'
import { formatMoney } from '@/shared/utils/money'
const props = defineProps<{
modelValue: boolean
@@ -160,15 +161,15 @@ function accountTypeLabel(type: string) {
<el-descriptions :column="2" border>
<el-descriptions-item label="提现金额">
<span style="color: #f56c6c; font-weight: 600; font-size: 16px">
¥{{ withdrawal.amount.toFixed(2) }}
¥{{ formatMoney(withdrawal.amount) }}
</span>
</el-descriptions-item>
<el-descriptions-item label="手续费">
¥{{ withdrawal.fee.toFixed(2) }}
¥{{ formatMoney(withdrawal.fee) }}
</el-descriptions-item>
<el-descriptions-item label="实际到账" :span="2">
<span style="color: #67c23a; font-weight: 600; font-size: 16px">
¥{{ withdrawal.actual_amount.toFixed(2) }}
¥{{ formatMoney(withdrawal.actual_amount) }}
</span>
</el-descriptions-item>
</el-descriptions>
@@ -273,7 +274,7 @@ function accountTypeLabel(type: string) {
>
<template #title>
<div style="font-size: 13px">
请手动转账 <strong>¥{{ withdrawal.actual_amount.toFixed(2) }}</strong> 到用户收款账号
请手动转账 <strong>¥{{ formatMoney(withdrawal.actual_amount) }}</strong> 到用户收款账号
完成后点击"确认打款"按钮
</div>
</template>
+1
View File
@@ -4,6 +4,7 @@ export * from './api/adminUsers'
export * from './api/adminMgr'
export * from './api/adminWallet'
export * from './api/adminPayments'
export * from './api/adminFinance'
export * from './api/adminAudit'
export * from './api/systemConfigs'
export * from './composables/useAdminTable'
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { Refresh, Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import {
fetchFinanceDashboard,
type FinanceDashboard,
type FinanceDailyItem,
} from '@/features/admin/api/adminFinance'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/utils/time'
const loading = ref(false)
const dashboard = ref<FinanceDashboard | null>(null)
const filters = reactive({
start_date: defaultStartDate(),
end_date: defaultEndDate(),
})
const dailyItems = computed(() => dashboard.value?.daily_items ?? [])
onMounted(loadDashboard)
async function loadDashboard() {
loading.value = true
try {
dashboard.value = await fetchFinanceDashboard(filters)
} finally {
loading.value = false
}
}
function moneyCent(value: number) {
return formatMoneyWithSymbol(Number(value || 0) / 100)
}
function money(value: number) {
return formatMoneyWithSymbol(value)
}
function defaultStartDate() {
const date = new Date()
date.setDate(date.getDate() - 6)
return formatInputDate(date)
}
function defaultEndDate() {
return formatInputDate(new Date())
}
function formatInputDate(date: Date) {
return date.toISOString().slice(0, 10)
}
function diffType(value: number) {
return Math.abs(Number(value || 0)) >= 0.05 ? 'danger' : 'success'
}
function rowDiffClass(row: FinanceDailyItem) {
return Math.abs(Number(row.settlement_diff_amount || 0)) >= 0.05 ? 'amount-danger' : ''
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Finance Dashboard</p>
<h1>财务仪表盘</h1>
<p>按天查看第三方收款退款平台收入号主入账和结算差异</p>
</div>
<div class="toolbar-actions">
<el-date-picker
v-model="filters.start_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="开始日期"
/>
<el-date-picker
v-model="filters.end_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="结束日期"
/>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadDashboard">
查询
</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
</div>
</div>
<div v-if="dashboard" class="metric-grid">
<div class="metric-card">
<span>总流水</span>
<strong>{{ moneyCent(dashboard.summary.total_flow_amount_cent) }}</strong>
<small>{{ dashboard.summary.successful_pay_count }} 笔成功收款</small>
</div>
<div class="metric-card">
<span>总退款</span>
<strong>{{ moneyCent(dashboard.summary.total_refund_amount_cent) }}</strong>
<small>{{ dashboard.summary.successful_refund_count }} 笔成功退款</small>
</div>
<div class="metric-card">
<span>渠道净流入</span>
<strong>{{ moneyCent(dashboard.summary.channel_net_amount_cent) }}</strong>
<small>成功收款 - 成功退款</small>
</div>
<div class="metric-card">
<span>平台收入</span>
<strong>{{ money(dashboard.summary.platform_income_amount) }}</strong>
<small>{{ dashboard.summary.settled_order_count }} 个已结算订单</small>
</div>
<div class="metric-card">
<span>号主应得</span>
<strong>{{ money(dashboard.summary.owner_should_income_amount) }}</strong>
<small>结账单口径</small>
</div>
<div class="metric-card">
<span>号主实际入账</span>
<strong>{{ money(dashboard.summary.owner_wallet_income_amount) }}</strong>
<small>钱包流水口径</small>
</div>
<div class="metric-card">
<span>退款中</span>
<strong>{{ moneyCent(dashboard.summary.pending_refund_amount_cent) }}</strong>
<small>{{ dashboard.summary.pending_refund_count }} 笔待回执</small>
</div>
<div class="metric-card">
<span>结算差异</span>
<strong>
<el-tag :type="diffType(dashboard.summary.settlement_diff_amount)">
{{ money(dashboard.summary.settlement_diff_amount) }}
</el-tag>
</strong>
<small>{{ dashboard.summary.financial_exception_count }} 个异常订单</small>
</div>
</div>
<el-table v-loading="loading" class="table-panel" :data="dailyItems">
<el-table-column prop="date" label="日期" width="130" />
<el-table-column label="总流水" width="130">
<template #default="{ row }">{{ moneyCent(row.total_flow_amount_cent) }}</template>
</el-table-column>
<el-table-column label="总退款" width="130">
<template #default="{ row }">{{ moneyCent(row.total_refund_amount_cent) }}</template>
</el-table-column>
<el-table-column label="渠道净流入" width="140">
<template #default="{ row }">{{ moneyCent(row.channel_net_amount_cent) }}</template>
</el-table-column>
<el-table-column label="平台收入" width="130">
<template #default="{ row }">{{ money(row.platform_income_amount) }}</template>
</el-table-column>
<el-table-column label="号主应得" width="130">
<template #default="{ row }">{{ money(row.owner_should_income_amount) }}</template>
</el-table-column>
<el-table-column label="号主入账" width="130">
<template #default="{ row }">{{ money(row.owner_wallet_income_amount) }}</template>
</el-table-column>
<el-table-column label="结算差异" width="130">
<template #default="{ row }">
<span :class="rowDiffClass(row)">{{ money(row.settlement_diff_amount) }}</span>
</template>
</el-table-column>
<el-table-column label="收款/退款/结算" min-width="170">
<template #default="{ row }">
{{ row.successful_pay_count }} / {{ row.successful_refund_count }} /
{{ row.settled_order_count }}
</template>
</el-table-column>
</el-table>
<p v-if="dashboard" class="generated-at">
数据生成时间{{ formatDateTime(dashboard.generated_at) }}
</p>
</section>
</template>
<style scoped>
.amount-danger {
color: #dc2626;
font-weight: 700;
}
.generated-at {
margin: 12px 0 0;
color: #64748b;
font-size: 13px;
}
</style>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { Search } from '@element-plus/icons-vue'
import { onMounted, reactive, ref } from 'vue'
import {
fetchFinanceDetails,
type FinanceDetail,
} from '@/features/admin/api/adminFinance'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const loading = ref(false)
const details = ref<FinanceDetail[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const filters = reactive({
start_date: defaultStartDate(),
end_date: defaultEndDate(),
date_type: 'settled',
order_no: '',
user_id: '',
order_status: '',
settlement_status: '',
})
onMounted(loadDetails)
async function loadDetails() {
loading.value = true
try {
const result = await fetchFinanceDetails({
...filters,
page: currentPage.value,
page_size: currentPageSize.value,
})
details.value = result.items
total.value = result.total
} finally {
loading.value = false
}
}
function resetFilters() {
filters.start_date = defaultStartDate()
filters.end_date = defaultEndDate()
filters.date_type = 'settled'
filters.order_no = ''
filters.user_id = ''
filters.order_status = ''
filters.settlement_status = ''
currentPage.value = 1
void loadDetails()
}
async function handlePageChange() {
await loadDetails()
}
function money(value: number) {
return formatMoneyWithSymbol(value)
}
function moneyCent(value: number) {
return formatMoneyWithSymbol(Number(value || 0) / 100)
}
function financeStatusLabel(status: string) {
const map: Record<string, string> = {
normal: '正常',
refund_pending: '退款中',
refund_failed: '退款失败',
settlement_diff: '结算差异',
}
return map[status] || status
}
function financeStatusType(status: string) {
if (status === 'normal') return 'success'
if (status === 'settlement_diff' || status === 'refund_failed') return 'danger'
if (status === 'refund_pending') return 'warning'
return 'info'
}
function settlementStatusLabel(status: string) {
const map: Record<string, string> = {
unsettled: '未结算',
settling: '结算中',
settled: '已结算',
}
return map[status] || status || '-'
}
function defaultStartDate() {
const date = new Date()
date.setDate(date.getDate() - 29)
return formatInputDate(date)
}
function defaultEndDate() {
return formatInputDate(new Date())
}
function formatInputDate(date: Date) {
return date.toISOString().slice(0, 10)
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Finance Details</p>
<h1>财务明细</h1>
<p>按订单核对收款退款平台收入号主应得与钱包实际入账</p>
</div>
<div class="toolbar-actions">
<el-button @click="resetFilters">重置</el-button>
<el-button type="primary" :icon="Search" :loading="loading" @click="loadDetails">
查询
</el-button>
</div>
</div>
<el-form class="filter-panel" label-position="top">
<el-form-item label="日期类型">
<el-select v-model="filters.date_type" class="full-control">
<el-option label="结算日期" value="settled" />
<el-option label="下单日期" value="created" />
</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-input v-model="filters.order_no" clearable placeholder="按订单号筛选" />
</el-form-item>
<el-form-item label="用户 ID">
<el-input v-model="filters.user_id" clearable placeholder="租客或号主 ID" />
</el-form-item>
<el-form-item label="订单状态">
<el-select v-model="filters.order_status" clearable placeholder="全部状态" class="full-control">
<el-option label="待支付" value="pending_payment" />
<el-option label="使用中" value="renting" />
<el-option label="待结账" value="pending_checkout_confirm" />
<el-option label="已完成" value="completed" />
<el-option label="已关闭" value="closed" />
</el-select>
</el-form-item>
<el-form-item label="结算状态">
<el-select
v-model="filters.settlement_status"
clearable
placeholder="全部结算"
class="full-control"
>
<el-option label="未结算" value="unsettled" />
<el-option label="已结算" value="settled" />
</el-select>
</el-form-item>
</el-form>
<el-table v-loading="loading" class="table-panel" :data="details">
<el-table-column label="订单" min-width="180">
<template #default="{ row }">
<RouterLink :to="`/admin/orders/${row.order_id}`">{{ row.order_no }}</RouterLink>
</template>
</el-table-column>
<el-table-column label="状态" width="150">
<template #default="{ row }">
<div>{{ orderStatusLabel(row.order_status) }}</div>
<small>{{ settlementStatusLabel(row.settlement_status) }}</small>
</template>
</el-table-column>
<el-table-column label="租客/号主" min-width="170">
<template #default="{ row }">
<div>{{ row.renter_phone || row.renter_nickname || row.renter_id }}</div>
<small>{{ row.owner_phone || row.owner_nickname || row.owner_id }}</small>
</template>
</el-table-column>
<el-table-column label="收款" width="110">
<template #default="{ row }">{{ moneyCent(row.paid_amount_cent) }}</template>
</el-table-column>
<el-table-column label="已退款" width="110">
<template #default="{ row }">{{ moneyCent(row.refunded_amount_cent) }}</template>
</el-table-column>
<el-table-column label="退款中" width="110">
<template #default="{ row }">{{ moneyCent(row.refunding_amount_cent) }}</template>
</el-table-column>
<el-table-column label="净流入" width="110">
<template #default="{ row }">{{ moneyCent(row.channel_net_amount_cent) }}</template>
</el-table-column>
<el-table-column label="平台收入" width="110">
<template #default="{ row }">{{ money(row.checkout_platform_fee) }}</template>
</el-table-column>
<el-table-column label="号主应得" width="110">
<template #default="{ row }">{{ money(row.checkout_owner_income) }}</template>
</el-table-column>
<el-table-column label="号主入账" width="110">
<template #default="{ row }">{{ money(row.owner_wallet_income_amount) }}</template>
</el-table-column>
<el-table-column label="差异" width="110">
<template #default="{ row }">
<span :class="{ 'amount-danger': Math.abs(row.settlement_diff_amount) >= 0.05 }">
{{ money(row.settlement_diff_amount) }}
</span>
</template>
</el-table-column>
<el-table-column label="财务状态" width="120">
<template #default="{ row }">
<el-tag :type="financeStatusType(row.finance_status)">
{{ financeStatusLabel(row.finance_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="结算时间" min-width="170">
<template #default="{ row }">{{ row.settled_at ? formatDateTime(row.settled_at) : '-' }}</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="handlePageChange"
/>
</section>
</template>
<style scoped>
.amount-danger {
color: #dc2626;
font-weight: 700;
}
</style>
@@ -5,6 +5,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import {
adminMarkListingAbnormal,
adminOfflineListing,
@@ -78,7 +79,7 @@ async function submitAction() {
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
return formatMoneyWithSymbol(value)
}
function listingPrice(row: Listing) {
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { fetchAdminFileBlob } from '@/shared/api/files'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import {
adjustListingReviewPrice,
approveListing,
@@ -242,7 +243,7 @@ function openEvidence(row: Listing) {
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
return formatMoneyWithSymbol(value)
}
function quantity(value: number) {
@@ -15,6 +15,7 @@ import {
type RefundStatus,
} from '@/features/orders'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
import { handoffStatusLabel, orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
import { formatListingNo } from '@/utils/listingDisplay'
@@ -107,7 +108,7 @@ function orderEstimatedEndAt() {
}
function money(value: unknown) {
return Math.round(Number(value || 0))
return formatMoney(Number(value || 0))
}
async function handleRefund() {
@@ -167,7 +168,7 @@ function paymentBizTypeLabel(type: string) {
}
function moneyCent(value: number) {
return `¥${(Number(value || 0) / 100).toFixed(2)}`
return formatMoneyWithSymbol(Number(value || 0) / 100)
}
function formatHandoffRecordType(type: string) {
@@ -240,7 +241,7 @@ function formatHandoffRecordType(type: string) {
<span>退款状态</span>
<strong>{{ refundStatusLabel(refundStatus.refund_status) }}</strong>
<small v-if="refundStatus.refund_amount_cent > 0"
>¥{{ (refundStatus.refund_amount_cent / 100).toFixed(2) }}</small
>{{ moneyCent(refundStatus.refund_amount_cent) }}</small
>
</div>
</div>
@@ -11,6 +11,7 @@ import {
type PaymentConfig,
} from '@/features/admin/api/paymentConfig'
import { formatDateTime } from '@/utils/time'
import { formatMoney } from '@/shared/utils/money'
import { readError } from '@/utils/error'
import PaymentConfigDialog from '../components/PaymentConfigDialog.vue'
@@ -230,7 +231,7 @@ function formatEnvironment(env: string) {
}
function formatAmount(amountCent: number) {
return (amountCent / 100).toFixed(2)
return formatMoney(amountCent / 100)
}
function getStatusType(status: string) {
@@ -3,6 +3,7 @@ import { Document, Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { formatDateTime } from '@/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
@@ -69,7 +70,7 @@ async function handlePageChange() {
}
function moneyCent(value: number) {
return `¥${(Number(value || 0) / 100).toFixed(2)}`
return formatMoneyWithSymbol(Number(value || 0) / 100)
}
function paymentStatusType(status: string) {
@@ -9,6 +9,7 @@ import {
unfreezeAdminUser,
type AdminUserItem,
} from '@/features/admin/api/adminUsers'
import { formatMoney } from '@/shared/utils/money'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { userStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -93,7 +94,7 @@ function readError(error: unknown, fallback: string) {
}
function money(value: number | string | undefined) {
return Number(value || 0).toFixed(2)
return formatMoney(Number(value || 0))
}
</script>
@@ -3,6 +3,7 @@ import { Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { fetchAdminWalletLedger, type AdminWalletLedger } from '@/features/admin/api/adminWallet'
import { formatMoneyWithSymbol } from '@/shared/utils/money'
import { balanceTypeLabel, ledgerDirectionLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
import AdminTablePagination from '../components/AdminTablePagination.vue'
@@ -64,7 +65,7 @@ async function handlePageChange() {
}
function money(value: number) {
return `¥${Math.round(Number(value || 0))}`
return formatMoneyWithSymbol(value)
}
function directionType(direction: string) {
@@ -8,6 +8,7 @@ import {
confirmPayment,
type WithdrawalDetail,
} from '@/features/admin/api/adminWithdrawal'
import { formatMoney } from '@/shared/utils/money'
import WithdrawalDetailDialog from '../components/WithdrawalDetailDialog.vue'
@@ -219,7 +220,7 @@ function onDetailDialogSaved() {
</el-table-column>
<el-table-column label="提现金额" width="120" align="right">
<template #default="{ row }">
<span style="color: #f56c6c; font-weight: 600"> ¥{{ row.amount.toFixed(2) }} </span>
<span style="color: #f56c6c; font-weight: 600"> ¥{{ formatMoney(row.amount) }} </span>
</template>
</el-table-column>
<el-table-column label="收款方式" min-width="180">