2
0

feat:账单系统

This commit is contained in:
zhongzm
2025-06-17 18:40:51 +08:00
parent 7c77b10c8c
commit 6c3499b470
8 changed files with 430 additions and 13 deletions

View File

@@ -61,7 +61,7 @@ function createCommonRequest<ResponseData = any>(
return Promise.resolve(response);
}
if (opts.isBackendSuccess(response)) {
if (opts.isBackendSuccess(response) || response.config?.responseType === "blob") {
return Promise.resolve(response);
}

View File

@@ -7,6 +7,7 @@ const viewEn: any = {
"view.endpoint_access": "Current Connection",
"view.endpoint_records": "Past Connection",
"view.endpoint_cdrlrecords":"Internet Records",
"view.endpoint_bill":"Bill Records",
"view.billing": "Billing",
"view.billing_histories": "Historical",
"view.billing_Rechargehistory":"Recharge History",
@@ -740,6 +741,30 @@ const local: any = {
confirm:'Confirm',
insufficientBalance:'Insufficient balance'
},
bill:{
loading:'Loading',
tipTitle:'Bill DownLoad',
exportTip:'Sure to download?',
total:'Total',
invoiceNumber:'Bill Number',
title: 'Bill List',
billDate: 'Bill Date',
amount: 'Amount',
billType: 'Bill Type',
statu: 'Status',
actions: 'Action',
download: 'Download',
preview: 'Preview',
exportOk: 'Download Success',
exportFail: 'Download Fail',
previewTitle: 'Bill Preview',
previewFailed: 'Preview fail',
noFile: 'No billing documents available',
types: {
recharge:'Recharge',
package: 'Package',
},
},
kyc:{
rejectReason:'Reject Reason',
drive:'Driving license',

View File

@@ -7,6 +7,7 @@ const viewZh: any = {
"view.endpoint_access": "当前设备",
"view.endpoint_records": "历史设备",
"view.endpoint_cdrlrecords":"上网记录",
"view.endpoint_bill":"账单记录",
"view.billing": "账单",
"view.billing_billservice":"账单服务",
"view.billing_histories": "历史查询",
@@ -602,6 +603,36 @@ const local:any = {
Paid:'已支付',
Unpaid:'未支付',
},
bill:{
loading:'加载中',
tipTitle:'账单下载',
exportTip:'确定下载吗?',
total:'共',
invoiceNumber:'账单编号',
title: '账单列表',
billDate: '账单日期',
amount: '金额',
billType: '账单类型',
statu: '状态',
actions: '操作',
download: '下载',
preview: '预览',
status: {
pending: '待支付',
paid: '已支付',
failed: '支付失败',
unknown: '未知状态'
},
exportOk: '下载成功',
exportFail: '下载失败',
previewTitle: '账单预览',
previewFailed: '预览失败',
noFile: '暂无账单文件',
types: {
recharge:'余额充值',
package: '套餐费用',
},
},
Internetdetails:{
title:"上网详单",
month:"月",

View File

@@ -415,6 +415,17 @@ export const customRoutes: GeneratedRoute[] = [
order: 4
},
},
{
name: 'billing_bill',
path: '/billing/bill',
component: 'view.billing_bill',
meta: {
title: '上网记录',
i18nKey: 'view.endpoint_bill',
icon: 'ant-design:book-outlined',
order: 5
},
},
{
name: 'billing_wxpay',
path: '/billing/wxpay',

View File

@@ -1,5 +1,5 @@
import { request } from '../request';
import { request, rawRequest } from '../request';
/**
* Login
*
@@ -206,6 +206,15 @@ export function resetPasswordByEmail(data: { email: string; code: string; passwo
data
});
}
/** 下载账单 PDF 文件 */
/** 下载账单 PDF 文件(使用 axios 直连) */
/** 下载账单 PDF 文件(使用 rawRequest避免 transformBackendResponse 破坏 blob */
export function downloadBill(id: string) {
return request({
url: `/u/bill/download/${id}`,
method: 'get',
responseType: 'blob'
});
}

21
src/typings/api.d.ts vendored
View File

@@ -676,13 +676,20 @@ declare namespace Api {
namespace Bill {
/** Bill record information */
interface BillRecord {
id: string;
startTime: string | null;
endTime: string | null;
traffic: string | null;
amount: string;
status: number;
createTime: string;
/** 账单编号 */
invoiceNumber: string;
/** 账单金额 */
amount: number;
/** 账单类型0-购买套餐1-余额充值 */
type: 0 | 1;
/** 账单时间 */
invoiceTime: string;
/** 账单状态0-待支付1-已支付2-支付失败 */
status: 0 | 1 | 2;
/** 账单文件URL */
fileUrl?: string;
/** 发票文件URL */
invoiceFile?: string;
}
/** Bill list response */

View File

@@ -34,14 +34,12 @@ declare module 'vue' {
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
APopover: typeof import('ant-design-vue/es')['Popover']
AppLoading: typeof import('./../components/common/app-loading.vue')['default']
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
ARow: typeof import('ant-design-vue/es')['Row']
ASegmented: typeof import('ant-design-vue/es')['Segmented']
ASelect: typeof import('ant-design-vue/es')['Select']
@@ -80,7 +78,6 @@ declare module 'vue' {
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
OrderConfirmModal: typeof import('./../components/order-confirm/orderConfirmModal.vue')['default']
Pay: typeof import('./../components/pay/pay.vue')['default']
PaypalButton: typeof import('./../components/payment/paypal-button.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']

View File

@@ -0,0 +1,337 @@
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import type { TableColumnsType } from 'ant-design-vue'
import { Table as ATable, Button as AButton, Modal, message } from 'ant-design-vue'
import { useI18n } from "vue-i18n"
import { useRouter } from 'vue-router'
import { useWindowSize } from '@vueuse/core'
import { useTable } from '@/hooks/common/table'
import { fetchBillHistory, downloadBill } from '@/service/api/auth'
import { getPaymentConfig } from '@/service/api/payment'
import type { Api } from '@/typings/api'
import { SimpleScrollbar } from '~/packages/materials/src'
import { Card as ACard, Tag as ATag } from 'ant-design-vue'
import { useElementSize } from '@vueuse/core'
import axios from 'axios'
const router = useRouter()
const { t } = useI18n()
const currencySymbol = ref('¥')
// 获取货币符号
const fetchCurrencySymbol = async () => {
try {
const response = await getPaymentConfig()
if (response && response.data) {
currencySymbol.value = response.data.currencySymbol
}
} catch (error) {
// console.error('Failed to fetch currency symbol:', error)
}
}
// 格式化日期时间
const formatDateTime = (dateTimeString: string) => {
if (!dateTimeString) return ''
const date = new Date(dateTimeString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
// 响应式布局
const { width } = useWindowSize()
const isMobile = computed(() => width.value < 768)
// 处理文件URL
const parseFile = (path: string) => {
let baseUrl = import.meta.env.VITE_SERVICE_BASE_URL;
// console.log(baseUrl)
// debugger
// 移除末尾的斜杠
baseUrl = baseUrl.replace(/\/+$/, '');
// 从完整 URL 中提取域名部分
if (baseUrl.includes('://')) {
try {
const url = new URL(baseUrl);
baseUrl = url.host; // 这将获取域名和端口(如果有的话)
} catch (error) {
// 如果解析失败,使用原始值的域名部分
baseUrl = baseUrl.replace(/^https?:\/\//, '').split('/')[0];
}
}
// 构造最终的 URL
return `${path.startsWith('/') ? path : '/' + path}`;
}
const wrapperEl = ref<HTMLElement | null>(null)
const { height: wrapperElHeight } = useElementSize(wrapperEl)
const scrollConfig = computed(() => ({
y: wrapperElHeight.value - 72,
x: 800
}))
const getStatusColor = (status: number) => {
switch (status) {
case 0:
return 'warning'
case 1:
return 'success'
case 2:
return 'error'
default:
return 'default'
}
}
const getStatusText = (status: number) => {
switch (status) {
case 0:
return t('page.bill.status.pending')
case 1:
return t('page.bill.status.paid')
case 2:
return t('page.bill.status.failed')
default:
return t('page.bill.status.unknown')
}
}
// 表格相关配置
const { columns, data, loading, getData, mobilePagination, searchParams } = useTable<Api.Bill.BillRecord>({
apiFn: fetchBillHistory,
apiParams: {
pageNum: 1,
pageSize: 10
},
rowKey: 'id',
pagination: true,
columns: (): AntDesign.TableColumn<Api.Bill.BillRecord>[] => [
{
key: 'invoiceNumber',
dataIndex: 'invoiceNumber',
title: t('page.bill.invoiceNumber'),
align: 'center',
width: 150
},
{
key: 'invoiceTime',
dataIndex: 'invoiceTime',
title: t('page.bill.billDate'),
align: 'center',
width: 150,
customRender: ({ text }: { text: string }) => text ? formatDateTime(text) : ''
},
{
key: 'amount',
dataIndex: 'amount',
title: t('page.bill.amount'),
align: 'center',
width: 120,
customRender: ({ text }: { text: number }) => {
const formattedAmount = Number(text).toFixed(2)
return `${currencySymbol.value}${formattedAmount}`
}
},
{
key: 'type',
dataIndex: 'type',
title: t('page.bill.billType'),
align: 'center',
width: 120,
customRender: ({ text }: { text: Api.Bill.BillRecord['type'] }) => {
const typeMap: Record<Api.Bill.BillRecord['type'],{ color: string; label: string }> = {
0: { color: 'blue', label: t('page.bill.types.package') },
1: { color: 'gold', label: t('page.bill.types.recharge') }
}
const typeInfo = typeMap[text] || { color: 'default', label: t('page.bill.types.unknown') }
return h(ATag, { color: typeInfo.color }, () => typeInfo.label)
}
},
{
key: 'invoiceFile',
title: t('page.bill.actions'),
align: 'center',
width: 150,
fixed: 'right'
}
]
})
// 下载账单
const handleExport = (record: Api.Bill.BillRecord) => {
Modal.confirm({
title: t('page.bill.tipTitle'),
content: t('page.bill.exportTip'),
async onOk() {
const key = 'exportJob';
message.loading({ content: t('page.bill.loading'), key });
try {
const res = await downloadBill(record.id);
// console.log('downloadBill 返回:', res);
let blob;
if (res instanceof Blob) {
blob = res;
} else if (res.data instanceof Blob) {
blob = res.data;
} else {
blob = new Blob([res.data]);
}
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = `invoice-${record.invoiceNumber || record.id}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
message.success({ content: t('page.bill.exportOk'), key, duration: 2 });
} catch (e) {
// console.error('导出异常:', e);
message.error({ content: t('page.bill.exportFail'), key, duration: 2 });
}
}
});
}
// 预览账单
const handlePreview = async (record: Api.Bill.BillRecord) => {
// console.log('handlePreview被调用', record);
if (!record.invoiceFile) {
message.error(t('page.bill.noFile'))
return
}
try {
const url = parseFile(record.invoiceFile)
// console.log('预览PDF地址:', url, record.invoiceFile)
Modal.info({
title: t('page.bill.previewTitle'),
width: '80%',
content: h('iframe', {
src: url,
style: {
width: '100%',
height: '80vh',
border: 'none'
}
})
})
} catch (error) {
message.error(t('page.bill.previewFailed'))
}
}
onMounted(() => {
fetchCurrencySymbol()
getData()
})
</script>
<template>
<SimpleScrollbar>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<ACard
:title="t('page.bill.title')"
:bordered="false"
:body-style="{ flex: 1, overflow: 'hidden' }"
class="flex-col-stretch sm:flex-1-hidden card-wrapper"
>
<ATable
ref="wrapperEl"
:columns="columns"
:data-source="data"
:loading="loading"
row-key="id"
size="small"
:pagination="{
...mobilePagination,
total: mobilePagination.total,
current: searchParams.pageNum,
pageSize: searchParams.pageSize,
showTotal: (total: number) => `${t('page.bill.total')} ${total} `
}"
:scroll="scrollConfig"
class="h-full"
@change="(pagination) => {
searchParams.pageNum = pagination.current;
searchParams.pageSize = pagination.pageSize;
getData();
}"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<ATag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</ATag>
</template>
<template v-if="column.key === 'invoiceFile'">
<div class="table-file-wrapper">
<AButton
type="link"
size="small"
:disabled="!record.invoiceFile"
@click="handlePreview(record)"
>
{{ t('page.bill.preview') }}
</AButton>
<AButton
type="link"
size="small"
:disabled="!record.invoiceFile"
@click="handleExport(record)"
>
{{ t('page.bill.download') }}
</AButton>
</div>
</template>
</template>
</ATable>
</ACard>
</div>
</SimpleScrollbar>
</template>
<style scoped>
.h-full {
height: 100%;
}
.card-wrapper {
margin-top: 16px;
}
.table-file-wrapper {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 4px;
}
:deep(.ant-btn-link) {
padding: 0;
height: auto;
line-height: inherit;
}
:deep(.ant-btn-link:hover) {
color: #1890ff;
text-decoration: underline;
}
:deep(.ant-modal-body) {
padding: 0;
}
:deep(.ant-modal-confirm-content) {
margin: 0;
}
</style>