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

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
+1
View File
@@ -25,6 +25,7 @@ declare module 'vue' {
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
@@ -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">
@@ -13,6 +13,7 @@ import {
import { fetchPostRentalNotice, type PostRentalNotice } from '@/features/orders/api/orders'
import { formatDateMinute } from '@/utils/time'
import { uploadFile } from '@/shared/api/files'
import { formatMoney } from '@/shared/utils/money'
const session = useSessionStore()
const router = useRouter()
@@ -316,7 +317,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<div class="balance-row">
<div class="balance-info">
<span class="balance-label">账户可用余额()</span>
<strong class="balance-value">¥{{ Math.round(Number(balance || 0)) }}</strong>
<strong class="balance-value">¥{{ formatMoney(balance) }}</strong>
</div>
<button class="withdraw-btn" @click="handleWithdraw">提现</button>
</div>
@@ -417,7 +418,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<span class="ledger-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
<div class="ledger-right" :class="item.direction === 'in' ? 'in-color' : 'out-color'">
{{ item.direction === 'in' ? '+' : '-' }}¥{{ Math.round(Number(item.amount || 0)) }}
{{ item.direction === 'in' ? '+' : '-' }}¥{{ formatMoney(item.amount) }}
</div>
</div>
</div>
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import type { Listing } from '@/features/listings'
import { formatMoney } from '@/shared/utils/money'
import {
formatHafCoinM,
getCoinWan,
@@ -135,11 +136,11 @@ function formatStatNumber(value: number) {
<div class="card-price">
<div class="price-item total">
<small>总租金</small>
<strong>¥{{ getListingDisplayPrice(listing) }}</strong>
<strong>¥{{ formatMoney(getListingDisplayPrice(listing)) }}</strong>
</div>
<div class="price-item deposit">
<small>押金</small>
<span>¥{{ listing.deposit_amount }}</span>
<span>¥{{ formatMoney(listing.deposit_amount) }}</span>
</div>
<div class="price-action">
<button class="rent-btn">立即租用</button>
@@ -67,7 +67,7 @@ onMounted(async () => {
})
const orderTotal = computed(() => {
if (!listing.value) return '0'
if (!listing.value) return '0.0'
return formatMoney(getListingDisplayPrice(listing.value))
})
@@ -102,7 +102,7 @@ const detailMetrics = computed(() => {
tone: 'coin',
},
{ label: '价格', value: `¥${orderTotal.value}`, tone: 'price' },
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
{ label: '押金', value: `¥${formatMoney(listing.value.deposit_amount)}`, tone: '' },
]
})
@@ -401,7 +401,7 @@ function listingPrice(item: Listing) {
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small>
<small v-if="resource.amount > 0">¥{{ formatMoney(resource.amount) }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small>
</em>
@@ -458,14 +458,14 @@ function listingPrice(item: Listing) {
<div class="order-price-breakdown">
<div>
<span>基础租金</span>
<strong>¥{{ orderPriceBreakdown.rent }}</strong>
<strong>¥{{ formatMoney(orderPriceBreakdown.rent) }}</strong>
</div>
<div>
<span>额外物品</span>
<strong>¥{{ orderPriceBreakdown.consumable }}</strong>
<strong>¥{{ formatMoney(orderPriceBreakdown.consumable) }}</strong>
</div>
</div>
<em>押金另付 ¥{{ listing.deposit_amount }}</em>
<em>押金另付 ¥{{ formatMoney(listing.deposit_amount) }}</em>
</div>
<dl class="order-check-list">
<div>
@@ -6,6 +6,7 @@ import {
type ListingPublishOptions,
} from '@/features/listings/api/listingOptions'
import { fetchListings, type Listing } from '@/features/listings/api/listings'
import { formatMoney } from '@/shared/utils/money'
import {
defaultHomeAnnouncements,
defaultHomeBanners,
@@ -620,8 +621,8 @@ function parseQuantityUnit(price: string) {
</div>
</div>
<div class="resource-price-box">
<strong>¥{{ getListingDisplayPrice(item) }}</strong>
<span class="rent-sub">押金¥{{ item.deposit_amount }}</span>
<strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
<span class="rent-sub">押金¥{{ formatMoney(item.deposit_amount) }}</span>
</div>
</RouterLink>
</div>
@@ -5,6 +5,7 @@ import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { ensureSupportChat } from '@/features/chats/api/chats'
import { formatMoney } from '@/shared/utils/money'
import {
emptyListingPublishOptions,
type ListingPublishOptions,
@@ -695,8 +696,8 @@ function chipTone(label: string) {
</div>
<div class="card-footer">
<div class="price-col">
<strong>¥{{ getListingDisplayPrice(item) }}</strong>
<span class="rent-sub">押金 ¥{{ item.deposit_amount }}</span>
<strong>¥{{ formatMoney(getListingDisplayPrice(item)) }}</strong>
<span class="rent-sub">押金 ¥{{ formatMoney(item.deposit_amount) }}</span>
</div>
</div>
</div>
@@ -94,8 +94,8 @@ const detailMetrics = computed(() => {
value: dailyLoss ? `${dailyLoss}/天` : '--',
tone: 'coin',
},
{ label: '价格', value: `¥${getListingDisplayPrice(listing.value)}`, tone: 'price' },
{ label: '押金', value: `¥${listing.value.deposit_amount}`, tone: '' },
{ label: '价格', value: `¥${formatMoney(getListingDisplayPrice(listing.value))}`, tone: 'price' },
{ label: '押金', value: `¥${formatMoney(listing.value.deposit_amount)}`, tone: '' },
]
})
@@ -369,7 +369,7 @@ async function copyListingCode() {
<strong>{{ resource.quantity }}</strong>
<em>
<b>{{ resource.mode || '--' }}</b>
<small v-if="resource.amount > 0">¥{{ resource.amount }}</small>
<small v-if="resource.amount > 0">¥{{ formatMoney(resource.amount) }}</small>
<small v-else-if="resource.mode === '收费'">{{ resource.price || '¥0' }}</small>
<small v-else>无额外收费</small>
</em>
@@ -414,8 +414,8 @@ async function copyListingCode() {
<span class="price-amount">¥{{ orderTotal }}</span>
</div>
<div class="order-price-detail">
<span>租金 ¥{{ orderPriceBreakdown.rent }}</span>
<span>额外 ¥{{ orderPriceBreakdown.consumable }}</span>
<span>租金 ¥{{ formatMoney(orderPriceBreakdown.rent) }}</span>
<span>额外 ¥{{ formatMoney(orderPriceBreakdown.consumable) }}</span>
</div>
</div>
<van-button
@@ -6,6 +6,7 @@ import { showToast, showDialog } from 'vant'
import { fetchOrderChat } from '@/features/chats/api/chats'
import { createDispute } from '@/features/disputes/api/disputes'
import { uploadFile } from '@/shared/api/files'
import { formatMoney } from '@/shared/utils/money'
import {
acceptCheckout,
cancelOrder,
@@ -529,7 +530,7 @@ function readUnitPrice(priceText: string) {
}
function roundMoney(value: number) {
return Math.round(value)
return Math.round(Number(value || 0) * 10) / 10
}
function roundQuantity(value: number) {
@@ -537,7 +538,7 @@ function roundQuantity(value: number) {
}
function money(value: unknown) {
return `${roundMoney(readNumber(value))}`
return formatMoney(readNumber(value))
}
function formatHandoffRecordType(type: string) {
@@ -10,6 +10,7 @@ import {
type Order,
type PaymentOrder,
} from '@/features/orders/api/orders'
import { formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { formatDateMinute } from '@/utils/time'
import { formatListingNo } from '@/utils/listingDisplay'
@@ -119,7 +120,7 @@ function readError(error: unknown, fallback: string) {
}
function money(value: unknown) {
return Math.round(Number(value || 0))
return formatMoney(Number(value || 0))
}
function isOwner(order: Order) {
@@ -8,6 +8,7 @@ import QRCode from 'qrcode'
import { fetchOrderChat } from '@/features/chats/api/chats'
import { createDispute } from '@/features/disputes'
import { uploadFile } from '@/shared/api/files'
import { formatMoney } from '@/shared/utils/money'
import {
acceptCheckout,
cancelOrder,
@@ -588,7 +589,7 @@ function readUnitPrice(priceText: string) {
}
function roundMoney(value: number) {
return Math.round(value)
return Math.round(Number(value || 0) * 10) / 10
}
function roundQuantity(value: number) {
@@ -596,7 +597,7 @@ function roundQuantity(value: number) {
}
function money(value: unknown) {
return `${roundMoney(readNumber(value))}`
return formatMoney(readNumber(value))
}
function orderRentAmount(item: Order) {
@@ -1252,7 +1253,7 @@ async function copyListingCode() {
<div class="pay-summary">
<div class="pay-summary-row">
<span>支付金额</span>
<strong>¥{{ (activePayment.amount_cent / 100).toFixed(2) }}</strong>
<strong>¥{{ formatMoney(activePayment.amount_cent / 100) }}</strong>
</div>
</div>
<div v-if="paymentPayURL()" class="pay-qr-section">
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { CopyDocument, Search } from '@element-plus/icons-vue'
import { fetchOrders, type Order } from '@/features/orders'
import { formatMoney } from '@/shared/utils/money'
import { useSessionStore } from '@/stores/session'
import { orderStatusLabel } from '@/utils/statusLabels'
import { formatDateTime } from '@/utils/time'
@@ -139,7 +140,7 @@ function ownerActualIncome(order: Order) {
}
function money(value: unknown) {
return Math.round(Number(value || 0))
return formatMoney(Number(value || 0))
}
function shortenOrderNo(orderNo: string) {
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { usePublishForm } from '@/features/seller/composables/usePublishForm'
import { formatMoney } from '@/shared/utils/money'
import type { AgreementContent } from '@/features/listings/api/listingOptions'
const {
@@ -549,7 +550,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
</template>
</van-field>
<button class="deposit-recommend-btn" type="button" @click="useRecommendedDeposit">
推荐押金 ¥{{ recommendedDepositAmount }}
推荐押金 ¥{{ formatMoney(recommendedDepositAmount) }}
</button>
<van-field label="每日损耗" required class="publish-field">
<template #input>
@@ -638,7 +639,7 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
</template>
</van-field>
<van-field
:model-value="calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : ''"
:model-value="calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : ''"
label="纯币基础价"
readonly
required
@@ -652,13 +653,13 @@ function selectRadio<T extends string>(value: T, setter: (value: T) => void) {
</template>
</van-field>
<van-field
:model-value="`¥${calculatedConsumablePrice}`"
:model-value="`¥${formatMoney(calculatedConsumablePrice)}`"
label="额外消耗品"
readonly
class="publish-field result-field"
/>
<van-field
:model-value="calculatedSellerPrice ? `¥${calculatedSellerPrice}` : ''"
:model-value="calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : ''"
label="卖家价格"
readonly
required
@@ -10,6 +10,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, ref } from 'vue'
import { usePublishForm } from '@/features/seller/composables/usePublishForm'
import { formatMoney } from '@/shared/utils/money'
import type { AgreementContent } from '@/features/listings/api/listingOptions'
import OptionChips from './components/OptionChips.vue'
import PublishSection from './components/PublishSection.vue'
@@ -489,13 +490,13 @@ function selectDailyLoss(value: string | number) {
:placeholder="priceConfig.deposit_placeholder"
/>
<button class="recommend-button" type="button" @click="useRecommendedDeposit">
使用推荐 ¥{{ recommendedDepositAmount }}
使用推荐 ¥{{ formatMoney(recommendedDepositAmount) }}
</button>
</div>
<div class="deposit-breakdown">
<span v-for="item in depositBreakdownItems" :key="`${item.label}-${item.count}`">
{{ item.label }}<template v-if="item.count > 1"> x{{ item.count }}</template> ¥{{
item.amount
formatMoney(item.amount)
}}
</span>
</div>
@@ -579,15 +580,15 @@ function selectDailyLoss(value: string | number) {
</div>
<div class="price-cell">
<span>纯币基础价</span>
<strong>{{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }}</strong>
<strong>{{ calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : '--' }}</strong>
</div>
<div class="price-cell">
<span>额外消耗品</span>
<strong>¥{{ calculatedConsumablePrice }}</strong>
<strong>¥{{ formatMoney(calculatedConsumablePrice) }}</strong>
</div>
<div class="price-cell accent">
<span>发布价格</span>
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong>
<strong>{{ calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : '--' }}</strong>
</div>
</div>
@@ -607,21 +608,21 @@ function selectDailyLoss(value: string | number) {
<div class="summary-panel">
<div class="summary-main">
<span>卖家发布价格</span>
<strong>{{ calculatedSellerPrice ? `¥${calculatedSellerPrice}` : '--' }}</strong>
<strong>{{ calculatedSellerPrice ? `¥${formatMoney(calculatedSellerPrice)}` : '--' }}</strong>
</div>
<div class="summary-breakdown">
<div class="summary-breakdown-title">价格明细</div>
<div>
<span>纯币价格</span>
<strong>{{ calculatedCoinBasePrice ? `¥${calculatedCoinBasePrice}` : '--' }}</strong>
<strong>{{ calculatedCoinBasePrice ? `¥${formatMoney(calculatedCoinBasePrice)}` : '--' }}</strong>
</div>
<div>
<span>额外物品价格</span>
<strong>¥{{ calculatedConsumablePrice }}</strong>
<strong>¥{{ formatMoney(calculatedConsumablePrice) }}</strong>
</div>
<div>
<span>押金价格</span>
<strong>{{ form.deposit_amount === '' ? '--' : `¥${form.deposit_amount}` }}</strong>
<strong>{{ form.deposit_amount === '' ? '--' : `¥${formatMoney(Number(form.deposit_amount))}` }}</strong>
</div>
</div>
<div class="summary-list">
@@ -9,6 +9,7 @@ import {
submitListingReview,
type Listing,
} from '@/features/listings'
import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
import { listingReviewStatusLabel, listingStatusLabel } from '@/utils/statusLabels'
import { formatListingCode, getListingSellerPrice } from '@/utils/listingDisplay'
@@ -103,7 +104,7 @@ function replaceListing(next: Listing) {
}
function listingPrice(row: Listing) {
return `¥${Math.round(getListingSellerPrice(row))}`
return formatMoneyWithSymbol(getListingSellerPrice(row))
}
async function copyListingCode(row: Listing) {
@@ -226,7 +227,7 @@ function isPendingReview(row: Listing) {
</div>
<div>
<span>押金</span>
<strong>¥{{ item.deposit_amount }}</strong>
<strong>¥{{ formatMoney(item.deposit_amount) }}</strong>
</div>
</div>
@@ -222,7 +222,7 @@ function readError(error: unknown, fallback: string) {
}
function formatMoney(value: number) {
return `¥${Number(value || 0).toFixed(2)}`
return `¥${(Math.round(Number(value || 0) * 10) / 10).toFixed(1)}`
}
function walletBizTypeLabel(type: string) {
@@ -13,6 +13,7 @@ import {
type WithdrawalRequest,
} from '../api/withdrawal'
import { fetchWalletBalance, type WalletAccount } from '../api/wallet'
import { formatMoney, formatMoneyWithSymbol } from '@/shared/utils/money'
const router = useRouter()
@@ -84,7 +85,7 @@ async function handleSubmit() {
try {
await ElMessageBox.confirm(
`确认提现 ¥${withdrawForm.value.amount.toFixed(2)}${selectedAccount.value?.account_name} (${selectedAccount.value?.account_no}) ?`,
`确认提现 ${formatMoneyWithSymbol(withdrawForm.value.amount)}${selectedAccount.value?.account_name} (${selectedAccount.value?.account_no}) ?`,
'确认提现',
{
confirmButtonText: '确认',
@@ -175,7 +176,7 @@ function accountTypeLabel(type: string) {
<el-icon><Wallet /></el-icon>
可用余额
</div>
<div class="balance-value">¥{{ account?.available_balance.toFixed(2) || '0.00' }}</div>
<div class="balance-value">¥{{ formatMoney(account?.available_balance) }}</div>
</div>
<div class="balance-item">
<div class="balance-label">
@@ -183,7 +184,7 @@ function accountTypeLabel(type: string) {
冻结余额
</div>
<div class="balance-value frozen">
¥{{ account?.frozen_balance.toFixed(2) || '0.00' }}
¥{{ formatMoney(account?.frozen_balance) }}
</div>
</div>
</div>
@@ -251,7 +252,7 @@ function accountTypeLabel(type: string) {
<el-form-item label="到账金额">
<div class="actual-amount">
¥{{ withdrawForm.amount > 0 ? withdrawForm.amount.toFixed(2) : '0.00' }}
¥{{ formatMoney(withdrawForm.amount > 0 ? withdrawForm.amount : 0) }}
</div>
</el-form-item>
@@ -264,7 +265,7 @@ function accountTypeLabel(type: string) {
show-icon
style="margin-bottom: 16px"
>
余额不足,可用余额:¥{{ account.available_balance.toFixed(2) }}
余额不足,可用余额:¥{{ formatMoney(account.available_balance) }}
</el-alert>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">
@@ -308,7 +309,7 @@ function accountTypeLabel(type: string) {
<el-table-column prop="withdraw_no" label="提现单号" min-width="180" />
<el-table-column label="提现金额" width="120">
<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="收款方式" width="150">
+12
View File
@@ -97,6 +97,18 @@ const allNavGroups: NavGroup[] = [
index: 'finance',
icon: Coin,
children: [
{
label: '财务仪表盘',
to: '/admin/finance/dashboard',
icon: DataLine,
permission: 'wallet:view',
},
{
label: '财务明细',
to: '/admin/finance/details',
icon: Document,
permission: 'wallet:view',
},
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet, permission: 'wallet:view' },
{ label: '支付流水', to: '/admin/payments', icon: CreditCard, permission: 'wallet:view' },
{ label: '提现审核', to: '/admin/withdrawals', icon: Money, permission: 'withdrawal:list' },
+12
View File
@@ -64,6 +64,18 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminChatsView.vue'),
meta: adminMeta,
},
{
path: '/admin/finance/dashboard',
name: 'admin-finance-dashboard',
component: () => import('@/features/admin/views/AdminFinanceDashboardView.vue'),
meta: adminMeta,
},
{
path: '/admin/finance/details',
name: 'admin-finance-details',
component: () => import('@/features/admin/views/AdminFinanceDetailsView.vue'),
meta: adminMeta,
},
{
path: '/admin/wallet-ledger',
name: 'admin-wallet-ledger',
+3 -1
View File
@@ -1,3 +1,5 @@
import { formatMoneyWithSymbol } from '@/shared/utils/money'
export function useMoney() {
return (value: number | undefined | null) => `¥${Math.round(Number(value || 0))}`
return (value: number | undefined | null) => formatMoneyWithSymbol(value)
}
+1 -1
View File
@@ -9,7 +9,7 @@
* @example roundMoney(12.36) -> 12.4
*/
export function roundMoney(value: number): number {
return Math.round(value * 10) / 10
return Math.round(Number(value || 0) * 10) / 10
}
/**
+4 -4
View File
@@ -46,13 +46,13 @@ export function getListingDisplayPrice(item: Listing) {
export function getListingRentPrice(item: Listing) {
const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price')
if (buyerCoinBasePrice > 0) return Math.round(buyerCoinBasePrice)
return Math.max(0, Math.round(getListingDisplayPrice(item) - getListingConsumablePrice(item)))
if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice)
return Math.max(0, roundMoney(getListingDisplayPrice(item) - getListingConsumablePrice(item)))
}
export function getListingConsumablePrice(item: Listing) {
const consumablePrice = readPriceBreakdownNumber(item, 'consumable_price')
if (consumablePrice > 0) return Math.round(consumablePrice)
if (consumablePrice > 0) return roundMoney(consumablePrice)
return getListingResources(item).reduce((sum, resource) => sum + resource.amount, 0)
}
@@ -145,7 +145,7 @@ export function getListingResources(item: Listing): ListingDisplayResource[] {
mode: typeof row.mode === 'string' ? row.mode : '',
amount:
row.mode === '收费'
? Math.round(
? roundMoney(
readUnknownNumber(row.quantity) *
readUnitPrice(typeof row.price === 'string' ? row.price : '')
)
+2 -2
View File
@@ -23,7 +23,7 @@ export const commonOnlineTimes = [
]
export function roundMoney(value: number) {
return Math.round(value)
return Math.round(value * 10) / 10
}
export function roundRatio(value: number) {
@@ -31,7 +31,7 @@ export function roundRatio(value: number) {
}
export function formatNumber(value: number) {
return Number.isInteger(value) ? `${value}` : `${Math.round(value * 10) / 10}`
return (Math.round(value * 10) / 10).toFixed(1)
}
export function readUnitPrice(priceText: string) {