feat:账单系统
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:"月",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
21
src/typings/api.d.ts
vendored
@@ -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 */
|
||||
|
||||
3
src/typings/components.d.ts
vendored
3
src/typings/components.d.ts
vendored
@@ -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']
|
||||
|
||||
337
src/views/billing/bill/index.vue
Normal file
337
src/views/billing/bill/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user