fix: 更新提现审核相关功能 更新账务报表页面功能

This commit is contained in:
ZhuRui
2026-08-05 17:34:01 +08:00
parent 8951733a2d
commit bc7e9169db
12 changed files with 374 additions and 168 deletions
@@ -377,7 +377,7 @@ export default defineComponent({
try {
const file = base64ToFile(dataUrl, `banner_${Date.now()}.jpg`);
const result = await uploadFile(file, OssUploadType.Banner as any);
const result = await uploadFile(file, OssUploadType.EventCover);
URL.revokeObjectURL(localUrl);
formData.imageUrl = result.url;
@@ -123,4 +123,10 @@
color: rgba(0, 0, 0, 0.85);
}
}
.detailTableWrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
}
+73 -51
View File
@@ -2,7 +2,7 @@ import { defineComponent, ref, nextTick } from 'vue';
import { Select, Table } from 'ant-design-vue';
import * as echarts from 'echarts';
import { useReportsModel } from './model/useReportsModel';
import { useEffect } from '@/hooks';
import { useEffect, useContainerSize } from '@/hooks';
import styles from './index.module.less';
/**
@@ -11,7 +11,7 @@ import styles from './index.module.less';
* 页面结构:
* 1. 顶部标题 + 时间范围选择
* 2. 三张统计卡(总收入 / 总提现 / 平台余额)
* 3. 月度收支趋势(近 6 个月,echarts 柱状 + 折线组合图)
* 3. 月度收支趋势(echarts 柱状 + 折线组合图)
* 4. 月度收支明细(表格)
*/
export default defineComponent({
@@ -33,22 +33,23 @@ export default defineComponent({
let chartInstance: any = null;
const getChartOption = () => {
const months = chartData.value.map((d) => d.month);
const order = chartData.value.map((d) => d.order);
const netIncome = chartData.value.map((d) => d.netIncome);
const data = chartData.value;
const months = data.map((d) => d.month);
const orderAmount = data.map((d) => d.order);
const refundAmount = data.map((d) => d.refund);
const withdrawAmount = data.map((d) => d.withdraw);
const netIncome = data.map((d) => d.netIncome);
return {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
crossStyle: { color: 'rgba(0, 0, 0, 0.15)' },
label: { backgroundColor: '#6a7985' },
},
axisPointer: { type: 'shadow' },
valueFormatter: (v: number) => `¥${formatMoney(v)}`,
},
legend: {
data: [
{ name: '订单金额', icon: 'roundRect' },
{ name: '退款金额', icon: 'roundRect' },
{ name: '提现金额', icon: 'roundRect' },
{ name: '净收入', icon: 'circle' },
],
bottom: 8,
@@ -63,7 +64,6 @@ export default defineComponent({
axisTick: { show: false },
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: 'rgba(0, 0, 0, 0.55)' },
axisPointer: { type: 'shadow' },
},
yAxis: {
type: 'value',
@@ -77,23 +77,28 @@ export default defineComponent({
{
name: '订单金额',
type: 'bar',
data: order,
barMaxWidth: 48,
barCategoryGap: '35%',
itemStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#4096ff' },
{ offset: 1, color: '#1677ff' },
],
},
borderRadius: [4, 4, 0, 0],
},
stack: 'amount',
data: orderAmount,
barMaxWidth: 40,
itemStyle: { color: '#4096ff' },
emphasis: { focus: 'series' },
},
{
name: '退款金额',
type: 'bar',
stack: 'amount',
data: refundAmount,
barMaxWidth: 40,
itemStyle: { color: '#ff7875' },
emphasis: { focus: 'series' },
},
{
name: '提现金额',
type: 'bar',
stack: 'amount',
data: withdrawAmount,
barMaxWidth: 40,
itemStyle: { color: '#ffa940' },
emphasis: { focus: 'series' },
},
{
@@ -154,48 +159,59 @@ export default defineComponent({
};
}, [() => chartRef.value]);
// 时间范围切换时刷新图表
// 数据更新时刷新图表
useEffect(() => {
renderChart();
}, [range]);
}, [chartData]);
// ===== 明细表容器高度 =====
const { containerRef: detailContainerRef, height: detailTableHeight } = useContainerSize({
headerOffset: 56,
});
return () => {
/** 根据增长率构建箭头 + 颜色(正值 ↑ 绿,负值 ↓ 红,null 时固定文本) */
const changeDesc = (rate: number | null) => {
if (rate == null) return { desc: '= 总收入 - 提现金额', descColor: 'rgba(0, 0, 0, 0.55)' };
const arrow = rate > 0 ? '' : '↓';
/** 根据增长率构建箭头 + 颜色 */
const changeDesc = (rateStr: string | undefined, fallbackRate: number | null) => {
if (rateStr === undefined && fallbackRate !== null) rateStr = String(fallbackRate);
if (rateStr == null || rateStr === '' || isNaN(parseFloat(rateStr)))
return { desc: '= 总收入 - 提现金额', descColor: 'rgba(0, 0, 0, 0.55)' };
const rate = parseFloat(rateStr);
const arrow = rate > 0 ? '↑' : rate < 0 ? '↓' : '';
const sign = rate > 0 ? '+' : '';
const absRate = Math.abs(rate).toFixed(2);
return {
desc: `${arrow} 较上月 ${sign}${rate}%`,
descColor: rate > 0 ? '#52c41a' : '#ff4d4f',
desc: rate === 0 ? `→ 与上月持平` : `${arrow} 较上月 ${sign}${absRate}%`,
descColor: rate > 0 ? '#52c41a' : rate < 0 ? '#ff4d4f' : 'rgba(0, 0, 0, 0.55)',
};
};
const s = summary.value;
/** 顶部 3 张统计卡配置 */
const statCards = [
{
key: 'totalIncome',
label: '总收入(扣除退款后)',
value: summary.value.totalIncome,
value: s.totalIncome,
bgColor: '#f6ffed',
accentColor: '#52c41a',
...changeDesc(12.5),
...changeDesc(s.totalIncomeTendency, null),
},
{
key: 'totalWithdraw',
label: '总提现金额',
value: summary.value.totalWithdraw,
value: s.totalWithdraw,
bgColor: '#fffbe6',
accentColor: '#faad14',
...changeDesc(32),
...changeDesc(s.totalWithdrawTendency, null),
},
{
key: 'totalBalance',
label: '平台余额(所有用户余额总和)',
value: summary.value.totalBalance,
value: s.totalBalance,
bgColor: '#e6f4ff',
accentColor: '#1677ff',
...changeDesc(null),
...changeDesc(undefined, null),
},
];
@@ -236,7 +252,7 @@ export default defineComponent({
<div class={styles.chartCard}>
<div class={styles.cardTitle}>
<span class={styles.cardTitleIcon}>📊</span>
6
{chartData.value.length}
</div>
<div
ref={(el: any) => {
@@ -249,14 +265,20 @@ export default defineComponent({
{/* ===== 月度收支明细 ===== */}
<div class={styles.detailCard}>
<div class={styles.cardTitle}></div>
<Table
columns={detailColumns}
dataSource={detailData.value}
pagination={false}
size="middle"
bordered={false}
rowKey="key"
/>
<div ref={detailContainerRef} class={styles.detailTableWrap}>
<Table
columns={detailColumns}
dataSource={detailData.value}
pagination={false}
size="middle"
bordered={false}
rowKey="key"
scroll={{
y: detailTableHeight.value,
x: 'max-content',
}}
/>
</div>
</div>
</div>
);
@@ -0,0 +1,31 @@
/**
* 财务报表 - 接口服务
*/
import { get } from '@/utils/request';
import type {
ReportQueryParams,
FinanceReportVO,
ReportMonthItem,
ApiResult,
} from '@/api/reports/types';
export type {
ReportQueryParams,
FinanceReportVO,
ReportMonthItem,
ApiResult,
} from '@/api/reports/types';
// ============================================================
// URL 常量
// ============================================================
const reportList = '/admin/finance/report/list';
// ============================================================
// API 函数
// ============================================================
/** 财务报表 — GET /admin/finance/report/list?date=yyyy-MM */
export function getFinanceReport(params: ReportQueryParams): Promise<ApiResult<FinanceReportVO>> {
return get(reportList, params as Record<string, any>);
}
@@ -1,6 +1,9 @@
import { ref } from 'vue';
import { computed, ref } from 'vue';
import type { SelectValue } from 'ant-design-vue/es/select';
import { useState } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { getFinanceReport, type FinanceReportVO } from './services';
import dayjs from 'dayjs';
export type RangeValue = (typeof RANGE_OPTIONS)[number]['value'];
@@ -10,85 +13,44 @@ export type RangeValue = (typeof RANGE_OPTIONS)[number]['value'];
/** 时间范围选项 */
export const RANGE_OPTIONS = [
{ value: 'month', label: '本月' },
{ value: '3m', label: '近 3 个月' },
{ value: '6m', label: '近 6 个月' },
{ value: 'year', label: '本年' },
] as const;
{ value: 'month' as const, label: '本月' },
{ value: '3m' as const, label: '近 3 个月' },
{ value: '6m' as const, label: '近 6 个月' },
{ value: 'year' as const, label: '本年' },
];
/** 范围对应的月数 */
const RANGE_MONTHS: Record<RangeValue, number> = {
month: 1,
'3m': 3,
'6m': 6,
year: 12,
};
function isRangeValue(val: SelectValue): val is RangeValue {
return typeof val === 'string' && RANGE_OPTIONS.some((item) => item.value === val);
}
// ============================================================
// 假数据(数值自洽:总收入 - 总提现 = 平台余额)
// ============================================================
/** 6 个月图表数据(月份标签 + 订单金额 + 净收入) */
const CHART_DATA = [
{ month: '01月', order: 22500, netIncome: 19000, withdraw: 300 },
{ month: '02月', order: 24500, netIncome: 21500, withdraw: 1850 },
{ month: '03月', order: 24800, netIncome: 23200, withdraw: 1400 },
{ month: '04月', order: 26100, netIncome: 24900, withdraw: 1500 },
{ month: '05月', order: 28900, netIncome: 27400, withdraw: 1800 },
{ month: '06月', order: 32500, netIncome: 31860, withdraw: 2100 },
];
/** 月度收支明细(最近 4 个月) */
const DETAIL_DATA = [
{
key: '2026-06',
month: '2026-06',
order: 32500,
refund: 640,
netIncome: 31860,
withdraw: 2100,
},
{
key: '2026-05',
month: '2026-05',
order: 28900,
refund: 1500,
netIncome: 27400,
withdraw: 1800,
},
{
key: '2026-04',
month: '2026-04',
order: 26100,
refund: 1200,
netIncome: 24900,
withdraw: 1500,
},
{
key: '2026-03',
month: '2026-03',
order: 24800,
refund: 1600,
netIncome: 23200,
withdraw: 1400,
},
];
/** 顶部 3 张统计卡数据 */
const SUMMARY = {
totalIncome: 147860, // 总收入(净收入合计)
totalWithdraw: 8950, // 总提现
totalBalance: 138910, // 平台余额 = 总收入 - 总提现
};
// ============================================================
// 工具
// ============================================================
/** 千分位 + 保留 2 位小数 */
function formatMoney(n: number): string {
return (n || 0).toLocaleString('zh-CN', {
function formatMoneyStr(value: string): string {
const n = parseFloat(value);
if (isNaN(n)) return '0.00';
return n.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/** 字符串金额转数字 */
function toNumber(value: string): number {
const n = parseFloat(value);
return isNaN(n) ? 0 : n;
}
// ============================================================
// Model
// ============================================================
@@ -98,22 +60,55 @@ function formatMoney(n: number): string {
*/
export function useReportsModel() {
// ===== 时间范围 =====
const [range, setRange] = useState<RangeValue>('month');
const range = ref<RangeValue>('month');
/** 切换时间范围(演示用:打日志,不改数据) */
const handleRangeChange = (val: SelectValue) => {
if (!isRangeValue(val)) return;
setRange(val);
// TODO: 接入 API 时按 range 重新拉取汇总 / 图表 / 明细
// ===== 构建 API 参数(根据 range 推算查询起始月份) =====
const buildQueryParams = () => {
const months = RANGE_MONTHS[range.value];
const date = dayjs()
.subtract(months - 1, 'month')
.format('YYYY-MM');
return { date };
};
// ===== 请求(range 变化时自动重新请求) =====
const {
data,
loading,
run: fetchReport,
} = useRequest<FinanceReportVO>(() => getFinanceReport(buildQueryParams()), {
refreshDeps: [range],
formatResult: (res) => (res.code == 200 ? res.data : null),
});
// ===== 根据范围过滤后的月列表 =====
const filteredMonthList = computed(() => {
const list = data.value?.monthList || [];
const count = RANGE_MONTHS[range.value];
return list.slice(-count);
});
// ===== 汇总 =====
const summary = ref({ ...SUMMARY });
const summary = computed(() => ({
totalIncome: data.value?.totalIncome || '0.00',
totalIncomeTendency: data.value?.totalIncomeTendency || '0',
totalWithdraw: data.value?.totalWithdrawIncome || '0.00',
totalWithdrawTendency: data.value?.totalWithdrawIncomeTendency || '0',
totalBalance: data.value?.totalBalanceAmount || '0.00',
}));
// ===== 图表 =====
const chartData = ref([...CHART_DATA]);
// ===== 图表数据 =====
const chartData = computed(() =>
filteredMonthList.value.map((item) => ({
month: dayjs(item.month).format('MM月'),
order: toNumber(item.orderAmount),
refund: toNumber(item.refundAmount),
netIncome: toNumber(item.netIncome),
withdraw: toNumber(item.withdrawAmount),
})),
);
// ===== 明细表 =====
// ===== 明细表 =====
const detailColumns = [
{
title: '月份',
@@ -156,10 +151,39 @@ export function useReportsModel() {
customCell: (_record: any) => ({ style: { color: '#fa8c16' } }),
},
];
const detailData = ref([...DETAIL_DATA]);
// ===== 明细表数据 =====
const detailData = computed(() =>
filteredMonthList.value.map((item) => ({
key: item.month,
month: item.month,
order: toNumber(item.orderAmount),
refund: toNumber(item.refundAmount),
netIncome: toNumber(item.netIncome),
withdraw: toNumber(item.withdrawAmount),
})),
);
// ===== 切换时间范围 =====
const handleRangeChange = (val: SelectValue) => {
if (!isRangeValue(val)) return;
range.value = val;
};
// ===== 范围变化时刷新图表(通过 page 中的 useEffect 监听 range =====
/** 千分位格式金额(页面渲染用) */
function formatMoney(val: string | number): string {
if (typeof val === 'string') return formatMoneyStr(val);
return val.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
return {
range,
loading,
handleRangeChange,
summary,
chartData,
@@ -135,6 +135,11 @@
padding-top: 6px;
}
.required {
color: #ff4d4f;
margin-right: 2px;
}
.footer {
display: flex;
justify-content: flex-end;
@@ -1,11 +1,16 @@
import { defineComponent, reactive, ref, watch } from 'vue';
import { defineComponent, reactive, ref } from 'vue';
import { useEffect } from '@/hooks';
import { Modal, Radio, Input, Button, Upload, Image, Spin, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { uploadFile, OssUploadType } from '@/utils/oss';
import { getWithdrawInfo, postWithdrawAudit, type WithdrawInfoVO } from '../model/services';
import { getAuditStatusText, getPayStatusText } from '../model/useWithdrawModel';
import styles from './WithdrawDetailModal.module.less';
const { TextArea } = Input;
const MAX_IMAGE_COUNT = 3;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
interface WithdrawDetailModalProps {
visible: boolean;
@@ -30,7 +35,7 @@ export default defineComponent({
const detailLoading = ref(false);
const auditForm = reactive({ pass: true, remark: '' });
const imageList = ref<any[]>([]);
const imageList = ref<UploadFile[]>([]);
const submitting = ref(false);
// ===== 拉取详情 =====
@@ -47,44 +52,91 @@ export default defineComponent({
}
};
watch(
() => props.visible,
async (val) => {
if (val && props.record?.id) {
auditForm.pass = true;
auditForm.remark = '';
imageList.value = [];
submitting.value = false;
await fetchDetail(props.record.id);
}
},
{ immediate: true },
);
// ===== 弹窗打开时拉取详情 =====
useEffect(() => {
if (props.visible && props.record?.id) {
auditForm.pass = true;
auditForm.remark = '';
imageList.value = [];
submitting.value = false;
fetchDetail(props.record.id);
}
}, [() => props.visible]);
// ===== 上传 =====
const beforeUpload = (file: any) => {
if (file.size / 1024 / 1024 >= 5) {
// ===== 图片上传(OSS =====
const beforeUpload = async (file: UploadFile) => {
const rawFile = file as unknown as File;
if (imageList.value.length >= MAX_IMAGE_COUNT) {
message.warning(`最多上传 ${MAX_IMAGE_COUNT} 张图片`);
return false;
}
if (!/^image\//.test(rawFile.type)) {
message.error('请选择图片文件');
return false;
}
if (rawFile.size >= MAX_IMAGE_SIZE) {
message.error('图片大小不能超过 5MB');
return false;
}
imageList.value = [...imageList.value, file];
const uid = file.uid || `${Date.now()}-${Math.random()}`;
const uploadingItem: UploadFile = {
uid,
name: rawFile.name,
status: 'uploading',
};
imageList.value = [...imageList.value, uploadingItem];
try {
const result = await uploadFile(rawFile, OssUploadType.WithdrawAudit);
imageList.value = imageList.value.map((item) =>
item.uid === uid
? {
...item,
status: 'done',
url: result.url,
thumbUrl: result.url,
}
: item,
);
} catch (err: any) {
imageList.value = imageList.value.filter((item) => item.uid !== uid);
message.error(err?.message || '图片上传失败');
}
return false;
};
const handleRemove = (file: any) => {
imageList.value = imageList.value.filter((f) => f.uid !== file.uid);
const handleRemove = (file: UploadFile) => {
imageList.value = imageList.value.filter((item) => item.uid !== file.uid);
};
// ===== 提交审核 =====
const handleSubmit = async () => {
if (!auditForm.remark.trim()) {
message.warning('请输入审核内容');
// 驳回:审核内容必填
if (!auditForm.pass && !auditForm.remark.trim()) {
message.warning('驳回时请填写审核内容');
return;
}
// 通过:必须上传图片
if (auditForm.pass) {
const hasDoneImage = imageList.value.some((item) => item.status === 'done' && item.url);
if (!hasDoneImage) {
message.warning('审核通过时必须上传审核图片');
return;
}
}
if (imageList.value.some((item) => item.status === 'uploading')) {
message.warning('图片仍在上传中,请稍候');
return;
}
submitting.value = true;
try {
const checkImg = imageList.value
.map((f) => f.name || f.url || '')
.filter(Boolean)
.filter((item) => item.status === 'done' && item.url)
.map((item) => item.url as string)
.join(',');
const res = await postWithdrawAudit({
id: String(props.record.id),
@@ -117,7 +169,7 @@ export default defineComponent({
const renderContent = () => {
const d = detail.value!;
const bankCardTypeLabel = d.bankCardType === 1 ? '个人' : d.bankCardType || '-';
const isPending = d.auditStatus === 0;
const isPending = d.auditStatus == 0;
return (
<>
@@ -177,9 +229,12 @@ export default defineComponent({
</Radio.Group>
</div>
<div class={styles.formRow}>
<span class={[styles.formLabel, styles.formLabelTop]}></span>
<span class={[styles.formLabel, styles.formLabelTop]}>
{!auditForm.pass && <span class={styles.required}>*</span>}
</span>
<TextArea
v-model={auditForm.remark}
v-model:value={auditForm.remark}
placeholder="请输入审核内容"
rows={4}
maxlength={500}
@@ -188,7 +243,10 @@ export default defineComponent({
/>
</div>
<div class={styles.formRow}>
<span class={[styles.formLabel, styles.formLabelTop]}></span>
<span class={[styles.formLabel, styles.formLabelTop]}>
{auditForm.pass && <span class={styles.required}>*</span>}
</span>
<Upload
listType="picture-card"
fileList={imageList.value}
@@ -197,10 +255,10 @@ export default defineComponent({
accept="image/*"
class={styles.uploader}
>
{imageList.value.length >= 3 ? null : (
{imageList.value.length >= MAX_IMAGE_COUNT ? null : (
<div class={styles.uploadTrigger}>
<PlusOutlined />
<div class={styles.uploadText}></div>
<div class={styles.uploadText}></div>
</div>
)}
</Upload>
+1 -1
View File
@@ -81,7 +81,7 @@ export default defineComponent({
{
title: '操作',
key: 'action',
width: 100,
width: 140,
fixed: 'right' as const,
align: 'center' as const,
},