111
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
cce677e3ff
commit
530cfcd939
42 files changed
+3951
-483
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
import request from "/@/utils/request"
|
||||
|
||||
/**
|
||||
@@ -165,3 +166,6 @@ export function getSimpleUserAll(params?: { name?: string; mobile?: string; user
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitUserExport = (query: Record<string, unknown>) => submitExportTask("/admin/user/export-async", query)
|
||||
@@ -0,0 +1,84 @@
|
||||
import request from "/@/utils/request"
|
||||
|
||||
/** 通用异步任务进度状态,由后端 Redis 进度接口返回。 */
|
||||
export interface AsyncTaskProgress {
|
||||
/** 提交接口返回的不透明任务 ID。 */
|
||||
taskId: string
|
||||
/** 业务 Redis Key 命名空间,不包含租户、用户等服务端隔离信息。 */
|
||||
redisKey: string
|
||||
/** 任务展示名称。 */
|
||||
taskName: string
|
||||
/** 固定状态编码:PENDING、RUNNING、SUCCESS、FAILED。 */
|
||||
status: string
|
||||
/** 本次任务需要处理的总项目数。 */
|
||||
total: number
|
||||
/** 当前已经处理的项目数。 */
|
||||
completed: number
|
||||
/** 当前完成百分比,取值为 0 至 100。 */
|
||||
percentage: number
|
||||
/** 当前进度或最终结果说明。 */
|
||||
message: string
|
||||
/** 是否已进入成功或失败终态。 */
|
||||
finished: boolean
|
||||
/** 终态是否成功;任务未结束时为 false。 */
|
||||
success: boolean
|
||||
/** 导出等任务成功后生成的文件下载地址;导入任务为空。 */
|
||||
resultFileUrl?: string
|
||||
/** 导出等任务成功后建议使用的下载文件名称;导入任务为空。 */
|
||||
resultFileName?: string
|
||||
}
|
||||
|
||||
/** 通用异步任务轮询配置,导入、导出等业务只需提供自身命名空间和必要覆盖项。 */
|
||||
export interface AsyncTaskPollingConfig {
|
||||
/** 通用进度查询接口地址。 */
|
||||
progressUrl: string
|
||||
/** 当前业务使用的 Redis Key 命名空间。 */
|
||||
redisKey: string
|
||||
/** 提交接口响应数据中的任务 ID 字段名,默认 taskId。 */
|
||||
taskIdField?: string
|
||||
/** 两次进度查询之间的间隔,单位毫秒,默认 1000。 */
|
||||
pollIntervalMs?: number
|
||||
/** 单次持续轮询的最长时间,单位毫秒,默认 2 小时。 */
|
||||
pollTimeoutMs?: number
|
||||
/** 连续查询失败多少次后暂停轮询并允许用户重试,默认 5 次。 */
|
||||
maxConsecutiveErrors?: number
|
||||
/** 是否等任务进入终态或轮询中断后再显示弹窗,默认 false。 */
|
||||
showDialogOnFinished?: boolean
|
||||
/** 任务执行期间展示的等待提示;为空时不额外展示。 */
|
||||
waitingMessage?: string
|
||||
/** 成功终态存在结果文件时的下载按钮文案,默认“下载结果”。 */
|
||||
downloadButtonText?: string
|
||||
/** 任务成功后由业务组件展示的覆盖文案。 */
|
||||
successMessage?: string
|
||||
/** 任务失败后由业务组件展示的覆盖文案。 */
|
||||
failureMessage?: string
|
||||
}
|
||||
|
||||
/** 通用异步任务进度查询参数。 */
|
||||
export interface AsyncTaskProgressQuery {
|
||||
/** 业务 Redis Key 命名空间。 */
|
||||
redisKey: string
|
||||
/** 提交接口返回的任务 ID。 */
|
||||
taskId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前登录用户创建的异步任务进度。
|
||||
*
|
||||
* @param progressUrl 业务配置的通用进度接口地址
|
||||
* @param query Redis Key 命名空间和任务 ID
|
||||
* @returns 包含当前任务进度的统一响应
|
||||
*/
|
||||
export const getAsyncTaskProgress = (progressUrl: string, query: AsyncTaskProgressQuery) => {
|
||||
return request({
|
||||
url: progressUrl,
|
||||
method: "get",
|
||||
params: query,
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交导出快照;仅透传原有排序参数,分页由服务端导出批次控制。 */
|
||||
export const submitExportTask = (url: string, query: Record<string, unknown>) => request({
|
||||
url, method: "post", data: query,
|
||||
params: { ascs: query.ascs, descs: query.descs },
|
||||
})
|
||||
+60
-41
@@ -1,3 +1,4 @@
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
import request from "/@/utils/request"
|
||||
|
||||
/** 订单详情中的单张识别证据,字段来自 HardOrderEvidenceImageVo。 */
|
||||
@@ -54,58 +55,73 @@ export interface HardOrderDetail {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 订单列表统计查询条件;字段与订单分页筛选保持一致。 */
|
||||
export type HardOrderStatisticsQuery = Record<string, any>
|
||||
/** 与订单列表一致的筛选条件,不含分页、排序和勾选记录。 */
|
||||
export type HardOrderStatisticsQuery = Record<string, unknown>
|
||||
|
||||
/** 订单按餐段汇总的统计,金额单位均为分。 */
|
||||
export interface HardOrderMealStatistics {
|
||||
/** 餐段字典编码;历史订单未记录时为空。 */
|
||||
hourType?: string | null
|
||||
/** 订单数。 */
|
||||
orderCount?: number
|
||||
/** 购物车商品份数。 */
|
||||
productCount?: number
|
||||
/** 商品总价。 */
|
||||
totalPrice?: number
|
||||
/** 额外支付。 */
|
||||
extraPrice?: number
|
||||
/** 配送费。 */
|
||||
deliveryPrice?: number
|
||||
/** 实际支付。 */
|
||||
payPrice?: number
|
||||
/** 已退款金额。 */
|
||||
refundPrice?: number
|
||||
/** 订单实际金额,即实际支付减已退款。 */
|
||||
actualPrice?: number
|
||||
}
|
||||
|
||||
/** 订单统计通用汇总,金额单位均为分。 */
|
||||
/** 金额为整数分字符串;计数为字符串,避免累计精度丢失。 */
|
||||
export interface HardOrderStatisticsSummary {
|
||||
orderCount?: number
|
||||
productCount?: number
|
||||
totalPrice?: number
|
||||
extraPrice?: number
|
||||
deliveryPrice?: number
|
||||
payPrice?: number
|
||||
refundPrice?: number
|
||||
actualPrice?: number
|
||||
meals?: HardOrderMealStatistics[]
|
||||
orderCount: string
|
||||
totalPrice: string
|
||||
extraPrice: string
|
||||
couponPrice: string
|
||||
payPrice: string
|
||||
refundPrice: string
|
||||
netPrice: string
|
||||
}
|
||||
|
||||
/** 订单按预计使用日期拆分的统计。 */
|
||||
export interface HardOrderDailyStatistics extends HardOrderStatisticsSummary {
|
||||
date?: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export interface HardOrderPayTypeStatistics extends HardOrderStatisticsSummary {
|
||||
payType: string | null
|
||||
}
|
||||
|
||||
export interface HardOrderMerchantStatistics extends HardOrderStatisticsSummary {
|
||||
merchantId: string | null
|
||||
merchantName: string
|
||||
}
|
||||
|
||||
export interface HardOrderStatisticsData {
|
||||
summary: HardOrderStatisticsSummary
|
||||
daily: HardOrderDailyStatistics[]
|
||||
payTypes: HardOrderPayTypeStatistics[]
|
||||
withoutPayTime: HardOrderStatisticsSummary
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
/** 订单统计接口统一响应。 */
|
||||
export interface HardOrderStatisticsResponse {
|
||||
data: {
|
||||
total?: HardOrderStatisticsSummary
|
||||
daily?: HardOrderDailyStatistics[]
|
||||
}
|
||||
data: HardOrderStatisticsData
|
||||
msg?: string
|
||||
}
|
||||
|
||||
export interface HardOrderMerchantStatisticsPage {
|
||||
records: HardOrderMerchantStatistics[]
|
||||
current: number
|
||||
size: number
|
||||
hasMore: boolean
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
export function statistics(query: HardOrderStatisticsQuery) {
|
||||
return request<HardOrderStatisticsData, HardOrderStatisticsResponse>({
|
||||
url: "/hard/hard-order/statistics",
|
||||
method: "post",
|
||||
data: query,
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
export function merchantStatistics(query: HardOrderStatisticsQuery, current = 1, size = 20) {
|
||||
return request<HardOrderMerchantStatisticsPage, { data: HardOrderMerchantStatisticsPage }>({
|
||||
url: "/hard/hard-order/statistics/merchants",
|
||||
method: "post",
|
||||
params: { current, size },
|
||||
data: query,
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 离线订单手动支付
|
||||
*/
|
||||
@@ -207,3 +223,6 @@ export function putObj(obj?: Object) {
|
||||
data: obj,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-order/export-async", query)
|
||||
@@ -0,0 +1,87 @@
|
||||
import request from "/@/utils/request"
|
||||
|
||||
export type ThirdOrderId = string | number
|
||||
|
||||
/** 第三方应用支付写入的硬件底单,所有金额均为分。 */
|
||||
export interface ThirdOrder {
|
||||
id: ThirdOrderId
|
||||
orderNo?: string
|
||||
orderType: string
|
||||
orderStatus: string
|
||||
payType?: string
|
||||
userId?: ThirdOrderId
|
||||
username?: string
|
||||
userMobile?: string
|
||||
userAvatar?: string
|
||||
userDeptName?: string
|
||||
merchantId?: ThirdOrderId
|
||||
merchantName?: string
|
||||
merchantType?: string
|
||||
walletId?: ThirdOrderId
|
||||
walletTypeUniqueCode?: string
|
||||
parentCarId?: ThirdOrderId
|
||||
productDesc?: string
|
||||
totalPrice?: number
|
||||
extraPrice?: number
|
||||
payPrice?: number
|
||||
refundPrice?: number
|
||||
refundStatus?: string
|
||||
payRefundId?: ThirdOrderId
|
||||
afterSaleNo?: string
|
||||
createTime?: string
|
||||
payTime?: string
|
||||
refundTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export interface ThirdOrderProduct {
|
||||
id: ThirdOrderId
|
||||
productName?: string
|
||||
productPrice?: number
|
||||
productCount?: number
|
||||
totalPrice?: number
|
||||
}
|
||||
|
||||
export interface ThirdOrderRefund {
|
||||
id: ThirdOrderId
|
||||
no?: string
|
||||
status: string
|
||||
refundPrice?: number
|
||||
applyReason?: string
|
||||
applyDescription?: string
|
||||
auditReason?: string
|
||||
createTime?: string
|
||||
auditTime?: string
|
||||
refundTime?: string
|
||||
}
|
||||
|
||||
export interface ThirdOrderDetail {
|
||||
order: ThirdOrder
|
||||
products: ThirdOrderProduct[]
|
||||
refunds: ThirdOrderRefund[]
|
||||
}
|
||||
|
||||
export interface ThirdOrderRefundParams {
|
||||
orderId: ThirdOrderId
|
||||
refundPrice: number
|
||||
requestNo: string
|
||||
applyReason: string
|
||||
applyDescription?: string
|
||||
}
|
||||
|
||||
export function page(query: Record<string, unknown>) {
|
||||
return request({
|
||||
url: "/hard/hard-third-order/page",
|
||||
method: "post",
|
||||
params: { current: query.current, size: query.size },
|
||||
data: query,
|
||||
})
|
||||
}
|
||||
|
||||
export function detail(id: ThirdOrderId) {
|
||||
return request({ url: `/hard/hard-third-order/${id}`, method: "get" })
|
||||
}
|
||||
|
||||
export function refund(data: ThirdOrderRefundParams) {
|
||||
return request({ url: "/hard/hard-third-order/refund", method: "post", data })
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
import request from "/@/utils/request"
|
||||
|
||||
/**
|
||||
@@ -126,3 +127,12 @@ export function pageListCsByMonth(query?: any) {
|
||||
data: query,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitMerchantExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-cs-async", query)
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitMerchantMonthExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-cs-by-month-async", query)
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitDepartmentExport = (query: Record<string, unknown>) => submitExportTask("/hard/hard-total/export-dept-async", query)
|
||||
@@ -1,4 +1,11 @@
|
||||
import request from "/@/utils/request"
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
|
||||
/** 按当前筛选条件提交账户余额后台分批导出。 */
|
||||
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-async", query)
|
||||
|
||||
/** 按所选月份与原筛选条件提交钱包历史余额分批导出。 */
|
||||
export const submitQbExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-qb-async", query)
|
||||
|
||||
/**
|
||||
* 不活跃用户统计分页
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
import request from "/@/utils/request"
|
||||
|
||||
export function saveTopup(data?: Object) {
|
||||
@@ -87,3 +88,6 @@ export function validateError(params: any) {
|
||||
params: params,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-recharge-apply/export-async", query)
|
||||
@@ -1,3 +1,4 @@
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
import request from "/@/utils/request"
|
||||
|
||||
export function pageJoin(query: any) {
|
||||
@@ -58,3 +59,6 @@ export function recover(params?: Object) {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-recharge-import/export-async", query)
|
||||
@@ -1,3 +1,4 @@
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
import request from "/@/utils/request"
|
||||
|
||||
export function invoiceBack(query: any) {
|
||||
@@ -107,3 +108,6 @@ export function putObj(obj?: Object) {
|
||||
data: obj,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/walletRechargeInvoice/export-async", query)
|
||||
@@ -1,3 +1,4 @@
|
||||
import { submitExportTask } from "/@/api/asyncTask"
|
||||
import request from "/@/utils/request"
|
||||
|
||||
/**
|
||||
@@ -238,3 +239,18 @@ export function putObj(obj?: Object) {
|
||||
data: obj,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitThExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-th-async", query)
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitTkExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-tk-async", query)
|
||||
|
||||
/** 按原筛选条件提交后台分批导出。 */
|
||||
export const submitXfExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-xf-async", query)
|
||||
|
||||
/** 按当前筛选条件提交充值流水分批导出。 */
|
||||
export const submitCzExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-cz-async", query)
|
||||
|
||||
/** 按当前筛选条件提交记账扣款流水分批导出。 */
|
||||
export const submitJzExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-transaction/export-jz-async", query)
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<el-dialog v-model="state.visible" :title="state.progress?.taskName || '导出进度'" width="520px"
|
||||
:close-on-click-modal="false" :close-on-press-escape="false" :show-close="canClose" draggable>
|
||||
<div class="space-y-4" aria-live="polite">
|
||||
<el-progress :percentage="state.progress?.percentage || 0" :status="progressStatus" :stroke-width="14" />
|
||||
<div class="flex justify-between text-sm gap-4">
|
||||
<span>{{ state.progress?.message || "正在提交导出任务…" }}</span>
|
||||
<span class="shrink-0" v-if="state.progress?.total">{{ state.progress?.completed || 0 }} / {{ state.progress.total }} 步</span>
|
||||
</div>
|
||||
<el-alert v-if="state.polling && config.waitingMessage" :title="config.waitingMessage" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="state.queryError" :title="state.queryError" type="warning" :closable="false" show-icon />
|
||||
</div>
|
||||
<template #footer v-if="canClose">
|
||||
<el-button v-if="state.queryError && activeContext" type="primary" @click="retry">重新查询</el-button>
|
||||
<el-button v-if="canDownload" type="primary" @click="downloadResult">{{ config.downloadButtonText || "下载 Excel" }}</el-button>
|
||||
<el-button @click="state.visible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="async-task-progress">
|
||||
import { computed, reactive, onBeforeUnmount } from "vue"
|
||||
import type { AsyncTaskPollingConfig, AsyncTaskProgress } from "/@/api/asyncTask"
|
||||
import { getAsyncTaskProgress } from "/@/api/asyncTask"
|
||||
import { downloadExportFile } from "/@/utils/exportDownload"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
|
||||
const props = defineProps<{ config: AsyncTaskPollingConfig }>()
|
||||
const emit = defineEmits<{ (event: "success" | "failure", progress: AsyncTaskProgress): void }>()
|
||||
/** 轮询上下文在启动时复制,页面切换商户标签不会改变已有任务。 */
|
||||
let activeContext: (Required<Pick<AsyncTaskPollingConfig, "progressUrl" | "redisKey" | "pollIntervalMs" | "pollTimeoutMs" | "maxConsecutiveErrors">> & { taskId: string }) | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let startedAt = 0
|
||||
let errors = 0
|
||||
let version = 0
|
||||
let disposed = false
|
||||
let terminalEmitted = false
|
||||
const state = reactive({ visible: false, submitting: false, polling: false, progress: null as AsyncTaskProgress | null, queryError: "" })
|
||||
const canClose = computed(() => !state.submitting && !state.polling)
|
||||
const canDownload = computed(() => Boolean(state.progress?.finished && state.progress.success && state.progress.resultFileUrl))
|
||||
const progressStatus = computed(() => state.progress?.finished ? (state.progress.success ? "success" : "exception") : undefined)
|
||||
|
||||
/** 停止下一次查询;版本号同时隔离已经发出而尚未返回的请求。 */
|
||||
const stopPolling = () => {
|
||||
state.polling = false
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
version += 1
|
||||
}
|
||||
const pause = (message: string) => {
|
||||
stopPolling()
|
||||
state.queryError = message
|
||||
state.visible = true
|
||||
}
|
||||
/** 递归定时器保证查询不重叠,短时网络错误不会改变后端任务状态。 */
|
||||
const poll = async () => {
|
||||
if (disposed || !state.polling || !activeContext) return
|
||||
if (Date.now() - startedAt >= activeContext.pollTimeoutMs) {
|
||||
pause("进度查询已超过 2 小时,后台任务可能仍在运行,可重新查询")
|
||||
return
|
||||
}
|
||||
const currentVersion = version
|
||||
try {
|
||||
const result = await getAsyncTaskProgress(activeContext.progressUrl, { taskId: activeContext.taskId, redisKey: activeContext.redisKey })
|
||||
if (disposed || currentVersion !== version) return
|
||||
if (!result?.data?.taskId) throw new Error("进度接口未返回任务数据")
|
||||
state.progress = result.data
|
||||
state.queryError = ""
|
||||
errors = 0
|
||||
if (state.progress?.finished) {
|
||||
stopPolling()
|
||||
if (!terminalEmitted) {
|
||||
terminalEmitted = true
|
||||
emit(state.progress.success ? "success" : "failure", state.progress)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (disposed || currentVersion !== version) return
|
||||
if (++errors >= activeContext.maxConsecutiveErrors) {
|
||||
pause(error?.msg || error?.message || "获取进度失败,后台任务不会因此取消,请重新查询")
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!disposed && state.polling) timer = setTimeout(poll, activeContext.pollIntervalMs)
|
||||
}
|
||||
|
||||
/** 提交前立即显示反馈,避免慢提交期间重复点击。 */
|
||||
const startSubmitting = (taskName: string) => {
|
||||
stopPolling()
|
||||
activeContext = null
|
||||
state.progress = { taskName, percentage: 0, message: "正在提交导出任务…" } as AsyncTaskProgress
|
||||
state.queryError = ""
|
||||
state.submitting = true
|
||||
state.visible = true
|
||||
}
|
||||
const submissionFailed = (message: string) => {
|
||||
stopPolling()
|
||||
state.submitting = false
|
||||
state.queryError = message
|
||||
state.progress = { ...state.progress, status: "FAILED", finished: true, success: false, message } as AsyncTaskProgress
|
||||
activeContext = null
|
||||
}
|
||||
/** 接管提交响应,只在文件就绪终态发出一次成功事件。 */
|
||||
const start = (progress: AsyncTaskProgress) => {
|
||||
stopPolling()
|
||||
if (!progress.taskId || !props.config.progressUrl) throw new Error("导出任务进度配置不完整")
|
||||
activeContext = {
|
||||
progressUrl: props.config.progressUrl,
|
||||
redisKey: progress.redisKey || props.config.redisKey,
|
||||
taskId: progress.taskId,
|
||||
pollIntervalMs: Math.max(500, props.config.pollIntervalMs || 1000),
|
||||
pollTimeoutMs: props.config.pollTimeoutMs || 2 * 60 * 60 * 1000,
|
||||
maxConsecutiveErrors: props.config.maxConsecutiveErrors || 5,
|
||||
}
|
||||
state.submitting = false
|
||||
state.progress = { ...progress }
|
||||
state.queryError = ""
|
||||
state.visible = true
|
||||
state.polling = true
|
||||
errors = 0
|
||||
terminalEmitted = false
|
||||
startedAt = Date.now()
|
||||
void poll()
|
||||
}
|
||||
/** 中断后只查询原 taskId,保持导出提交互斥状态。 */
|
||||
const retry = () => {
|
||||
if (!activeContext || state.polling) return
|
||||
state.queryError = ""
|
||||
errors = 0
|
||||
startedAt = Date.now()
|
||||
state.polling = true
|
||||
void poll()
|
||||
}
|
||||
const show = () => { state.visible = true }
|
||||
const downloadResult = () => {
|
||||
if (!canDownload.value) return
|
||||
try { downloadExportFile(state.progress!.resultFileUrl!, state.progress!.resultFileName || "导出结果.xlsx") }
|
||||
catch (error: any) { useMessage().error(error?.message || "下载导出文件失败") }
|
||||
}
|
||||
onBeforeUnmount(() => { disposed = true; stopPolling() })
|
||||
defineExpose({ start, startSubmitting, submissionFailed, show })
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ref, toRaw, unref } from "vue"
|
||||
import type { AsyncTaskPollingConfig, AsyncTaskProgress } from "/@/api/asyncTask"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { downloadExportFile } from "/@/utils/exportDownload"
|
||||
|
||||
/** 页面只声明业务接口、名称及命名空间,任务状态由公共组件管理。 */
|
||||
interface BatchExportOptions {
|
||||
progressUrl: string
|
||||
redisKey: string
|
||||
taskName: string
|
||||
fileName: string
|
||||
submitTask: (query: Record<string, unknown>) => Promise<any>
|
||||
}
|
||||
|
||||
/** 固定筛选快照,保留排序;当前列表页码和页大小不限制全量导出。 */
|
||||
export const snapshotExportQuery = (query: Record<string, unknown>) => {
|
||||
const snapshot = JSON.parse(JSON.stringify(toRaw(unref(query))))
|
||||
delete snapshot.current
|
||||
delete snapshot.size
|
||||
return snapshot as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 复用任务提交互斥、进度跟踪及下载。查询中断后继续跟踪原任务,不重新提交。 */
|
||||
export const useBatchExport = (options: BatchExportOptions) => {
|
||||
const exportProgressRef = ref<InstanceType<typeof AsyncTaskProgressComponent>>()
|
||||
const exportSubmitting = ref(false)
|
||||
const exportTaskConfig: AsyncTaskPollingConfig = {
|
||||
progressUrl: options.progressUrl,
|
||||
redisKey: options.redisKey,
|
||||
waitingMessage: "正在后台分批生成文件,进度达到 100% 表示文件已就绪",
|
||||
downloadButtonText: "重新下载 Excel",
|
||||
}
|
||||
const submitExport = async (query: Record<string, unknown>) => {
|
||||
if (exportSubmitting.value) {
|
||||
exportProgressRef.value?.show()
|
||||
return
|
||||
}
|
||||
exportSubmitting.value = true
|
||||
exportProgressRef.value?.startSubmitting(options.taskName)
|
||||
try {
|
||||
const response = await options.submitTask(snapshotExportQuery(query))
|
||||
if (!response?.data?.taskId) throw new Error("导出任务响应缺少任务 ID")
|
||||
exportProgressRef.value?.start(response.data)
|
||||
} catch (error: any) {
|
||||
exportSubmitting.value = false
|
||||
const message = error?.msg || error?.message || "导出任务提交失败,请重试"
|
||||
exportProgressRef.value?.submissionFailed(message)
|
||||
useMessage().error(message)
|
||||
}
|
||||
}
|
||||
const handleExportSuccess = (progress: AsyncTaskProgress) => {
|
||||
exportSubmitting.value = false
|
||||
try {
|
||||
downloadExportFile(progress.resultFileUrl || "", progress.resultFileName || options.fileName || "导出结果.xlsx")
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.message || "下载导出文件失败")
|
||||
}
|
||||
}
|
||||
const handleExportFailure = (progress: AsyncTaskProgress) => {
|
||||
exportSubmitting.value = false
|
||||
useMessage().error(progress.message || "导出任务失败")
|
||||
}
|
||||
return { exportProgressRef, exportTaskConfig, exportSubmitting, submitExport, handleExportSuccess, handleExportFailure }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import other from "/@/utils/other"
|
||||
|
||||
/** 文件中心结果由浏览器原生下载,避免 Axios Blob 将整个大文件读进 JS 内存。 */
|
||||
export const downloadExportFile = (fileUrl: string, fileName: string) => {
|
||||
if (!fileUrl) throw new Error("导出完成,但未返回文件下载地址")
|
||||
const url = /^https?:\/\//i.test(fileUrl)
|
||||
? fileUrl
|
||||
: `${String(import.meta.env.VITE_API_URL || "").replace(/\/$/, "")}${other.adaptationUrl(fileUrl)}`
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.style.display = "none"
|
||||
link.download = (fileName || "导出结果.xlsx").replace(/[<>:"/\\|?*]/g, "_")
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
@@ -82,6 +82,8 @@ const handleResponse = (response: AxiosResponse<any>) => {
|
||||
* 添加 Axios 的响应拦截器,用于全局响应结果处理
|
||||
*/
|
||||
service.interceptors.response.use(handleResponse, (error) => {
|
||||
// 超时和网络中断没有 response,保留原错误供页面展示和重试。
|
||||
if (!error.response) return Promise.reject(error)
|
||||
const status = Number(error.response.status) || 200
|
||||
if (status === 423) {
|
||||
return Promise.reject({ msg: '"演示环境,仅供预览"' })
|
||||
|
||||
@@ -224,10 +224,16 @@
|
||||
|
||||
<!-- 批量审批对话框 -->
|
||||
<batch-deal ref="batchDealRef" @task-submit-event="taskSubmitEvent" />
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="systemUser" setup>
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitUserExport as submitBatchExport } from "/@/api/admin/user"
|
||||
|
||||
import { delObj, pageList, putObj } from "/@/api/admin/user"
|
||||
import { putGlobalFace } from "/@/api/admin/userFace"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
@@ -274,7 +280,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
},
|
||||
pageList: pageList,
|
||||
})
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
const { user_staffing_type } = useDict("user_staffing_type")
|
||||
|
||||
@@ -325,14 +331,17 @@ const taskSubmitEvent = () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/admin/async-task/progress", redisKey: "admin:export:user",
|
||||
taskName: "用户管理", fileName: "用户管理.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => {
|
||||
const _data = { ...state.queryForm }
|
||||
if (_data.role && !Array.isArray(_data.role)) {
|
||||
_data.role = [_data.role]
|
||||
} else {
|
||||
_data.role = []
|
||||
}
|
||||
postDownBlobFile("/admin/user/export", Object.assign(_data, { ids: selectObjs.value }), "用户管理.xlsx")
|
||||
const query = { ...state.queryForm }
|
||||
// 保留原导出角色参数的转换规则及勾选元数据。
|
||||
query.role = query.role && !Array.isArray(query.role) ? [query.role] : []
|
||||
return submitExport({ ...query, ids: [...selectObjs.value] })
|
||||
}
|
||||
|
||||
// 是否可以多选
|
||||
|
||||
@@ -197,10 +197,16 @@
|
||||
</template>
|
||||
</DraggableDepTree>
|
||||
</div>
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="hard-dept-total">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitDepartmentExport as submitBatchExport } from "/@/api/hard/hard-total"
|
||||
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
@@ -264,12 +270,16 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile("/hard/hard-total/export-dept", Object.assign(state.queryForm), "部门交易记录导出.xlsx")
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:department",
|
||||
taskName: "部门交易记录导出", fileName: "部门交易记录导出.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
const cellClassName = ({ row, column, rowIndex }: any) => {
|
||||
if (column.property) {
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<el-drawer :model-value="modelValue" title="订单统计" direction="rtl" size="min(1120px, 100vw)"
|
||||
class="order-statistics-drawer" append-to-body :close-on-click-modal="false"
|
||||
@opened="resizeCharts" @update:model-value="changeVisible">
|
||||
<div class="statistics-heading">
|
||||
<div>
|
||||
<div>当前筛选条件的全部订单</div>
|
||||
<div class="statistics-muted">{{ rangeDescription }}</div>
|
||||
</div>
|
||||
<el-button icon="Refresh" :loading="loading" @click="refresh">重新统计</el-button>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" @tab-change="changeTab">
|
||||
<el-tab-pane label="概览" name="overview">
|
||||
<div v-loading="overviewLoading" class="statistics-content">
|
||||
<el-result v-if="overviewError" icon="error" title="统计失败" :sub-title="overviewError">
|
||||
<template #extra><el-button type="primary" @click="loadOverview">重试</el-button></template>
|
||||
</el-result>
|
||||
<template v-else-if="overview">
|
||||
<div class="statistics-updated">统计生成时间:{{ overview.generatedAt }}</div>
|
||||
<div class="statistics-cards">
|
||||
<div class="statistics-card">
|
||||
<div class="statistics-muted">订单数</div>
|
||||
<div class="statistics-value">{{ formatStatisticsCount(overview.summary.orderCount) }}<small>笔</small></div>
|
||||
</div>
|
||||
<div v-for="metric in moneyMetrics" :key="metric.key" class="statistics-card">
|
||||
<div class="statistics-muted">{{ metric.label }}</div>
|
||||
<div class="statistics-value">{{ formatStatisticsMoney(overview.summary[metric.key]) }}<small>元</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="statistics-muted statistics-note">按订单原始金额汇总,不额外排除订单状态;净额=实际支付-已退款金额。</p>
|
||||
<el-empty v-if="overview.summary.orderCount === '0'" description="当前条件下暂无订单" />
|
||||
<template v-else>
|
||||
<el-alert v-if="overview.withoutPayTime.orderCount !== '0'" type="info" :closable="false" show-icon
|
||||
:title="`无支付时间的订单共 ${formatStatisticsCount(overview.withoutPayTime.orderCount)} 笔,实际支付金额 ${formatStatisticsMoney(overview.withoutPayTime.payPrice)} 元;已计入总汇总,未归入按日趋势。`" />
|
||||
<el-card shadow="never" class="statistics-section">
|
||||
<template #header>按日支付趋势</template>
|
||||
<p v-if="overview.daily.length === 1" class="statistics-muted statistics-chart-note">仅 {{ overview.daily[0].date }} 有支付时间记录,以下展示当日金额与订单数。</p>
|
||||
<v-chart v-if="overview.daily.length && activeTab === 'overview'" ref="trendChart" class="statistics-chart"
|
||||
:style="{ height: overview.daily.length === 1 ? '240px' : '320px' }" :option="trendOption" autoresize />
|
||||
<el-empty v-else-if="!overview.daily.length" description="当前订单均无支付时间" />
|
||||
</el-card>
|
||||
<el-card shadow="never" class="statistics-section">
|
||||
<template #header>支付方式占比<span class="statistics-muted statistics-caption">按实际支付金额</span></template>
|
||||
<div class="statistics-payment">
|
||||
<v-chart v-if="hasPositivePayment && activeTab === 'overview'" ref="paymentChart" class="statistics-chart" :option="paymentOption" autoresize />
|
||||
<el-empty v-else-if="!hasPositivePayment" description="暂无正向支付金额" />
|
||||
<el-table :data="overview.payTypes" border max-height="320">
|
||||
<el-table-column label="支付方式" min-width="150">
|
||||
<template #default="{ row }">{{ payTypeName(row.payType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单数" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际支付(元)" min-width="160" align="right">
|
||||
<template #default="{ row }">{{ formatStatisticsMoney(row.payPrice) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-collapse class="statistics-section">
|
||||
<el-collapse-item title="每日金额明细(元)" name="daily">
|
||||
<el-table :data="overview.daily" border max-height="360">
|
||||
<el-table-column prop="date" label="支付日期" width="120" fixed />
|
||||
<el-table-column label="订单数" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-for="metric in moneyMetrics" :key="metric.key" :label="metric.label" min-width="140" align="right">
|
||||
<template #default="{ row }">{{ formatStatisticsMoney(row[metric.key]) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="商户消费" name="merchants" :disabled="overviewLoading">
|
||||
<div v-loading="merchantLoading" class="statistics-content">
|
||||
<el-result v-if="merchantError" icon="error" title="商户统计失败" :sub-title="merchantError">
|
||||
<template #extra><el-button type="primary" @click="loadMerchants(requestedPage)">重试</el-button></template>
|
||||
</el-result>
|
||||
<template v-else-if="merchantPage">
|
||||
<div class="statistics-updated">统计生成时间:{{ merchantPage.generatedAt }}</div>
|
||||
<el-empty v-if="!merchantPage.records.length && merchantPage.current === 1" description="当前条件下暂无商户消费" />
|
||||
<template v-else>
|
||||
<el-card v-if="ranking.length" shadow="never" class="statistics-section">
|
||||
<template #header>商户消费前十名<span class="statistics-muted statistics-caption">按实际支付金额</span></template>
|
||||
<p class="statistics-muted statistics-chart-note">展示 {{ ranking.length }} 个商户<span v-if="ranking.every(row => Number(row.payPrice) === 0)">,实际支付金额均为 0 元</span></p>
|
||||
<v-chart v-if="activeTab === 'merchants'" ref="merchantChart" class="statistics-chart"
|
||||
:style="{ height: `${Math.max(120, ranking.length * 34 + 58)}px` }" :option="merchantOption" autoresize />
|
||||
</el-card>
|
||||
<el-table :data="merchantPage.records" border max-height="440">
|
||||
<el-table-column label="商户" min-width="210" fixed show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.merchantName }}</div>
|
||||
<small v-if="row.merchantId" class="statistics-muted">{{ row.merchantId }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单数" min-width="100" align="right">
|
||||
<template #default="{ row }">{{ formatStatisticsCount(row.orderCount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-for="metric in moneyMetrics" :key="metric.key" :label="`${metric.label}(元)`" min-width="155" align="right">
|
||||
<template #default="{ row }">{{ formatStatisticsMoney(row[metric.key]) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="statistics-pagination">
|
||||
<el-button :disabled="merchantLoading || merchantPage.current <= 1" @click="loadMerchants(merchantPage.current - 1)">上一页</el-button>
|
||||
<span>第 {{ merchantPage.current }} 页 · 每页 {{ merchantPage.size }} 条</span>
|
||||
<el-button :disabled="merchantLoading || !merchantPage.hasMore" @click="loadMerchants(merchantPage.current + 1)">下一页</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="hardOrderStatisticsDrawer">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
import VChart from "vue-echarts"
|
||||
import { use } from "echarts/core"
|
||||
import { BarChart, LineChart, PieChart } from "echarts/charts"
|
||||
import { AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent } from "echarts/components"
|
||||
import { CanvasRenderer } from "echarts/renderers"
|
||||
import type { EChartsOption } from "echarts"
|
||||
import { merchantStatistics, statistics } from "/@/api/hard/hard-order"
|
||||
import type { HardOrderMerchantStatistics, HardOrderMerchantStatisticsPage, HardOrderStatisticsData, HardOrderStatisticsQuery, HardOrderStatisticsSummary } from "/@/api/hard/hard-order"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { escapeChartText, formatStatisticsCount, formatStatisticsMoney } from "./utils"
|
||||
|
||||
use([BarChart, LineChart, PieChart, AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; query: HardOrderStatisticsQuery }>()
|
||||
const emit = defineEmits<{ (event: "update:modelValue", value: boolean): void }>()
|
||||
const { hard_pay_type } = useDict("hard_pay_type")
|
||||
const activeTab = ref("overview")
|
||||
const overview = ref<HardOrderStatisticsData>()
|
||||
const merchantPage = ref<HardOrderMerchantStatisticsPage>()
|
||||
const ranking = ref<HardOrderMerchantStatistics[]>([])
|
||||
const overviewLoading = ref(false)
|
||||
const merchantLoading = ref(false)
|
||||
const overviewError = ref("")
|
||||
const merchantError = ref("")
|
||||
const requestedPage = ref(1)
|
||||
const trendChart = ref<InstanceType<typeof VChart>>()
|
||||
const paymentChart = ref<InstanceType<typeof VChart>>()
|
||||
const merchantChart = ref<InstanceType<typeof VChart>>()
|
||||
const loading = computed(() => overviewLoading.value || merchantLoading.value)
|
||||
let alive = true
|
||||
let overviewRequest = 0
|
||||
let merchantRequest = 0
|
||||
|
||||
type MoneyKey = Exclude<keyof HardOrderStatisticsSummary, "orderCount">
|
||||
const moneyMetrics: { key: MoneyKey; label: string }[] = [
|
||||
{ key: "totalPrice", label: "商品总价" }, { key: "extraPrice", label: "额外支付" },
|
||||
{ key: "couponPrice", label: "优惠金额" }, { key: "payPrice", label: "实际支付" },
|
||||
{ key: "refundPrice", label: "已退款金额" }, { key: "netPrice", label: "净额" },
|
||||
]
|
||||
|
||||
const rangeDescription = computed(() => [
|
||||
["createTimeList", "创建时间"], ["payTimeList", "支付时间"],
|
||||
].flatMap(([key, label]) => {
|
||||
const value = props.query[key]
|
||||
return Array.isArray(value) && value.length === 2 ? [`${label}:${value[0]} 至 ${value[1]}`] : []
|
||||
}).join(";"))
|
||||
|
||||
function payTypeName(code: string | null) {
|
||||
return hard_pay_type.value.find((item: { value: string; label: string }) => String(item.value) === code)?.label
|
||||
|| (code ? `未知方式(${code})` : "未记录支付方式")
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const detail = error as { code?: string; msg?: string; message?: string }
|
||||
if (detail?.code === "ECONNABORTED" || detail?.code === "ETIMEDOUT") return "统计查询超时,请缩小查询范围后重试"
|
||||
return detail?.msg || detail?.message || "统计查询失败,请重试或缩小查询范围"
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
if (overviewLoading.value) return
|
||||
const request = ++overviewRequest
|
||||
overviewLoading.value = true
|
||||
overviewError.value = ""
|
||||
try {
|
||||
const response = await statistics(props.query)
|
||||
if (alive && request === overviewRequest) overview.value = response.data
|
||||
} catch (error) {
|
||||
if (alive && request === overviewRequest) overviewError.value = errorMessage(error)
|
||||
} finally {
|
||||
if (alive && request === overviewRequest) overviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMerchants(current = 1) {
|
||||
if (merchantLoading.value) return
|
||||
const request = ++merchantRequest
|
||||
requestedPage.value = current
|
||||
merchantLoading.value = true
|
||||
merchantError.value = ""
|
||||
try {
|
||||
const response = await merchantStatistics(props.query, current)
|
||||
if (alive && request === merchantRequest) {
|
||||
merchantPage.value = response.data
|
||||
if (current === 1) ranking.value = response.data.records.slice(0, 10)
|
||||
}
|
||||
} catch (error) {
|
||||
if (alive && request === merchantRequest) merchantError.value = errorMessage(error)
|
||||
} finally {
|
||||
if (alive && request === merchantRequest) merchantLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeTab(name: string | number) {
|
||||
if (name === "merchants" && !merchantPage.value && !merchantError.value) void loadMerchants()
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (loading.value) return
|
||||
if (activeTab.value === "overview") void loadOverview()
|
||||
else void loadMerchants(1)
|
||||
}
|
||||
|
||||
function changeVisible(value: boolean) {
|
||||
if (!value) {
|
||||
alive = false
|
||||
overviewRequest++
|
||||
merchantRequest++
|
||||
}
|
||||
emit("update:modelValue", value)
|
||||
}
|
||||
|
||||
async function resizeCharts() {
|
||||
await nextTick()
|
||||
if (!alive || !props.modelValue) return
|
||||
const charts = activeTab.value === "overview" ? [trendChart.value, paymentChart.value] : [merchantChart.value]
|
||||
charts.forEach(chart => chart?.resize())
|
||||
}
|
||||
|
||||
const trendOption = computed<EChartsOption>(() => {
|
||||
const rows = overview.value?.daily ?? []
|
||||
const singleDay = rows.length === 1
|
||||
const hasZoom = rows.length > 14
|
||||
return {
|
||||
aria: { enabled: true },
|
||||
color: ["#409eff", "#67c23a"],
|
||||
legend: { data: ["实际支付", "订单数"] },
|
||||
grid: { left: 12, right: 12, top: 48, bottom: hasZoom ? 52 : 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter: (params: any) => {
|
||||
const row = rows[params[0]?.dataIndex]
|
||||
return row ? `${escapeChartText(row.date)}<br/>实际支付:${formatStatisticsMoney(row.payPrice)} 元<br/>订单数:${formatStatisticsCount(row.orderCount)} 笔` : ""
|
||||
},
|
||||
},
|
||||
xAxis: singleDay ? { type: "category", data: rows.map(row => row.date) }
|
||||
: { type: "time", minInterval: 24 * 60 * 60 * 1000, axisLabel: { formatter: "{MM}-{dd}", hideOverlap: true } },
|
||||
yAxis: [{ type: "value", name: "金额(元)" }, { type: "value", name: "订单数", minInterval: 1 }],
|
||||
dataZoom: hasZoom ? [{ type: "inside" }, { type: "slider", height: 18, bottom: 8 }] : [],
|
||||
series: [
|
||||
{
|
||||
name: "实际支付", type: singleDay ? "bar" : "line", barMaxWidth: 36,
|
||||
data: rows.map(row => singleDay ? Number(row.payPrice) / 100 : [row.date, Number(row.payPrice) / 100]),
|
||||
showSymbol: rows.length < 32, symbolSize: 7,
|
||||
label: { show: singleDay, position: "top", formatter: () => `${formatStatisticsMoney(rows[0]?.payPrice)} 元` },
|
||||
},
|
||||
{
|
||||
name: "订单数", type: singleDay ? "bar" : "line", yAxisIndex: 1, barMaxWidth: 36,
|
||||
lineStyle: { type: "dashed" }, data: rows.map(row => singleDay ? Number(row.orderCount) : [row.date, Number(row.orderCount)]),
|
||||
showSymbol: rows.length < 32, symbolSize: 7,
|
||||
label: { show: singleDay, position: "top", formatter: () => `${formatStatisticsCount(rows[0]?.orderCount ?? '0')} 笔` },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const hasPositivePayment = computed(() => overview.value?.payTypes.some(row => Number(row.payPrice) > 0))
|
||||
const paymentOption = computed<EChartsOption>(() => ({
|
||||
aria: { enabled: true },
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: any) => `${escapeChartText(params.name)}<br/>实际支付:${formatStatisticsMoney(params.data.rawAmount)} 元<br/>订单数:${formatStatisticsCount(params.data.orderCount)} 笔<br/>占比:${params.percent}%`,
|
||||
},
|
||||
legend: { type: "scroll", bottom: 0 },
|
||||
series: [{
|
||||
type: "pie", radius: ["40%", "65%"], center: ["50%", "44%"],
|
||||
label: { formatter: "{b}\n{d}%" },
|
||||
data: (overview.value?.payTypes ?? []).filter(row => Number(row.payPrice) > 0).map(row => ({
|
||||
name: payTypeName(row.payType), value: Number(row.payPrice) / 100, rawAmount: row.payPrice, orderCount: row.orderCount,
|
||||
})),
|
||||
}],
|
||||
}))
|
||||
|
||||
const merchantOption = computed<EChartsOption>(() => ({
|
||||
aria: { enabled: true },
|
||||
grid: { left: 12, right: 32, top: 12, bottom: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: (params: any) => `${escapeChartText(params.name)}<br/>实际支付:${formatStatisticsMoney(params.data.rawAmount)} 元`,
|
||||
},
|
||||
color: ["#409eff"],
|
||||
// 商户名称可能相同,以位置作为类目并在标签中显示名称,避免同名商户合并。
|
||||
xAxis: { type: "value", name: "元" },
|
||||
yAxis: { type: "category", inverse: true, data: ranking.value.map((_, index) => String(index)), axisLabel: {
|
||||
width: 120, overflow: "truncate", formatter: (value: string) => ranking.value[Number(value)]?.merchantName ?? "",
|
||||
} },
|
||||
series: [{ type: "bar", barMaxWidth: 26, data: ranking.value.map((row, index) => ({
|
||||
name: row.merchantName, value: [Number(row.payPrice) / 100, index], rawAmount: row.payPrice,
|
||||
label: Number(row.payPrice) === 0 ? { show: true, position: "right", formatter: "0.00" } : { show: false },
|
||||
})), encode: { x: 0, y: 1 } }],
|
||||
}))
|
||||
|
||||
// 只在当前标签可见时挂载图表;抽屉动画结束、数据更新与切换标签后重新测量容器。
|
||||
watch([activeTab, overview, merchantPage], resizeCharts, { flush: "post" })
|
||||
onMounted(() => void loadOverview())
|
||||
onBeforeUnmount(() => { alive = false; overviewRequest++; merchantRequest++ })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:global(.order-statistics-drawer .el-drawer__header) { margin-bottom: 0; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
:global(.order-statistics-drawer .el-drawer__body) { min-width: 0; overflow: auto; }
|
||||
.statistics-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
.statistics-muted { color: var(--el-text-color-secondary); font-size: 13px; }
|
||||
.statistics-heading .statistics-muted { margin-top: 6px; line-height: 1.6; }
|
||||
.statistics-content { min-height: 220px; }
|
||||
.statistics-updated { margin-bottom: 14px; text-align: right; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.statistics-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.statistics-card { padding: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 6px; background: var(--el-fill-color-blank); }
|
||||
.statistics-value { margin-top: 10px; font-size: 22px; font-weight: 600; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.statistics-value small { margin-left: 6px; font-size: 12px; font-weight: normal; color: var(--el-text-color-secondary); }
|
||||
.statistics-note { margin: 12px 0 18px; }
|
||||
.statistics-section { margin-top: 16px; }
|
||||
.statistics-caption { margin-left: 12px; }
|
||||
.statistics-chart { width: 100%; height: 320px; }
|
||||
.statistics-chart-note { margin: 0 0 12px; }
|
||||
.statistics-payment { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: 20px; }
|
||||
.statistics-pagination { display: flex; align-items: center; justify-content: flex-end; gap: 16px; margin-top: 16px; }
|
||||
@media (max-width: 900px) {
|
||||
.statistics-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.statistics-payment { grid-template-columns: minmax(0, 1fr); }
|
||||
.statistics-heading { align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
import dayjs from "dayjs"
|
||||
import type { HardOrderStatisticsQuery } from "/@/api/hard/hard-order"
|
||||
|
||||
const ignoredFields = new Set(["current", "size", "descs", "ascs", "ids", "selectObjs", "tenantId", "mchTypeCodes"])
|
||||
|
||||
/** 在点击时复制表单,避免分页、导出勾选或后续表单编辑污染统计。 */
|
||||
export function snapshotStatisticsQuery(form: HardOrderStatisticsQuery): HardOrderStatisticsQuery {
|
||||
const query = JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(form).filter(([key]) => !ignoredFields.has(key)))))
|
||||
let hasTimeRange = false
|
||||
for (const [key, label] of [["createTimeList", "创建时间"], ["payTimeList", "支付时间"]]) {
|
||||
const range = query[key]
|
||||
if (range == null || (Array.isArray(range) && range.length === 0)) continue
|
||||
if (!Array.isArray(range) || range.length !== 2 || !range[0] || !range[1]) {
|
||||
throw new Error(`${label}需填写完整`)
|
||||
}
|
||||
const start = dayjs(range[0])
|
||||
const end = dayjs(range[1])
|
||||
if (!start.isValid() || !end.isValid() || end.isBefore(start)) {
|
||||
throw new Error(`${label}开始时间不能晚于结束时间`)
|
||||
}
|
||||
if (end.isAfter(start.add(3, "month").endOf("day"))) {
|
||||
throw new Error(`${label}区间不能超过三个月,请缩小查询范围`)
|
||||
}
|
||||
hasTimeRange = true
|
||||
}
|
||||
if (!hasTimeRange) throw new Error("统计需选择创建时间或支付时间,单个区间不能超过三个月")
|
||||
return query
|
||||
}
|
||||
|
||||
/** 直接移动整数分的小数点;卡片、表格和提示均不先转 Number。 */
|
||||
export function formatStatisticsMoney(value: string | number | null | undefined): string {
|
||||
const raw = String(value ?? "0")
|
||||
if (!/^-?\d+$/.test(raw)) return "0.00"
|
||||
const negative = raw.startsWith("-")
|
||||
const digits = raw.replace(/^-/, "").replace(/^0+(?=\d)/, "").padStart(3, "0")
|
||||
const yuan = digits.slice(0, -2).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
return `${negative && /[1-9]/.test(digits) ? "-" : ""}${yuan}.${digits.slice(-2)}`
|
||||
}
|
||||
|
||||
export function formatStatisticsCount(value: string | number): string {
|
||||
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
||||
}
|
||||
|
||||
export function escapeChartText(value: unknown): string {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[character]!))
|
||||
}
|
||||
@@ -34,6 +34,13 @@
|
||||
<dict-select v-model="state.queryForm.orderPayTypeCodes" dict-code="hard_pay_type"
|
||||
multiple />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="是否有额外支付" prop="hasExtraPrice">
|
||||
<el-select v-model="state.queryForm.hasExtraPrice" placeholder="请选择" clearable
|
||||
@clear="state.queryForm.hasExtraPrice = null">
|
||||
<el-option label="是" :value="true" />
|
||||
<el-option label="否" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="订单状态" prop="orderStatus">
|
||||
<dict-select v-model="state.queryForm.orderStatus" dict-code="hard_order_status" />
|
||||
</el-form-item>
|
||||
@@ -67,7 +74,12 @@
|
||||
</el-tabs>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'sys_audit_export'"
|
||||
@exportExcel="exportExcel" class="ml10" style="float: right; margin-right: 20px"
|
||||
@queryTable="getDataList" />
|
||||
@queryTable="getDataList">
|
||||
<el-tooltip content="统计" placement="top">
|
||||
<el-button icon="DataLine" circle aria-label="统计" v-auth="'hard_hardwareOrder_view'"
|
||||
:disabled="statisticsVisible" @click="openStatistics" />
|
||||
</el-tooltip>
|
||||
</right-toolbar>
|
||||
</div>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle" @selection-change="selectionChangHandle"
|
||||
@@ -271,10 +283,18 @@
|
||||
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
|
||||
<admin-refund-dialog ref="adminRefundDailogRef" @refresh="getDataList(false)" />
|
||||
<shop-car ref="shopCarDialogRef" />
|
||||
<order-statistics-drawer v-if="statisticsVisible" v-model="statisticsVisible" :query="statisticsQuery" />
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||
@failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemHardwareOrder">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitExport as submitBatchExport } from "/@/api/hard/hard-order"
|
||||
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import * as hardOrder from "/@/api/hard/hard-order"
|
||||
import * as hardInvoice from "/@/api/hard/hard-invoice"
|
||||
@@ -285,6 +305,7 @@ import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { ElLoading, TableColumnCtx } from "element-plus"
|
||||
import { VNode } from "vue"
|
||||
import dayjs from "dayjs"
|
||||
import { snapshotStatisticsQuery } from "./components/statistics-drawer/utils"
|
||||
|
||||
const { push } = useRouter()
|
||||
|
||||
@@ -293,6 +314,7 @@ const FormDialog = defineAsyncComponent(() => import("./form.vue"))
|
||||
const AdminRefundDialog = defineAsyncComponent(() => import("./components/admin-refund-dialog/index.vue"))
|
||||
const ShopCar = defineAsyncComponent(() => import("../components/shop-car.vue"))
|
||||
const TransactionList = defineAsyncComponent(() => import("/@/views/wallet/components/transaction-list.vue"))
|
||||
const OrderStatisticsDrawer = defineAsyncComponent(() => import("./components/statistics-drawer/index.vue"))
|
||||
// 定义查询字典
|
||||
const { hard_pay_type, hard_order_type, hard_order_status, hard_type, hard_refund_status, daily_menu, wallet_type } = useDict(
|
||||
"hard_pay_type",
|
||||
@@ -315,6 +337,18 @@ const draggableDepTreeRef = ref()
|
||||
// 搜索变量
|
||||
const queryRef = ref()
|
||||
const showSearch = ref(true)
|
||||
const statisticsVisible = ref(false)
|
||||
const statisticsQuery = ref<hardOrder.HardOrderStatisticsQuery>({})
|
||||
|
||||
const openStatistics = () => {
|
||||
if (statisticsVisible.value) return
|
||||
try {
|
||||
statisticsQuery.value = snapshotStatisticsQuery(state.queryForm)
|
||||
statisticsVisible.value = true
|
||||
} catch (error) {
|
||||
useMessage().warning((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// 多选变量
|
||||
const selectObjs = ref([]) as any
|
||||
@@ -328,12 +362,13 @@ const defaultCreateTimeList = [
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
queryForm: {
|
||||
createTimeList: defaultCreateTimeList,
|
||||
hasExtraPrice: null,
|
||||
},
|
||||
pageList: hardOrder.pageJoin,
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await getDicts("hard_order_type")
|
||||
@@ -386,9 +421,13 @@ const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepT
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
downBlobFile("/hard/hard-order/export", Object.assign(state.queryForm, { ids: selectObjs }), "订单记录.xlsx")
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:order",
|
||||
taskName: "订单记录", fileName: "订单记录.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm, ids: [...selectObjs.value] })
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
|
||||
@@ -61,15 +61,19 @@
|
||||
</template>
|
||||
</DraggableDepMchTree>
|
||||
</div>
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="hard-sb">
|
||||
import { ElMessage } from "element-plus"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitMerchantExport as submitBatchExport } from "/@/api/hard/hard-total"
|
||||
import { submitMerchantMonthExport } from "/@/api/hard/hard-total"
|
||||
|
||||
import dayjs from "dayjs"
|
||||
|
||||
import other from "/@/utils/other"
|
||||
import { formatDate } from '/@/utils/formatTime';
|
||||
|
||||
const HardShByDay = defineAsyncComponent(() => import("./components/hard-sh-by-day.vue"))
|
||||
const HardShByMonth = defineAsyncComponent(() => import("./components/hard-sh-by-month.vue"))
|
||||
@@ -124,16 +128,15 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/hard/async-task/progress", redisKey: "hard:export:merchant",
|
||||
taskName: "商户交易记录", fileName: "商户交易记录.xlsx", submitTask: (query) => activeTab.value === "MONTH" ? submitMerchantMonthExport(query) : submitBatchExport(query),
|
||||
})
|
||||
|
||||
const exportExcel = () => {
|
||||
ElMessage.info("下载中,请稍后")
|
||||
switch (activeTab.value) {
|
||||
case "MCH":
|
||||
other.postDownBlobFile("/hard/hard-total/export-cs", Object.assign(state.queryForm), formatDate(new Date(), "YYYY年mm月dd日 HH时MM分SS秒 ") + "商户交易记录.xlsx")
|
||||
break;
|
||||
case "MONTH":
|
||||
other.postDownBlobFile("/hard/hard-total/export-cs-by-month", Object.assign(state.queryForm), formatDate(new Date(), "YYYY年mm月dd日 HH时MM分SS秒 ") + "商户交易记录(按月).xlsx")
|
||||
break;
|
||||
}
|
||||
exportTaskConfig.redisKey = activeTab.value === "MONTH" ? "hard:export:merchant-month" : "hard:export:merchant"
|
||||
return submitExport({ ...state.queryForm })
|
||||
}
|
||||
|
||||
const tabClick = () => {
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
size="min(1040px, 100vw)"
|
||||
class="third-order-drawer"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="closeDetail"
|
||||
>
|
||||
<template #header="{ titleId }">
|
||||
<div class="drawer-heading">
|
||||
<h2 :id="titleId">支付底单详情</h2>
|
||||
<p>支付、退款与接口调用记录</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="loading" class="detail-content">
|
||||
<el-skeleton v-if="loading && !detail" :rows="8" animated />
|
||||
<el-result v-else-if="loadError" icon="warning" title="底单加载失败" :sub-title="loadError">
|
||||
<template #extra><el-button type="primary" @click="loadDetail">重新加载</el-button></template>
|
||||
</el-result>
|
||||
<template v-else-if="detail">
|
||||
<div class="detail-banner">
|
||||
<div>
|
||||
<span class="eyebrow">第三方订单号</span>
|
||||
<div class="detail-order-no">
|
||||
{{ detail.order.orderNo || "未记录" }}
|
||||
<el-button
|
||||
v-if="detail.order.orderNo"
|
||||
:icon="CopyDocument"
|
||||
link
|
||||
type="primary"
|
||||
aria-label="复制第三方订单号"
|
||||
@click="copyNumber(detail.order.orderNo)"
|
||||
/>
|
||||
</div>
|
||||
<div class="platform-no">
|
||||
平台底单号 {{ detail.order.id }}
|
||||
<el-button :icon="CopyDocument" link type="primary" aria-label="复制平台底单号" @click="copyNumber(detail.order.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="orderState(detail.order).type">{{ orderState(detail.order).label }}</el-tag>
|
||||
</div>
|
||||
<div class="amount-grid">
|
||||
<div>
|
||||
<span>支付金额</span>
|
||||
<strong>{{ displayPrice(detail.order.payPrice) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>累计已退</span>
|
||||
<strong>{{ displayPrice(detail.order.refundPrice) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>剩余可退</span>
|
||||
<strong>{{ displayPrice(remainingPrice(detail.order)) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
<el-tab-pane label="支付详情" name="overview">
|
||||
<div class="overview-grid">
|
||||
<section class="detail-section">
|
||||
<h3>支付信息</h3>
|
||||
<el-descriptions :column="1" border label-width="116px">
|
||||
<el-descriptions-item label="支付用户">{{ detail.order.username || "未记录" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ detail.order.userMobile || "未记录" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户部门">{{ detail.order.userDeptName || "未记录" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付商户">{{ detail.order.merchantName || "未记录" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="钱包类型">
|
||||
<dict-tag
|
||||
v-if="detail.order.walletTypeUniqueCode"
|
||||
:options="wallet_type"
|
||||
:value="String(detail.order.walletTypeUniqueCode)"
|
||||
/>
|
||||
<span v-else>未记录</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付方式">第三方应用钱包扣款</el-descriptions-item>
|
||||
<el-descriptions-item label="钱包编号">
|
||||
<span class="breakable number-text">{{ detail.order.walletId || "未记录" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商品总额">{{ displayPrice(detail.order.totalPrice) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付差额">{{ displayPrice(detail.order.extraPrice) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品描述">
|
||||
<span class="breakable">{{ detail.order.productDesc || "未提供商品描述" }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</section>
|
||||
<section class="detail-section lifecycle">
|
||||
<h3>交易生命周期</h3>
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="event in timeline"
|
||||
:key="event.id"
|
||||
:timestamp="event.time || '未记录时间'"
|
||||
placement="top"
|
||||
:type="event.type"
|
||||
>
|
||||
<strong>{{ event.title }}</strong>
|
||||
<p>{{ event.description }}</p>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-if="!timeline.length" description="暂无生命周期记录" :image-size="72" />
|
||||
</section>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`商品明细(${detail.products.length})`" name="products">
|
||||
<el-table :data="detail.products" border>
|
||||
<el-table-column type="index" label="#" width="48" />
|
||||
<el-table-column prop="productName" label="商品名称" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="商品单价" min-width="120" align="right">
|
||||
<template #default="{ row }">{{ displayPrice(row.productPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="productCount" label="数量" width="90" align="right" />
|
||||
<el-table-column label="商品金额" min-width="120" align="right">
|
||||
<template #default="{ row }">{{ displayPrice(row.totalPrice) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="第三方应用未提供商品明细" :image-size="88" /></template>
|
||||
</el-table>
|
||||
<p class="section-note">
|
||||
商品金额按第三方应用传入的商品快照展示;与实付金额的差额为 {{ displayPrice(detail.order.extraPrice) }}。
|
||||
</p>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`退款记录(${detail.refunds.length})`" name="refunds">
|
||||
<el-table :data="detail.refunds" border>
|
||||
<el-table-column label="退款单号" min-width="235">
|
||||
<template #default="{ row }">
|
||||
<div class="breakable number-text">{{ row.no || "未记录退款单号" }}</div>
|
||||
<span class="section-note">平台退款单 {{ row.id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退款金额" width="120" align="right">
|
||||
<template #default="{ row }">{{ displayPrice(row.refundPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="售后状态" width="115">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="saleState(row.status).type" size="small">{{ saleState(row.status).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="退款原因" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.applyReason || "未填写" }}</div>
|
||||
<div v-if="row.applyDescription" class="section-note">{{ row.applyDescription }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交易时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>{{ refundTime(row) || "未记录时间" }}</div>
|
||||
<span class="section-note">{{ String(row.status) === "50" ? "退款完成 / 审核时间" : "申请时间" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="该底单暂无退款记录" :image-size="88" /></template>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="auth('third_thirdPayLog_view')" label="接口调用记录" name="logs">
|
||||
<div class="logs-toolbar">
|
||||
<p class="section-note">按平台底单号与第三方订单号关联支付、退款及订单详情查询。</p>
|
||||
<el-select v-model="logType" clearable placeholder="全部接口类型" style="width: 160px" @change="loadLogs(true)">
|
||||
<el-option label="支付" value="10" />
|
||||
<el-option label="退款" value="20" />
|
||||
<el-option label="订单详情查询" value="50" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-alert v-if="logError" :title="logError" type="error" :closable="false" show-icon class="logs-error" />
|
||||
<el-table v-loading="logsLoading" :data="logs" border>
|
||||
<el-table-column label="调用时间" prop="createTime" width="175" />
|
||||
<el-table-column label="调用方" min-width="130">
|
||||
<template #default="{ row }"><dict-tag :options="third_key_type" :value="row.paySourceType" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接口类型" width="120">
|
||||
<template #default="{ row }">{{ apiTypeLabel(row.bizType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调用结果" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="apiResult(row).type" size="small">{{ apiResult(row).label }}</el-tag>
|
||||
<div v-if="row.error" class="section-note breakable">{{ row.error }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="耗时" width="90" align="right">
|
||||
<template #default="{ row }">{{ row.timeConsuming ? `${row.timeConsuming} ms` : "—" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="请求 / 响应" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="json-links">
|
||||
<span>请求</span>
|
||||
<el-button v-if="row.req" link type="primary" @click="showPayload(row.req, '请求报文')">查看</el-button>
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
<div class="json-links">
|
||||
<span>响应</span>
|
||||
<el-button v-if="row.res" link type="primary" @click="showPayload(row.res, '响应报文')">查看</el-button>
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="logError ? '接口记录加载失败' : '暂无关联接口记录'" :image-size="80">
|
||||
<el-button v-if="logError" @click="loadLogs()">重试</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</el-table>
|
||||
<div class="logs-pagination">
|
||||
<el-pagination
|
||||
:current-page="logPagination.current"
|
||||
:page-size="logPagination.size"
|
||||
:total="logPagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
:pager-count="5"
|
||||
background
|
||||
@current-change="changeLogPage"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="visible = false">关闭</el-button>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="refreshCurrent">刷新详情</el-button>
|
||||
<el-tooltip
|
||||
v-if="detail && auth('hard_hardAfterSale_adminRefund')"
|
||||
:disabled="!refundDisabledReason(detail.order, detail.refunds)"
|
||||
:content="refundDisabledReason(detail.order, detail.refunds)"
|
||||
>
|
||||
<span>
|
||||
<el-button
|
||||
type="warning"
|
||||
:disabled="loading || Boolean(refundDisabledReason(detail.order, detail.refunds))"
|
||||
@click="emit('refund', detail.order)"
|
||||
>
|
||||
发起退款
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="payloadVisible" :title="payloadTitle" width="min(860px, calc(100vw - 24px))" append-to-body destroy-on-close>
|
||||
<pre class="payload-content" tabindex="0">{{ payloadText }}</pre>
|
||||
<template #footer><el-button @click="payloadVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="hardThirdOrderDetail">
|
||||
import { computed, ref, reactive, watch } from "vue"
|
||||
import { useClipboard } from "@vueuse/core"
|
||||
import { CopyDocument, Refresh } from "@element-plus/icons-vue"
|
||||
import * as thirdOrderApi from "/@/api/hard/hard-third-order"
|
||||
import type { ThirdOrder, ThirdOrderDetail, ThirdOrderId } from "/@/api/hard/hard-third-order"
|
||||
import * as thirdPayLogApi from "/@/api/thirdparty/third-pay-log"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { auth } from "/@/utils/authFunction"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { apiResult, orderState, refundDisabledReason, refundTime, remainingPrice, saleState } from "../order"
|
||||
|
||||
const emit = defineEmits<{ (event: "refund", order: ThirdOrder): void }>()
|
||||
const { wallet_type, third_key_type } = useDict("wallet_type", "third_key_type")
|
||||
const { copy } = useClipboard({ legacy: true })
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadError = ref("")
|
||||
const detail = ref<ThirdOrderDetail>()
|
||||
const currentId = ref<ThirdOrderId>()
|
||||
const activeTab = ref("overview")
|
||||
const logs = ref<any[]>([])
|
||||
const logsLoading = ref(false)
|
||||
const logsLoaded = ref(false)
|
||||
const logError = ref("")
|
||||
const logType = ref("")
|
||||
const payloadVisible = ref(false)
|
||||
const payloadTitle = ref("")
|
||||
const payloadText = ref("")
|
||||
const logPagination = reactive({ current: 1, size: 10, total: 0 })
|
||||
let detailSequence = 0
|
||||
let logSequence = 0
|
||||
|
||||
const timeline = computed(() => {
|
||||
if (!detail.value) return []
|
||||
const { order, refunds } = detail.value
|
||||
const events: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
time?: string
|
||||
type: "primary" | "success" | "warning" | "danger" | "info"
|
||||
}[] = []
|
||||
if (order.createTime)
|
||||
events.push({ id: "created", title: "底单创建", description: "第三方应用发起钱包支付", time: order.createTime, type: "primary" })
|
||||
if (order.payTime)
|
||||
events.push({
|
||||
id: "paid",
|
||||
title: "钱包扣款完成",
|
||||
description: `支付金额 ${displayPrice(order.payPrice)}`,
|
||||
time: order.payTime,
|
||||
type: "success",
|
||||
})
|
||||
for (const refund of refunds) {
|
||||
const state = saleState(refund.status)
|
||||
events.push({
|
||||
id: `refund-${refund.id}`,
|
||||
title: state.label,
|
||||
description: `${displayPrice(refund.refundPrice)} · ${refund.no || refund.id}`,
|
||||
time: refundTime(refund),
|
||||
type: state.type || "info",
|
||||
})
|
||||
}
|
||||
return events.sort((a, b) => (a.time || "").localeCompare(b.time || ""))
|
||||
})
|
||||
|
||||
async function openDialog(id: ThirdOrderId) {
|
||||
currentId.value = id
|
||||
visible.value = true
|
||||
detail.value = undefined
|
||||
activeTab.value = "overview"
|
||||
resetLogs()
|
||||
await loadDetail()
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (currentId.value == null) return
|
||||
const sequence = ++detailSequence
|
||||
loading.value = true
|
||||
loadError.value = ""
|
||||
try {
|
||||
const { data } = await thirdOrderApi.detail(currentId.value)
|
||||
if (sequence !== detailSequence) return
|
||||
if (!data?.order) throw new Error("底单不存在或无权查看")
|
||||
detail.value = { ...data, products: data.products || [], refunds: data.refunds || [] }
|
||||
} catch (error: any) {
|
||||
if (sequence === detailSequence) loadError.value = error?.msg || error?.message || "详情加载失败"
|
||||
} finally {
|
||||
if (sequence === detailSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOrder(id: ThirdOrderId) {
|
||||
if (!visible.value || String(id) !== String(currentId.value)) return
|
||||
await refreshCurrent()
|
||||
}
|
||||
|
||||
async function refreshCurrent() {
|
||||
await loadDetail()
|
||||
resetLogs()
|
||||
if (activeTab.value === "logs") await loadLogs(true)
|
||||
}
|
||||
|
||||
function resetLogs() {
|
||||
++logSequence
|
||||
logs.value = []
|
||||
logsLoaded.value = false
|
||||
logsLoading.value = false
|
||||
logError.value = ""
|
||||
logPagination.current = 1
|
||||
logPagination.total = 0
|
||||
}
|
||||
|
||||
async function loadLogs(resetPage = false) {
|
||||
if (!detail.value || !auth("third_thirdPayLog_view")) return
|
||||
if (resetPage) logPagination.current = 1
|
||||
const sequence = ++logSequence
|
||||
logsLoading.value = true
|
||||
logError.value = ""
|
||||
try {
|
||||
const { data } = await thirdPayLogApi.fetchList({
|
||||
relatedPlatformOrderId: String(detail.value.order.id),
|
||||
relatedThirdOrderId: detail.value.order.orderNo,
|
||||
bizType: logType.value || undefined,
|
||||
current: logPagination.current,
|
||||
size: logPagination.size,
|
||||
})
|
||||
if (sequence !== logSequence) return
|
||||
logs.value = data?.records || []
|
||||
logPagination.total = Number(data?.total || 0)
|
||||
logsLoaded.value = true
|
||||
} catch (error: any) {
|
||||
if (sequence !== logSequence) return
|
||||
logs.value = []
|
||||
logPagination.total = 0
|
||||
logError.value = error?.msg || "接口记录加载失败,请重试"
|
||||
} finally {
|
||||
if (sequence === logSequence) logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeLogPage(page: number) {
|
||||
logPagination.current = page
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function apiTypeLabel(type: string) {
|
||||
return (
|
||||
({ "10": "支付", "20": "退款", "30": "钱包查询", "40": "流水查询", "50": "订单详情查询" } as Record<string, string>)[String(type)] ||
|
||||
type ||
|
||||
"未知接口"
|
||||
)
|
||||
}
|
||||
|
||||
function showPayload(payload: string, title: string) {
|
||||
// 原文展示既兼容非 JSON 错误响应,也避免格式化时损失长整型订单号的精度。
|
||||
payloadText.value = payload
|
||||
payloadTitle.value = title
|
||||
payloadVisible.value = true
|
||||
}
|
||||
|
||||
async function copyNumber(value: ThirdOrderId) {
|
||||
try {
|
||||
await copy(String(value))
|
||||
useMessage().success("已复制订单号")
|
||||
} catch {
|
||||
useMessage().warning("复制失败,请手动选择订单号复制")
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
++detailSequence
|
||||
payloadVisible.value = false
|
||||
resetLogs()
|
||||
detail.value = undefined
|
||||
currentId.value = undefined
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === "logs" && !logsLoaded.value) loadLogs(true)
|
||||
})
|
||||
defineExpose({ openDialog, refreshOrder })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.drawer-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.drawer-heading p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.detail-content {
|
||||
min-height: 300px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.detail-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-extra-light);
|
||||
border-radius: 8px;
|
||||
> div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.detail-order-no {
|
||||
margin-top: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 26px;
|
||||
}
|
||||
.platform-no {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.amount-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
> div {
|
||||
padding: 0 16px;
|
||||
border-left: 2px solid var(--el-border-color-light);
|
||||
}
|
||||
span {
|
||||
display: block;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 21px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
}
|
||||
.detail-section h3 {
|
||||
margin: 4px 0 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lifecycle {
|
||||
padding-right: 8px;
|
||||
:deep(.el-timeline) {
|
||||
padding-left: 2px;
|
||||
}
|
||||
strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 20px;
|
||||
margin-top: 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.breakable {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.number-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
.section-note {
|
||||
margin: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 20px;
|
||||
}
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.logs-error {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.json-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.logs-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
.payload-content {
|
||||
max-height: 65vh;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.amount-grid {
|
||||
gap: 8px;
|
||||
> div {
|
||||
padding: 0 8px;
|
||||
}
|
||||
strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="第三方支付底单退款"
|
||||
width="min(560px, calc(100vw - 24px))"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
destroy-on-close
|
||||
@closed="closeDialog"
|
||||
>
|
||||
<div v-loading="loading" class="refund-content">
|
||||
<el-skeleton v-if="loading && !detail" :rows="5" animated />
|
||||
<el-alert v-else-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
|
||||
<template v-else-if="detail">
|
||||
<div class="refund-order">
|
||||
<span>第三方订单号</span>
|
||||
<strong>{{ detail.order.orderNo || "未记录" }}</strong>
|
||||
<span>平台底单号 {{ detail.order.id }}</span>
|
||||
</div>
|
||||
<div class="refund-summary">
|
||||
<div>
|
||||
<span>支付金额</span>
|
||||
<strong>{{ displayPrice(detail.order.payPrice) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>累计已退</span>
|
||||
<strong>{{ displayPrice(detail.order.refundPrice) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>剩余可退</span>
|
||||
<strong>{{ displayPrice(remainingPrice(detail.order)) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="disabledReason" :title="disabledReason" type="info" :closable="false" show-icon />
|
||||
<template v-else>
|
||||
<el-alert title="退款金额将退回原支付钱包,请核对金额后提交。" type="warning" :closable="false" show-icon />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="refund-form" :disabled="submitting">
|
||||
<el-form-item label="退款金额(元)" prop="amount">
|
||||
<div class="amount-input">
|
||||
<el-input-number
|
||||
v-model="form.amount"
|
||||
:min="0.01"
|
||||
:max="remainingPrice(detail.order) / 100"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
aria-label="退款金额,单位元"
|
||||
/>
|
||||
<el-button link type="primary" @click="form.amount = remainingPrice(detail.order) / 100">剩余全退</el-button>
|
||||
</div>
|
||||
<div class="field-hint">支持部分退款,最多 {{ displayPrice(remainingPrice(detail.order)) }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="退款原因" prop="applyReason">
|
||||
<el-input
|
||||
v-model.trim="form.applyReason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="请填写退款原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="补充说明" prop="applyDescription">
|
||||
<el-input
|
||||
v-model.trim="form.applyDescription"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
placeholder="选填,可补充退款处理说明"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
|
||||
<el-button v-if="loadError" :loading="loading" @click="loadDetail">重新加载</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="warning"
|
||||
:loading="submitting"
|
||||
:disabled="loading || !detail || Boolean(disabledReason)"
|
||||
@click="submitRefund"
|
||||
>
|
||||
确认退款
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="hardThirdOrderRefund">
|
||||
import { computed, nextTick, reactive, ref } from "vue"
|
||||
import { ElMessageBox } from "element-plus"
|
||||
import type { FormInstance, FormRules } from "element-plus"
|
||||
import { nanoid } from "nanoid"
|
||||
import * as thirdOrderApi from "/@/api/hard/hard-third-order"
|
||||
import type { ThirdOrderDetail, ThirdOrderId } from "/@/api/hard/hard-third-order"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { displayPrice, storePrice } from "/@/utils/formatMoney"
|
||||
import { refundDisabledReason, remainingPrice } from "../order"
|
||||
|
||||
const emit = defineEmits<{ (event: "success", orderId: ThirdOrderId): void }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const loadError = ref("")
|
||||
const detail = ref<ThirdOrderDetail>()
|
||||
const orderId = ref<ThirdOrderId>()
|
||||
const requestNo = ref("")
|
||||
let loadSequence = 0
|
||||
const form = reactive({ amount: undefined as number | undefined, applyReason: "", applyDescription: "" })
|
||||
const disabledReason = computed(() => (detail.value ? refundDisabledReason(detail.value.order, detail.value.refunds) : ""))
|
||||
const rules: FormRules = {
|
||||
amount: [
|
||||
{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || storePrice(value) <= 0) return callback(new Error("请输入有效退款金额"))
|
||||
if (!detail.value || storePrice(value) > remainingPrice(detail.value.order))
|
||||
return callback(new Error("退款金额不能超过剩余可退金额"))
|
||||
callback()
|
||||
},
|
||||
trigger: "change",
|
||||
},
|
||||
],
|
||||
applyReason: [{ required: true, whitespace: true, message: "请填写退款原因", trigger: "blur" }],
|
||||
}
|
||||
|
||||
async function openDialog(id: ThirdOrderId) {
|
||||
if (submitting.value) return
|
||||
orderId.value = id
|
||||
requestNo.value = nanoid(24)
|
||||
detail.value = undefined
|
||||
Object.assign(form, { amount: undefined, applyReason: "", applyDescription: "" })
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
formRef.value?.clearValidate()
|
||||
await loadDetail()
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (orderId.value == null) return
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = ""
|
||||
try {
|
||||
const { data } = await thirdOrderApi.detail(orderId.value)
|
||||
if (sequence !== loadSequence) return
|
||||
if (!data?.order) throw new Error("底单不存在或无权查看")
|
||||
detail.value = { ...data, products: data.products || [], refunds: data.refunds || [] }
|
||||
form.amount = remainingPrice(data.order) / 100
|
||||
} catch (error: any) {
|
||||
if (sequence === loadSequence) loadError.value = error?.msg || error?.message || "底单加载失败,请重试"
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
if (submitting.value || loading.value || !detail.value || disabledReason.value) return
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return
|
||||
const refundPrice = storePrice(form.amount)
|
||||
const currentOrder = detail.value.order
|
||||
submitting.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认将 ${displayPrice(refundPrice)} 退回用户「${currentOrder.username || currentOrder.userId || "未记录"}」的原支付钱包?`,
|
||||
"确认退款",
|
||||
{ type: "warning", confirmButtonText: "确认退款", cancelButtonText: "返回核对", closeOnClickModal: false },
|
||||
)
|
||||
} catch {
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data } = await thirdOrderApi.refund({
|
||||
orderId: currentOrder.id,
|
||||
refundPrice,
|
||||
requestNo: requestNo.value,
|
||||
applyReason: form.applyReason,
|
||||
applyDescription: form.applyDescription,
|
||||
})
|
||||
if (data !== true) throw new Error("退款未完成,请刷新底单确认状态")
|
||||
useMessage().success("退款成功,金额已退回原支付钱包")
|
||||
visible.value = false
|
||||
emit("success", currentOrder.id)
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || error?.message || "退款请求失败,请刷新底单确认状态后重试")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
++loadSequence
|
||||
detail.value = undefined
|
||||
orderId.value = undefined
|
||||
}
|
||||
|
||||
defineExpose({ openDialog })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.refund-content {
|
||||
min-height: 180px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.refund-order {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
strong {
|
||||
font-size: 15px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.refund-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
span {
|
||||
display: block;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.refund-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.amount-input {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.field-hint {
|
||||
width: 100%;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.refund-summary {
|
||||
gap: 8px;
|
||||
strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,632 @@
|
||||
<template>
|
||||
<div class="layout-padding third-order-page">
|
||||
<div class="layout-padding-auto layout-padding-view third-order-view">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="page-title">
|
||||
<span class="title-mark" aria-hidden="true"></span>
|
||||
<h1>第三方应用支付底单</h1>
|
||||
<el-tag size="small" effect="plain">钱包支付</el-tag>
|
||||
</div>
|
||||
<p>集中查看第三方应用支付、商品明细与退款记录</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadOrders(false)">刷新底单</el-button>
|
||||
</header>
|
||||
|
||||
<div class="page-scroll">
|
||||
<section class="summary-grid" aria-label="本页底单汇总" aria-live="polite">
|
||||
<div class="summary-item">
|
||||
<span>本页底单</span>
|
||||
<strong>
|
||||
{{ orders.length }}
|
||||
<small>笔</small>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>本页支付</span>
|
||||
<strong>
|
||||
{{ displayPrice(pageSummary.paid, "") }}
|
||||
<small>元</small>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>本页已退</span>
|
||||
<strong class="refund-amount">
|
||||
{{ displayPrice(pageSummary.refunded, "") }}
|
||||
<small>元</small>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span>本页净收</span>
|
||||
<strong>
|
||||
{{ displayPrice(pageSummary.paid - pageSummary.refunded, "") }}
|
||||
<small>元</small>
|
||||
</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SearchPanel
|
||||
ref="queryRef"
|
||||
:model="queryForm"
|
||||
title="筛选底单"
|
||||
:loading="loading"
|
||||
:hidden-active-count="hiddenActiveCount"
|
||||
@search="loadOrders()"
|
||||
@reset="resetQuery"
|
||||
>
|
||||
<el-form-item label="第三方订单号" prop="orderNo">
|
||||
<el-input v-model.trim="queryForm.orderNo" placeholder="输入完整第三方订单号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="平台底单号" prop="id">
|
||||
<el-input v-model.trim="queryForm.id" placeholder="输入完整平台底单号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付用户" prop="userId"><select-user v-model="queryForm.userId" /></el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTimeList" class="range-field">
|
||||
<el-date-picker
|
||||
v-model="queryForm.createTimeList"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:format="windowWidth < 768 ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD HH:mm:ss'"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
<template #more>
|
||||
<el-form-item label="支付商户" prop="merchantId"><select-mch v-model="queryForm.merchantId" /></el-form-item>
|
||||
<el-form-item label="钱包类型" prop="walletTypeIdList">
|
||||
<select-wallet-type v-model="queryForm.walletTypeIdList" multiple />
|
||||
</el-form-item>
|
||||
<el-form-item label="退款进度" prop="thirdRefundState">
|
||||
<el-select v-model="queryForm.thirdRefundState" placeholder="全部退款进度" clearable>
|
||||
<el-option label="未退款" value="NONE" />
|
||||
<el-option label="部分退款" value="PARTIAL" />
|
||||
<el-option label="全额退款" value="FULL" />
|
||||
<el-option label="退款处理中" value="PROCESSING" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="退款单号" prop="afterSaleNo">
|
||||
<el-input v-model.trim="queryForm.afterSaleNo" placeholder="输入完整退款单号" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付时间" prop="payTimeList" class="range-field">
|
||||
<el-date-picker
|
||||
v-model="queryForm.payTimeList"
|
||||
type="datetimerange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:format="windowWidth < 768 ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD HH:mm:ss'"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</SearchPanel>
|
||||
|
||||
<div class="table-toolbar">
|
||||
<el-tabs v-model="queryForm.orderStatus" class="status-tabs" @tab-change="loadOrders()">
|
||||
<el-tab-pane label="全部底单" name="" />
|
||||
<el-tab-pane label="待支付" name="1" />
|
||||
<el-tab-pane label="已支付" name="5" />
|
||||
<el-tab-pane label="发生退款" name="10" />
|
||||
</el-tabs>
|
||||
<el-button
|
||||
v-if="auth('hard_hardwareOrder_export')"
|
||||
:icon="Download"
|
||||
:loading="exporting"
|
||||
:disabled="loading"
|
||||
@click="exportOrders"
|
||||
>
|
||||
{{ selectedIds.length ? `导出已选 ${selectedIds.length} 笔` : "导出筛选结果" }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="orders"
|
||||
row-key="id"
|
||||
class="order-table"
|
||||
:header-cell-style="{ background: 'var(--el-fill-color-light)', color: 'var(--el-text-color-regular)' }"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="#"
|
||||
width="48"
|
||||
:index="(index: number) => (pagination.current - 1) * pagination.size + index + 1"
|
||||
/>
|
||||
<el-table-column label="底单信息" min-width="270">
|
||||
<template #default="{ row }">
|
||||
<div class="order-number">
|
||||
<el-button link type="primary" :title="row.orderNo" @click="openDetail(row)">
|
||||
{{ row.orderNo || "未记录第三方订单号" }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="cell-secondary number-text">平台 {{ row.id }}</div>
|
||||
<div class="cell-secondary product-desc" :title="row.productDesc">{{ row.productDesc || "未提供商品描述" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付用户" min-width="205">
|
||||
<template #default="{ row }">
|
||||
<user-info-cell
|
||||
:user="{ name: row.username, phone: row.userMobile, avatar: row.userAvatar, deptName: row.userDeptName }"
|
||||
:avatar-size="32"
|
||||
:show-account="false"
|
||||
:show-gender="false"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商户 / 钱包" min-width="165">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.merchantName || "未记录商户" }}</div>
|
||||
<div class="cell-secondary">
|
||||
<dict-tag v-if="row.walletTypeUniqueCode" :options="wallet_type" :value="String(row.walletTypeUniqueCode)" />
|
||||
<span v-else>未记录钱包类型</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付金额" min-width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="money-value">{{ displayPrice(row.payPrice) }}</div>
|
||||
<div class="cell-secondary">商品 {{ displayPrice(row.totalPrice) }}</div>
|
||||
<div v-if="Number(row.extraPrice)" class="cell-secondary">差额 {{ displayPrice(row.extraPrice) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态 / 退款" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="orderState(row).type" effect="light" size="small">{{ orderState(row).label }}</el-tag>
|
||||
<div class="cell-secondary">
|
||||
已退
|
||||
<span :class="{ 'refund-amount': Number(row.refundPrice) > 0 }">{{ displayPrice(row.refundPrice) }}</span>
|
||||
</div>
|
||||
<div v-if="Number(row.refundPrice) > 0" class="cell-secondary">剩余可退 {{ displayPrice(remainingPrice(row)) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="交易时间" min-width="195">
|
||||
<template #default="{ row }">
|
||||
<div class="time-row">
|
||||
<span>创建</span>
|
||||
{{ row.createTime || "—" }}
|
||||
</div>
|
||||
<div class="time-row">
|
||||
<span>支付</span>
|
||||
{{ row.payTime || "—" }}
|
||||
</div>
|
||||
<div v-if="row.refundTime" class="time-row">
|
||||
<span>退款</span>
|
||||
{{ row.refundTime }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" :fixed="windowWidth >= 1024 ? 'right' : false" width="152">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
|
||||
<el-tooltip
|
||||
v-if="auth('hard_hardAfterSale_adminRefund')"
|
||||
:disabled="!refundDisabledReason(row)"
|
||||
:content="refundDisabledReason(row)"
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<el-button link type="warning" :disabled="Boolean(refundDisabledReason(row))" @click="openRefund(row)">退款</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :description="loadError || '暂无符合条件的第三方支付底单'">
|
||||
<el-button v-if="loadError" @click="loadOrders(false)">重新加载</el-button>
|
||||
<el-button v-else @click="resetQuery">重置筛选</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
</el-table>
|
||||
<footer class="table-footer">
|
||||
<span class="cell-secondary">金额汇总仅统计本页,净收为已支付金额减已退款金额</span>
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.current"
|
||||
v-model:page-size="pagination.size"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:pager-count="5"
|
||||
:layout="windowWidth < 768 ? 'total, prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
|
||||
background
|
||||
@size-change="changeSize"
|
||||
@current-change="loadOrders(false)"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<order-detail ref="detailRef" @refund="openRefund" />
|
||||
<refund-dialog ref="refundRef" @success="handleRefundSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="hardThirdOrder">
|
||||
import { computed, defineAsyncComponent, onMounted, reactive, ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { useWindowSize } from "@vueuse/core"
|
||||
import { Download, Refresh } from "@element-plus/icons-vue"
|
||||
import dayjs from "dayjs"
|
||||
import * as thirdOrderApi from "/@/api/hard/hard-third-order"
|
||||
import type { ThirdOrder, ThirdOrderId } from "/@/api/hard/hard-third-order"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { auth } from "/@/utils/authFunction"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import other from "/@/utils/other"
|
||||
import { orderState, refundDisabledReason, remainingPrice } from "./order"
|
||||
|
||||
const OrderDetail = defineAsyncComponent(() => import("./components/order-detail.vue"))
|
||||
const RefundDialog = defineAsyncComponent(() => import("./components/refund-dialog.vue"))
|
||||
const { wallet_type } = useDict("wallet_type")
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
const route = useRoute()
|
||||
const queryRef = ref()
|
||||
const tableRef = ref()
|
||||
const detailRef = ref()
|
||||
const refundRef = ref()
|
||||
const orders = ref<ThirdOrder[]>([])
|
||||
const selectedIds = ref<ThirdOrderId[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const loadError = ref("")
|
||||
let requestSequence = 0
|
||||
|
||||
const initialQuery = () => ({
|
||||
orderNo: "",
|
||||
id: "",
|
||||
userId: undefined as ThirdOrderId | undefined,
|
||||
merchantId: undefined as ThirdOrderId | undefined,
|
||||
orderStatus: "",
|
||||
thirdRefundState: "",
|
||||
afterSaleNo: "",
|
||||
walletTypeIdList: [] as ThirdOrderId[],
|
||||
createTimeList: [
|
||||
dayjs().subtract(1, "month").startOf("day").format("YYYY-MM-DD HH:mm:ss"),
|
||||
dayjs().endOf("day").format("YYYY-MM-DD HH:mm:ss"),
|
||||
],
|
||||
payTimeList: [] as string[],
|
||||
})
|
||||
const queryForm = reactive(initialQuery())
|
||||
const pagination = reactive({ current: 1, size: 20, total: 0 })
|
||||
const hiddenActiveCount = computed(
|
||||
() =>
|
||||
[
|
||||
queryForm.merchantId,
|
||||
queryForm.thirdRefundState,
|
||||
queryForm.afterSaleNo,
|
||||
queryForm.walletTypeIdList.length,
|
||||
queryForm.payTimeList?.length,
|
||||
].filter(Boolean).length,
|
||||
)
|
||||
const pageSummary = computed(() =>
|
||||
orders.value.reduce(
|
||||
(summary, order) => ({
|
||||
paid: summary.paid + (order.payTime || ["5", "10", "20"].includes(String(order.orderStatus)) ? Number(order.payPrice || 0) : 0),
|
||||
refunded: summary.refunded + Number(order.refundPrice || 0),
|
||||
}),
|
||||
{ paid: 0, refunded: 0 },
|
||||
),
|
||||
)
|
||||
|
||||
async function loadOrders(resetPage = true) {
|
||||
if (queryForm.id && !/^\d+$/.test(queryForm.id)) {
|
||||
useMessage().warning("平台底单号只能包含数字")
|
||||
return
|
||||
}
|
||||
if (resetPage) pagination.current = 1
|
||||
const sequence = ++requestSequence
|
||||
loading.value = true
|
||||
loadError.value = ""
|
||||
selectedIds.value = []
|
||||
tableRef.value?.clearSelection()
|
||||
try {
|
||||
const { data } = await thirdOrderApi.page({ ...queryForm, current: pagination.current, size: pagination.size })
|
||||
if (sequence !== requestSequence) return
|
||||
orders.value = data?.records || []
|
||||
pagination.total = Number(data?.total || 0)
|
||||
} catch (error: any) {
|
||||
if (sequence !== requestSequence) return
|
||||
orders.value = []
|
||||
pagination.total = 0
|
||||
loadError.value = error?.msg || "底单加载失败,请重试"
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
Object.assign(queryForm, initialQuery())
|
||||
queryRef.value?.clearValidate()
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
function changeSize() {
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: ThirdOrder[]) {
|
||||
selectedIds.value = rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
function openDetail(order: ThirdOrder) {
|
||||
detailRef.value?.openDialog(order.id)
|
||||
}
|
||||
|
||||
function openRefund(order: ThirdOrder) {
|
||||
refundRef.value?.openDialog(order.id)
|
||||
}
|
||||
|
||||
async function handleRefundSuccess(orderId: ThirdOrderId) {
|
||||
await Promise.all([loadOrders(false), detailRef.value?.refreshOrder(orderId)])
|
||||
}
|
||||
|
||||
async function exportOrders() {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await other.downBlobFile(
|
||||
"/hard/hard-third-order/export",
|
||||
selectedIds.value.length ? { orderType: "6", orderIds: [...selectedIds.value] } : { ...queryForm, orderType: "6" },
|
||||
`第三方支付底单_${dayjs().format("YYYYMMDD_HHmmss")}.xlsx`,
|
||||
)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const orderNo = typeof route.query.orderNo === "string" ? route.query.orderNo : ""
|
||||
const id = typeof route.query.id === "string" ? route.query.id : ""
|
||||
if (orderNo || id) Object.assign(queryForm, { orderNo, id, createTimeList: [] })
|
||||
loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.third-order-view {
|
||||
padding: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
}
|
||||
.title-mark {
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
.page-header p {
|
||||
margin: 6px 0 0 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.page-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px 24px 20px;
|
||||
}
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
.summary-item {
|
||||
padding: 16px 20px;
|
||||
> span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
small {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
+ .summary-item {
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
.refund-amount {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
}
|
||||
:deep(.search-panel__form .el-form-item) {
|
||||
width: 280px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
:deep(.search-panel__form .range-field) {
|
||||
width: 440px;
|
||||
}
|
||||
:deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.status-tabs {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
.order-table {
|
||||
width: 100%;
|
||||
:deep(.el-table__cell) {
|
||||
padding: 14px 0;
|
||||
}
|
||||
:deep(.el-table__body .cell) {
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
.order-number {
|
||||
overflow: hidden;
|
||||
.el-button {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
.cell-secondary {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.cell-title {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.number-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.product-desc {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.money-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.time-row {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
span {
|
||||
color: var(--el-text-color-regular);
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
white-space: nowrap;
|
||||
.el-button + .el-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.table-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.page-header {
|
||||
padding: 16px;
|
||||
}
|
||||
.page-scroll {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
.summary-item {
|
||||
padding: 14px;
|
||||
strong {
|
||||
font-size: 21px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.page-header {
|
||||
padding: 12px;
|
||||
}
|
||||
.page-title h1 {
|
||||
font-size: 17px;
|
||||
}
|
||||
.page-header p {
|
||||
margin-left: 0;
|
||||
}
|
||||
.page-scroll {
|
||||
padding: 12px;
|
||||
}
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.summary-item:nth-child(3) {
|
||||
border-left: 0;
|
||||
}
|
||||
.summary-item:nth-child(n + 3) {
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
:deep(.search-panel__form .el-form-item),
|
||||
:deep(.search-panel__form .range-field) {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
:deep(.search-panel__form .range-field) {
|
||||
display: block;
|
||||
.el-form-item__label {
|
||||
display: block;
|
||||
height: auto;
|
||||
margin-bottom: 4px;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.el-form-item__content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.table-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.table-toolbar > .el-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.table-footer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ThirdOrder, ThirdOrderRefund } from "/@/api/hard/hard-third-order"
|
||||
|
||||
type StatusType = "success" | "warning" | "danger" | "info" | ""
|
||||
|
||||
/** 部分退款与全额退款共用 orderStatus=10,必须结合累计退款金额判断。 */
|
||||
export function refundState(order: ThirdOrder): { label: string; type: StatusType } {
|
||||
if (String(order.refundStatus) === "10") return { label: "退款处理中", type: "warning" }
|
||||
const refunded = Number(order.refundPrice || 0)
|
||||
if (refunded > 0 && refunded >= Number(order.payPrice || 0)) return { label: "已全额退款", type: "info" }
|
||||
if (refunded > 0) return { label: "已部分退款", type: "warning" }
|
||||
if (String(order.refundStatus) === "30") return { label: "退款未通过", type: "danger" }
|
||||
return { label: "未退款", type: "info" }
|
||||
}
|
||||
|
||||
export function orderState(order: ThirdOrder): { label: string; type: StatusType } {
|
||||
if (Number(order.refundPrice || 0) > 0 || String(order.refundStatus) === "10") return refundState(order)
|
||||
const states: Record<string, { label: string; type: StatusType }> = {
|
||||
"1": { label: "待支付", type: "warning" },
|
||||
"2": { label: "待支付", type: "warning" },
|
||||
"5": { label: "已支付", type: "success" },
|
||||
"6": { label: "离线支付", type: "success" },
|
||||
"10": { label: "退款", type: "warning" },
|
||||
"20": { label: "已核销", type: "success" },
|
||||
"30": { label: "已取消", type: "info" },
|
||||
}
|
||||
return states[String(order.orderStatus)] || { label: `状态 ${order.orderStatus || "未知"}`, type: "info" }
|
||||
}
|
||||
|
||||
export const remainingPrice = (order: ThirdOrder) =>
|
||||
order.payTime && ["5", "10", "20"].includes(String(order.orderStatus))
|
||||
? Math.max(0, Number(order.payPrice || 0) - Number(order.refundPrice || 0))
|
||||
: 0
|
||||
|
||||
export function refundDisabledReason(order: ThirdOrder, refunds: ThirdOrderRefund[] = []): string {
|
||||
if (String(order.orderType) !== "6") return "仅支持第三方应用支付底单"
|
||||
if (!["5", "10", "20"].includes(String(order.orderStatus)) || !order.payTime) return "订单尚未完成支付"
|
||||
if (String(order.payType) !== "3" || !order.walletId) return "该支付方式不支持底单钱包退款"
|
||||
if (String(order.refundStatus) === "10" || refunds.some((item) => String(item.status) === "10")) return "存在处理中的退款"
|
||||
if (remainingPrice(order) <= 0) return "订单已全额退款"
|
||||
return ""
|
||||
}
|
||||
|
||||
export function saleState(status: string): { label: string; type: StatusType } {
|
||||
return (
|
||||
(
|
||||
{
|
||||
"10": { label: "申请中", type: "warning" },
|
||||
"50": { label: "退款完成", type: "success" },
|
||||
"61": { label: "已取消", type: "info" },
|
||||
"62": { label: "已拒绝", type: "danger" },
|
||||
} as Record<string, { label: string; type: StatusType }>
|
||||
)[String(status)] || { label: `状态 ${status || "未知"}`, type: "info" }
|
||||
)
|
||||
}
|
||||
|
||||
/** 历史第三方退款未写 refundTime 时,以已完成售后单的审核时间补充展示。 */
|
||||
export const refundTime = (refund: ThirdOrderRefund) =>
|
||||
refund.refundTime || (String(refund.status) === "50" ? refund.auditTime : undefined) || refund.createTime
|
||||
|
||||
export function apiResult(log: { error?: string; res?: string }): { label: string; type: StatusType } {
|
||||
if (log.error) return { label: "调用异常", type: "danger" }
|
||||
try {
|
||||
const result = JSON.parse(log.res || "null")
|
||||
if (result?.code === 200) return { label: "成功", type: "success" }
|
||||
if (result?.code != null) return { label: result.message || `返回码 ${result.code}`, type: "danger" }
|
||||
} catch {
|
||||
// 保留原始响应预览,无法解析时不推断调用成功。
|
||||
}
|
||||
return { label: "查看响应", type: "info" }
|
||||
}
|
||||
+337
-147
@@ -1,154 +1,194 @@
|
||||
<template>
|
||||
<el-dialog :title="form.id ? '编辑' : '新增'" width="50%" v-model="visible" :close-on-click-modal="false" draggable>
|
||||
<el-form ref="dataFormRef" :model="form" :rules="dataRules" label-width="140px" v-loading="loading">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="商户名:" prop="name">
|
||||
<el-input v-model.trim="form.name" placeholder="请输入商户名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="部门:" prop="deptId">
|
||||
<select-dept v-model="form.deptId" placeholder="请选择部门" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="商户类型:" prop="types">
|
||||
<el-radio-group v-model="form.types">
|
||||
<el-radio :label="item.value" :disabled="merchantTypeRadioDisabled"
|
||||
v-for="(item, index) in merchant_type" border :key="index"
|
||||
@click="handleClickRadio(item.value)">
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :span="24" class="mb20">
|
||||
<el-form-item label="合作商家类型:" prop="supType">
|
||||
<el-radio-group v-model="form.supType" @change="(value: string) => form.supType = value">
|
||||
<el-radio border :label="item.value" v-for="(item, index) in wallet_mch_sup_type"
|
||||
:key="index">{{ item.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2' && form.supType == '20'" :span="24" class="mb20">
|
||||
<el-form-item label="合作状态:" prop="cooperationStatus">
|
||||
<el-radio-group v-model="form.cooperationStatus">
|
||||
<el-radio border :label="item.value" v-for="(item, index) in yes_no_type" :key="index">{{
|
||||
item.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2' && form.supType == '20'" :span="24" class="mb20">
|
||||
<el-form-item label="品牌:" prop="parentId">
|
||||
<select-mch v-model="form.parentId" :apiParams="{ types: '2', supType: '10' }" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :span="24" class="mb20">
|
||||
<el-form-item label="分类:" prop="catgoryId">
|
||||
<select-mch-catory v-model="form.catgoryId" :apiParams="{ bizType: form.types }" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="商户logo:" prop="logoPic">
|
||||
<upload-img v-model:imageUrl="form.logoPic" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="轮播图:" prop="swiperPic">
|
||||
<upload-imgs v-model="form.swiperPic" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" class="mb20">
|
||||
<el-form-item prop="sort">
|
||||
<template #label>
|
||||
<span>排序:</span>
|
||||
<tip content="越大越在前" />
|
||||
</template>
|
||||
<el-input-number v-model="form.sort" placeholder="" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '1'" :span="24" class="mb20">
|
||||
<el-form-item prop="sort">
|
||||
<template #label>
|
||||
<span>是否对外开放:</span>
|
||||
<tip content="食堂是否对外开放(对外开放非食堂人员可以在手机端看到食堂信息)" />
|
||||
</template>
|
||||
<el-radio-group v-model="form.canteenIsOpen">
|
||||
<el-radio v-for="v in yes_no_type" :key="v.value" :label="v.value" border>
|
||||
{{ v.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :span="12" class="mb20">
|
||||
<el-form-item label="优惠价格:" prop="discountsPrice">
|
||||
<el-input v-model="form.discountsPrice" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :span="12" class="mb20">
|
||||
<el-form-item prop="discountsRate">
|
||||
<template #label>
|
||||
<span>优惠折扣:</span>
|
||||
<tip content="范围1-99,填10表示1折;折扣越大(数值越小)的手机端排在越前面,留空表示无折扣" />
|
||||
</template>
|
||||
<div class="flex w-full items-center">
|
||||
<el-input-number class="flex-1" v-model="form.discountsRate" :min="1" :max="99"
|
||||
:value-on-clear="null" placeholder="如10表示1折" />
|
||||
<span class="ml5">%</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item prop="phone">
|
||||
<template #label>
|
||||
<span>联系电话:</span>
|
||||
<tip content="多个联系电话用/分隔" />
|
||||
</template>
|
||||
<el-input v-model.trim="form.phone" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item prop="platformRanke">
|
||||
<template #label>
|
||||
<span>平台抽成:</span>
|
||||
<tip content="多个联系电话用/分隔" />
|
||||
</template>
|
||||
<el-input v-model.trim="form.platformRanke" placeholder="请输入平台抽成">
|
||||
<template #append>
|
||||
<span>%</span>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-alert style="margin-top: 10px;"
|
||||
:title="`该商户的平台抽成是${form.platformRanke}%,即每一笔订单的抽成金额为${form.platformRanke}%的订单金额`"
|
||||
type="success" :closable="false" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '1'" :span="24" class="mb20">
|
||||
<el-form-item label="营业时间:" class="my-0" />
|
||||
<el-form-item class="ml-2" label="早上" prop="jobTime">
|
||||
<el-time-picker v-model="jobTime.morning" placeholder="请选择营业时间" is-range range-separator="至"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
|
||||
</el-form-item>
|
||||
<el-form-item class="ml-2" label="中午" prop="jobTime">
|
||||
<el-time-picker v-model="jobTime.noon" placeholder="请选择营业时间" is-range range-separator="至"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
|
||||
</el-form-item>
|
||||
<el-form-item class="ml-2" label="晚上" prop="jobTime">
|
||||
<el-time-picker v-model="jobTime.evening" placeholder="请选择营业时间" is-range range-separator="至"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="HH:mm" format="HH:mm" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" class="mb20">
|
||||
<el-form-item label="店铺地址:" prop="addressDes">
|
||||
<el-input type="textarea" v-model.trim="form.addressDes" placeholder="请输入店铺地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-dialog
|
||||
class="merchant-form-dialog"
|
||||
:title="form.id ? '编辑商户' : '新增商户'"
|
||||
width="min(1040px, calc(100vw - 32px))"
|
||||
v-model="visible"
|
||||
:close-on-click-modal="false"
|
||||
draggable
|
||||
>
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
class="merchant-form"
|
||||
:model="form"
|
||||
:rules="dataRules"
|
||||
label-width="112px"
|
||||
v-loading="loading"
|
||||
scroll-to-error
|
||||
>
|
||||
<section class="form-section" aria-label="基本信息">
|
||||
<h2 class="form-section-title">基本信息</h2>
|
||||
<div class="form-section-body">
|
||||
<el-row :gutter="24">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="商户名称" prop="name">
|
||||
<el-input v-model.trim="form.name" placeholder="请输入商户名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item label="所属部门" prop="deptId">
|
||||
<select-dept v-model="form.deptId" placeholder="请选择部门" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="商户类型" prop="types">
|
||||
<el-radio-group v-model="form.types">
|
||||
<el-radio
|
||||
v-for="item in merchant_type"
|
||||
:key="item.value"
|
||||
:label="item.value"
|
||||
:disabled="merchantTypeRadioDisabled"
|
||||
border
|
||||
@click="handleClickRadio(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :span="24">
|
||||
<el-form-item label="合作商家类型" prop="supType">
|
||||
<el-radio-group v-model="form.supType" @change="(value: string) => form.supType = value">
|
||||
<el-radio v-for="item in wallet_mch_sup_type" :key="item.value" border :label="item.value">{{ item.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2' && form.supType == '20'" :xs="24" :sm="12">
|
||||
<el-form-item label="合作状态" prop="cooperationStatus">
|
||||
<el-radio-group v-model="form.cooperationStatus">
|
||||
<el-radio v-for="item in yes_no_type" :key="item.value" border :label="item.value">{{ item.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2' && form.supType == '20'" :xs="24" :sm="12">
|
||||
<el-form-item label="所属品牌" prop="parentId">
|
||||
<select-mch v-model="form.parentId" :apiParams="{ types: '2', supType: '10' }" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :xs="24" :sm="12">
|
||||
<el-form-item label="商户分类" prop="catgoryId">
|
||||
<select-mch-catory v-model="form.catgoryId" :apiParams="{ bizType: form.types }" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item prop="phone">
|
||||
<template #label>
|
||||
<span>联系电话</span>
|
||||
<tip content="多个联系电话用/分隔" />
|
||||
</template>
|
||||
<el-input v-model.trim="form.phone" placeholder="请输入联系电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item prop="sort">
|
||||
<template #label>
|
||||
<span>显示排序</span>
|
||||
<tip content="越大越在前" />
|
||||
</template>
|
||||
<el-input-number v-model="form.sort" placeholder="请输入排序" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-label="图片资料">
|
||||
<h2 class="form-section-title">图片资料</h2>
|
||||
<div class="form-section-body">
|
||||
<el-row :gutter="24">
|
||||
<el-col :xs="24" :md="8">
|
||||
<el-form-item label="商户 Logo" prop="logoPic">
|
||||
<upload-img v-model:imageUrl="form.logoPic" width="120px" height="120px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="16">
|
||||
<el-form-item label="商户轮播图" prop="swiperPic">
|
||||
<upload-imgs v-model="form.swiperPic" width="120px" height="120px" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-label="经营设置">
|
||||
<h2 class="form-section-title">经营设置</h2>
|
||||
<div class="form-section-body">
|
||||
<el-row :gutter="24">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item prop="platformRanke">
|
||||
<template #label>
|
||||
<span>平台抽成</span>
|
||||
<tip content="按订单金额计算平台抽成比例" />
|
||||
</template>
|
||||
<div class="field-with-help">
|
||||
<el-input v-model.trim="form.platformRanke" placeholder="请输入平台抽成">
|
||||
<template #append>%</template>
|
||||
</el-input>
|
||||
<p class="field-help">每笔订单按订单金额的 {{ form.platformRanke || 0 }}% 计算平台抽成。</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :xs="24" :sm="12">
|
||||
<el-form-item label="优惠价格" prop="discountsPrice">
|
||||
<el-input v-model="form.discountsPrice" placeholder="请输入优惠价格" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '2'" :xs="24" :sm="12">
|
||||
<el-form-item prop="discountsRate">
|
||||
<template #label>
|
||||
<span>优惠折扣</span>
|
||||
<tip content="范围1-99,填10表示1折;折扣越大(数值越小)的手机端排在越前面,留空表示无折扣" />
|
||||
</template>
|
||||
<div class="field-with-help">
|
||||
<div class="discount-input">
|
||||
<el-input-number v-model="form.discountsRate" :min="1" :max="99" :value-on-clear="null" placeholder="如10表示1折" />
|
||||
<span>%</span>
|
||||
</div>
|
||||
<p class="field-help">留空表示无折扣。</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '1'" :span="24">
|
||||
<el-form-item prop="canteenIsOpen">
|
||||
<template #label>
|
||||
<span>对外开放</span>
|
||||
<tip content="食堂是否对外开放(对外开放非食堂人员可以在手机端看到食堂信息)" />
|
||||
</template>
|
||||
<el-radio-group v-model="form.canteenIsOpen">
|
||||
<el-radio v-for="v in yes_no_type" :key="v.value" :label="v.value" border>{{ v.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col v-if="form.types == '1'" :span="24">
|
||||
<el-form-item label="营业时间" prop="jobTime">
|
||||
<div class="job-time-grid">
|
||||
<div v-for="slot in jobTimeSlots" :key="slot.key" class="job-time-item">
|
||||
<span class="job-time-label">{{ slot.label }}</span>
|
||||
<el-time-picker
|
||||
v-model="jobTime[slot.key]"
|
||||
is-range
|
||||
range-separator="至"
|
||||
:start-placeholder="`${slot.label}开始时间`"
|
||||
:end-placeholder="`${slot.label}结束时间`"
|
||||
value-format="HH:mm"
|
||||
format="HH:mm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="店铺地址" prop="addressDes">
|
||||
<el-input type="textarea" v-model.trim="form.addressDes" :rows="2" resize="vertical" placeholder="请输入店铺地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmit" :disabled="loading">确认</el-button>
|
||||
<el-button type="primary" @click="onSubmit" :loading="loading" :disabled="loading">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -202,6 +242,13 @@ const jobTime = reactive<{
|
||||
})
|
||||
const merchantTypeRadioDisabled = ref(false)
|
||||
|
||||
/** 营业时间仅调整展示布局,沿用当前接口的早、中、晚三个时段及序列化顺序。 */
|
||||
const jobTimeSlots = [
|
||||
{ key: "morning", label: "早上" },
|
||||
{ key: "noon", label: "中午" },
|
||||
{ key: "evening", label: "晚上" },
|
||||
]
|
||||
|
||||
// 定义校验规则
|
||||
const dataRules = {
|
||||
name: [{ required: true, message: "商户名不能为空", trigger: "change" }],
|
||||
@@ -318,3 +365,146 @@ defineExpose({
|
||||
openDialog,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-section {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-bg-color);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-lighter);
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--el-color-primary);
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
.form-section-body {
|
||||
padding: 20px 20px 0;
|
||||
|
||||
// 全局 last-of-type 会清零各列内表单项的间距,统一由列保留行距与校验提示空间。
|
||||
:deep(.el-col) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.el-select),
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-radio.is-bordered) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 已上传缩略图与添加入口保持一致,避免编辑回填后出现两种尺寸。
|
||||
:deep(.el-upload-list--picture-card .el-upload-list__item) {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-with-help {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin: 6px 0 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.discount-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
:deep(.el-input-number) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.job-time-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.job-time-item {
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.job-time-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.form-section-title {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.form-section-body {
|
||||
padding: 16px 12px 0;
|
||||
}
|
||||
|
||||
.merchant-form {
|
||||
:deep(.el-form-item) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
justify-content: flex-start;
|
||||
width: auto !important;
|
||||
height: auto;
|
||||
margin-bottom: 8px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__content) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+524
-179
@@ -1,175 +1,273 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding mch-manage-page">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<DraggableDepTree ref="draggableDepTreeRef" placeholder="请输入部门名称" :show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
|
||||
:defaultTreeWidth="20" :nodeClickable="false" :props="{
|
||||
<DraggableDepTree
|
||||
ref="draggableDepTreeRef"
|
||||
placeholder="请输入部门名称"
|
||||
:show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode"
|
||||
switch-inactive-text="包含"
|
||||
switch-active-text="不包含"
|
||||
:defaultTreeWidth="20"
|
||||
:nodeClickable="false"
|
||||
:props="{
|
||||
label: 'name',
|
||||
children: 'children',
|
||||
value: 'id',
|
||||
disabled: 'isLockNone',
|
||||
}">
|
||||
}"
|
||||
>
|
||||
<template #default>
|
||||
<el-form v-if="showSearch" ref="queryRef" :inline="true" :model="state.queryForm"
|
||||
@keyup.enter="getDataList">
|
||||
<el-row>
|
||||
<el-form-item style="width: 250px" label="商户名" prop="name">
|
||||
<el-input v-model="state.queryForm.name" placeholder="商户名" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="商户类型" prop="types">
|
||||
<dict-select v-model="state.queryForm.types" dict-code="merchant_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="是否显示在移动端" prop="visibleMobile">
|
||||
<dict-select v-model="state.queryForm.visibleMobile" dict-code="yes_no_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="是否同步正元" prop="isSyncZy">
|
||||
<dict-select v-model="state.queryForm.isSyncZy" dict-code="yes_no_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="是否对外开放" prop="canteenIsOpen">
|
||||
<dict-select v-model="state.queryForm.canteenIsOpen" dict-code="yes_no_type" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button icon="Search" type="primary" @click="getDataList">
|
||||
{{ $t("common.queryBtn") }}
|
||||
</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">{{ $t("common.resetBtn") }}</el-button>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()"
|
||||
v-auth="'wallet_mchManage_add'">
|
||||
新 增
|
||||
</el-button>
|
||||
<el-button plain icon="RefreshRight" type="primary" :loading="refreshAllQrcodeLoading"
|
||||
v-auth="'wallet_mchManage_refresh_qrcode'" @click="refreshAllQrcode">
|
||||
刷新全部二维码
|
||||
</el-button>
|
||||
<el-button plain :disabled="multiple" icon="Delete" type="primary"
|
||||
v-auth="'wallet_mchManage_del'" @click="handleDelete(selectObjs)">
|
||||
删 除
|
||||
</el-button>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_mchManage_export'"
|
||||
@exportExcel="exportExcel" class="ml10 mr20" style="float: right"
|
||||
@queryTable="getDataList" />
|
||||
<header class="page-heading">
|
||||
<h1>商户管理</h1>
|
||||
<p>管理商户资料、收款二维码和展示设置</p>
|
||||
</header>
|
||||
<el-form
|
||||
v-if="showSearch"
|
||||
ref="queryRef"
|
||||
class="merchant-search"
|
||||
label-position="top"
|
||||
:model="state.queryForm"
|
||||
@keyup.enter="getDataList"
|
||||
>
|
||||
<el-form-item label="商户名称" prop="name">
|
||||
<el-input v-model="state.queryForm.name" placeholder="请输入商户名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="商户类型" prop="types">
|
||||
<dict-select v-model="state.queryForm.types" dict-code="merchant_type" />
|
||||
</el-form-item>
|
||||
<el-form-item label="移动端展示" prop="visibleMobile">
|
||||
<dict-select v-model="state.queryForm.visibleMobile" dict-code="yes_no_type" />
|
||||
</el-form-item>
|
||||
<el-form-item label="同步正元" prop="isSyncZy">
|
||||
<dict-select v-model="state.queryForm.isSyncZy" dict-code="yes_no_type" />
|
||||
</el-form-item>
|
||||
<el-form-item label="对外开放" prop="canteenIsOpen">
|
||||
<dict-select v-model="state.queryForm.canteenIsOpen" dict-code="yes_no_type" />
|
||||
</el-form-item>
|
||||
<div class="search-actions">
|
||||
<el-button icon="Search" type="primary" @click="getDataList">{{ $t("common.queryBtn") }}</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">{{ $t("common.resetBtn") }}</el-button>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table :data="state.dataList" v-loading="state.loading"
|
||||
:header-cell-style="tableStyle.headerCellStyle" :row-class-name="tableRowClassName"
|
||||
@selection-change="selectionChangHandle" @sort-change="sortChangeHandle"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :default-expand-all="false"
|
||||
:indent="30" row-key="id">
|
||||
<el-table-column type="selection" width="40" align="center" />
|
||||
<el-table-column type="index" label="#" width="50" align="center" />
|
||||
<el-table-column prop="id" label="商户id" align="left" min-width="180" />
|
||||
<el-table-column prop="name" label="商户名" align="left" min-width="160" />
|
||||
<el-table-column prop="logoPic" label="logo" align="center">
|
||||
<template #default="{ row }">
|
||||
<preview-image :src="row.logoPic" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="collectionCode" width="150" align="center" label="商户收款二维码">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<preview-image :src="row.collectionCode" download
|
||||
:downloadName="`${row.name} 收款二维码`" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="collectionUrlCode" width="150" align="center" label="商户url收款码">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<preview-image :src="row.collectionUrlCode" download
|
||||
:downloadName="`${row.name} 商户url收款码`" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="merchantType" label="商户类型" align="center" :min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="flex">
|
||||
<div class="flex justify-center items-center">
|
||||
<div v-for="(v, i) in row.types?.split(',')" :key="i" class="mr-1">
|
||||
<dict-tag :options="merchant_type" :value="v" />
|
||||
</el-form>
|
||||
<section class="merchant-list" aria-label="商户列表">
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-actions">
|
||||
<h2>商户列表</h2>
|
||||
<el-button icon="Plus" type="primary" @click="formDialogRef.openDialog()" v-auth="'wallet_mchManage_add'">
|
||||
新增商户
|
||||
</el-button>
|
||||
<el-button
|
||||
plain
|
||||
icon="RefreshRight"
|
||||
:loading="refreshAllQrcodeLoading"
|
||||
v-auth="'wallet_mchManage_refresh_qrcode'"
|
||||
@click="refreshAllQrcode"
|
||||
>
|
||||
刷新全部二维码
|
||||
</el-button>
|
||||
<el-button
|
||||
plain
|
||||
:disabled="multiple"
|
||||
icon="Delete"
|
||||
type="danger"
|
||||
v-auth="'wallet_mchManage_del'"
|
||||
@click="handleDelete(selectObjs)"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
<span v-if="selectObjs.length" class="selection-count">已选 {{ selectObjs.length }} 项</span>
|
||||
</div>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
:export="'wallet_mchManage_export'"
|
||||
@exportExcel="exportExcel"
|
||||
:gutter="0"
|
||||
@queryTable="getDataList"
|
||||
/>
|
||||
</div>
|
||||
<el-table
|
||||
class="merchant-table"
|
||||
:data="state.dataList"
|
||||
v-loading="state.loading"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
:row-class-name="tableRowClassName"
|
||||
@selection-change="selectionChangHandle"
|
||||
@sort-change="sortChangeHandle"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
:default-expand-all="false"
|
||||
:indent="30"
|
||||
row-key="id"
|
||||
>
|
||||
<el-table-column type="selection" width="44" align="center" />
|
||||
<el-table-column type="index" label="#" width="48" align="center" />
|
||||
<el-table-column prop="name" label="商户信息" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="merchant-identity">
|
||||
<preview-image v-if="row.logoPic" class="merchant-logo" width="40px" height="40px" :src="row.logoPic" />
|
||||
<el-avatar v-else class="merchant-logo" :size="40" shape="square">{{ row.name?.slice(0, 1) || "商" }}</el-avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="merchant-name-row">
|
||||
<strong class="merchant-name" :title="row.name">{{ row.name || "未命名商户" }}</strong>
|
||||
<el-tag v-if="row.parentId && row.parentId != '-1'" size="small" type="success" effect="plain">分店</el-tag>
|
||||
<el-tag v-else-if="Number(row.mchCount) > 0" size="small" effect="plain">{{ row.mchCount }} 家分店</el-tag>
|
||||
</div>
|
||||
<div class="cell-secondary merchant-id" :title="String(row.id)">ID:{{ row.id }}</div>
|
||||
<div class="cell-secondary truncate" :title="row.deptName">{{ row.deptName || "未关联部门" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag v-if="row.catgoryName" type="primary">{{ row.catgoryName }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isSyncZy" label="平台抽成" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row?.platformRanke ?? 0 }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isSyncZy" label="是否同步正元" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :disabled="!useUserInfo().userInfos.roleCodes.includes('ROLE_ADMIN')"
|
||||
active-value="1" inactive-value="0" v-model="row.isSyncZy"
|
||||
@change="isSyncZyChange(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="是否在移动端展示" min-width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch :disabled="!useUserInfo().userInfos.roleCodes.includes('ROLE_ADMIN')"
|
||||
active-value="1" inactive-value="0" v-model="row.visibleMobile"
|
||||
@change="visibleMobileChange(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="200" label="支付默认钱包" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="color-primary">{{ row.walletTypeName }}</div>
|
||||
<div class="flex items-center just ml5">
|
||||
<dict-tag :options="wallet_type" :value="row.walletTypeUniqueCode" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="merchantType" label="商户类型" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="type-tags">
|
||||
<dict-tag v-for="v in row.types?.split(',')" :key="v" :options="merchant_type" :value="v" />
|
||||
<el-tag v-if="row.catgoryName" effect="plain">{{ row.catgoryName }}</el-tag>
|
||||
<span v-if="!row.types && !row.catgoryName" class="cell-secondary">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收款二维码" width="164" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="qr-cell">
|
||||
<div class="qr-item" aria-label="商户收款二维码">
|
||||
<span class="cell-secondary">收款码</span>
|
||||
<preview-image
|
||||
v-if="row.collectionCode"
|
||||
width="40px"
|
||||
height="40px"
|
||||
:src="row.collectionCode"
|
||||
download
|
||||
:downloadName="`${row.name} 收款二维码`"
|
||||
/>
|
||||
<span v-else class="qr-empty">—</span>
|
||||
</div>
|
||||
<div class="qr-item" aria-label="商户URL收款码">
|
||||
<span class="cell-secondary">URL码</span>
|
||||
<preview-image
|
||||
v-if="row.collectionUrlCode"
|
||||
width="40px"
|
||||
height="40px"
|
||||
:src="row.collectionUrlCode"
|
||||
download
|
||||
:downloadName="`${row.name} 商户url收款码`"
|
||||
/>
|
||||
<span v-else class="qr-empty">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结算信息" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title" :title="row.walletTypeName">{{ row.walletTypeName || "未设置默认钱包" }}</span>
|
||||
<dict-tag v-if="row.walletTypeUniqueCode" :options="wallet_type" :value="row.walletTypeUniqueCode" />
|
||||
<span class="cell-secondary"
|
||||
>平台抽成 <strong class="commission-value">{{ row.platformRanke ?? 0 }}%</strong></span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="展示设置" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="display-settings">
|
||||
<div class="display-setting">
|
||||
<span>同步正元</span>
|
||||
<el-switch
|
||||
:disabled="!isAdmin"
|
||||
:label="`${row.name} 同步正元`"
|
||||
active-value="1"
|
||||
inactive-value="0"
|
||||
v-model="row.isSyncZy"
|
||||
@change="isSyncZyChange(row)"
|
||||
/>
|
||||
</div>
|
||||
<div class="display-setting">
|
||||
<span>移动端展示</span>
|
||||
<el-switch
|
||||
:disabled="!isAdmin"
|
||||
:label="`${row.name} 移动端展示`"
|
||||
active-value="1"
|
||||
inactive-value="0"
|
||||
v-model="row.visibleMobile"
|
||||
@change="visibleMobileChange(row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="businessTime" label="营业时间" min-width="155">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack business-hours">
|
||||
<span v-for="(label, index) in ['早上', '中午', '晚上']" :key="label" v-show="row.jobTime?.split(',')[index]">
|
||||
{{ label }} {{ row.jobTime?.split(",")[index] }}
|
||||
</span>
|
||||
<span v-if="!row.jobTime?.split(',').some(Boolean)" class="cell-secondary">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="联系方式 / 地址" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title" :title="row.phone">{{ row.phone || "未填写联系电话" }}</span>
|
||||
<el-tooltip :content="row.addressDes" :disabled="!row.addressDes" placement="top">
|
||||
<span class="cell-secondary truncate">{{ row.addressDes || "未填写地址" }}</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="70" align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="175" />
|
||||
<el-table-column
|
||||
label="操作"
|
||||
fixed="right"
|
||||
v-auths="['wallet_mchManage_refresh_qrcode', 'wallet_mchManage_setting', 'wallet_mchManage_edit', 'wallet_mchManage_del']"
|
||||
width="200"
|
||||
align="center"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button
|
||||
icon="RefreshRight"
|
||||
link
|
||||
type="primary"
|
||||
v-auth="'wallet_mchManage_refresh_qrcode'"
|
||||
@click="refreshQrcode(row)"
|
||||
>
|
||||
刷新二维码
|
||||
</el-button>
|
||||
<el-button
|
||||
icon="setting"
|
||||
link
|
||||
type="primary"
|
||||
v-auth="'wallet_mchManage_setting'"
|
||||
@click="settingDialogRef.openDialog(row.id)"
|
||||
>
|
||||
配置
|
||||
</el-button>
|
||||
<el-button
|
||||
icon="edit-pen"
|
||||
link
|
||||
type="primary"
|
||||
v-auth="'wallet_mchManage_edit'"
|
||||
@click="formDialogRef.openDialog(row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button icon="delete" link type="danger" v-auth="'wallet_mchManage_del'" @click="handleDelete([row.id])">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无符合条件的商户" :image-size="88" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deptName" label="部门" align="center" min-width="100" />
|
||||
<el-table-column prop="phone" label="联系电话" :min-width="120" align="center" />
|
||||
<el-table-column prop="sort" label="排序" :min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="color-success">{{ row.sort }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mchCount" label="分店数量" :min-width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="color-primary">{{ row.mchCount || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="businessTime" label="营业时间" :min-width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.jobTime?.split(",")[0] ? "早上:" + row.jobTime?.split(",")[0] : "" }}</div>
|
||||
<div>{{ row.jobTime?.split(",")[1] ? "中午:" + row.jobTime?.split(",")[1] : "" }}</div>
|
||||
<div>{{ row.jobTime?.split(",")[2] ? "晚上:" + row.jobTime?.split(",")[2] : "" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="addressDes" label="地址" :min-width="160" align="center" />
|
||||
<el-table-column prop="createTime" label="创建时间" :min-width="160" align="center" />
|
||||
<el-table-column label="操作" fixed="right"
|
||||
v-auths="['wallet_mchManage_refresh_qrcode', 'wallet_mchManage_setting', 'wallet_mchManage_edit', 'wallet_mchManage_del']"
|
||||
min-width="300" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button icon="RefreshRight" text type="primary"
|
||||
v-auth="'wallet_mchManage_refresh_qrcode'" @click="refreshQrcode(row)">
|
||||
刷新二维码
|
||||
</el-button>
|
||||
<el-button icon="setting" text type="primary" v-auth="'wallet_mchManage_setting'"
|
||||
@click="settingDialogRef.openDialog(row.id)">
|
||||
配置
|
||||
</el-button>
|
||||
<el-button icon="edit-pen" text type="primary" v-auth="'wallet_mchManage_edit'"
|
||||
@click="formDialogRef.openDialog(row.id)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button icon="delete" text type="primary" v-auth="'wallet_mchManage_del'"
|
||||
@click="handleDelete([row.id])"> 删除 </el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="state.pagination" />
|
||||
</el-table>
|
||||
<div class="table-pagination">
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</DraggableDepTree>
|
||||
</div>
|
||||
@@ -189,7 +287,10 @@ import FormDialog from "./form.vue"
|
||||
import Setting from "./setting.vue"
|
||||
import { useUserInfo } from "/@/stores/userInfo"
|
||||
|
||||
const { merchant_type, wallet_type, yes_no_type } = useDict("merchant_type", "wallet_type", "yes_no_type")
|
||||
const { merchant_type, wallet_type } = useDict("merchant_type", "wallet_type")
|
||||
|
||||
/** 展示开关继续仅允许管理员操作,集中复用当前用户角色判断。 */
|
||||
const isAdmin = computed(() => useUserInfo().userInfos.roleCodes.includes("ROLE_ADMIN"))
|
||||
|
||||
// 定义变量内容
|
||||
const formDialogRef = ref()
|
||||
@@ -209,7 +310,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
||||
|
||||
const tableRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
|
||||
const tableRowClassName = ({ row }: { row: any }) => {
|
||||
if (row.parentId && row.parentId != "-1") {
|
||||
return "success-row"
|
||||
}
|
||||
@@ -320,31 +421,275 @@ const handleDelete = async (ids: string[]) => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status {
|
||||
.page-heading {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.merchant-search {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-fill-color-lighter);
|
||||
|
||||
:deep(.el-form-item) {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
margin-bottom: 8px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
grid-column: -2 / -1;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.merchant-list {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-bg-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-toolbar,
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
h2 {
|
||||
margin: 0 4px 0 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:deep(.top-right-btn .el-row) {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.selection-count,
|
||||
.cell-secondary {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
// 使用行内布局,给树表自带的缩进和展开图标保留空间。
|
||||
.merchant-identity {
|
||||
display: inline-flex;
|
||||
width: calc(100% - 28px);
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.merchant-logo {
|
||||
flex: none;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.merchant-name-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
:deep(.el-tag) {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.merchant-name,
|
||||
.merchant-id,
|
||||
.cell-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.merchant-name {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.merchant-id,
|
||||
.commission-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.type-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qr-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
|
||||
:deep(.el-image__inner) {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.qr-item,
|
||||
.cell-stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.qr-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-empty {
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-regular);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
|
||||
.cell-title {
|
||||
max-width: 100%;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cell-stack > .truncate {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.commission-value {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.display-settings {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.display-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
.business-hours {
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span:nth-child(1) {
|
||||
min-width: 60px;
|
||||
max-width: 100px;
|
||||
text-align: left;
|
||||
}
|
||||
.row-actions {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: center;
|
||||
gap: 12px 16px;
|
||||
|
||||
span:nth-child(2) {
|
||||
flex: 1;
|
||||
min-width: 50px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
:deep(.el-button) {
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table) .success-row {
|
||||
.table-pagination {
|
||||
padding: 0 16px 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.merchant-table {
|
||||
:deep(.el-table__cell) {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-image) {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
box-shadow: none;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
:deep(.el-image__inner:hover) {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.merchant-table .success-row) {
|
||||
--el-table-tr-bg-color: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.merchant-search,
|
||||
.table-toolbar {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
gap: 8px;
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+56
-6
@@ -6,20 +6,50 @@
|
||||
ref="queryRef"
|
||||
:model="state.queryForm"
|
||||
:loading="Boolean(state.loading)"
|
||||
:hidden-active-count="hiddenActiveCount"
|
||||
:search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')"
|
||||
@search="getDataList"
|
||||
@reset="resetQuery"
|
||||
>
|
||||
<el-form-item style="width: 250px" label="用户" prop="paySourceType">
|
||||
<dict-select v-model="state.queryForm.apiType" dict-code="third_key_type" />
|
||||
<el-form-item style="width: 250px" label="发起方" prop="paySourceType">
|
||||
<dict-select v-model="state.queryForm.paySourceType" dict-code="third_key_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="接口类型" prop="bizType">
|
||||
<dict-select v-model="state.queryForm.bizType" dict-code="third_pay_api_type" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 400px" label="请求时间" prop="createTimeList">
|
||||
<el-date-picker v-model="state.queryForm.createTimeList" value-format="YYYY-MM-DD" type="daterange" />
|
||||
<el-form-item style="width: 320px" label="接口 URL" prop="apiUrl">
|
||||
<el-input v-model.trim="state.queryForm.apiUrl" placeholder="输入接口路径关键字" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 400px" label="请求时间" prop="createTimeList">
|
||||
<el-date-picker
|
||||
v-model="state.queryForm.createTimeList"
|
||||
value-format="YYYY-MM-DD"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<template #more>
|
||||
<el-form-item style="width: 320px" label="请求内容" prop="req">
|
||||
<el-input v-model.trim="state.queryForm.req" placeholder="输入 req 内容关键字" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 320px" label="响应内容" prop="res">
|
||||
<el-input v-model.trim="state.queryForm.res" placeholder="输入 res 内容关键字" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 320px" label="请求头" prop="header">
|
||||
<el-input v-model.trim="state.queryForm.header" placeholder="输入 header 内容关键字" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 320px" label="错误内容" prop="error">
|
||||
<el-input v-model.trim="state.queryForm.error" placeholder="输入错误内容关键字" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 320px" label="业务 ID" prop="bizId">
|
||||
<el-input v-model.trim="state.queryForm.bizId" placeholder="输入完整业务 ID" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 320px" label="日志 ID" prop="id">
|
||||
<el-input v-model.trim="state.queryForm.id" placeholder="输入完整日志 ID" clearable />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</SearchPanel>
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
@@ -67,6 +97,11 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="apiUrl" label="api-url" width="250" />
|
||||
<el-table-column prop="header" label="header" width="180">
|
||||
<template #default="{ row }">
|
||||
<preview-json :json="row.header" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="res" label="res" width="180">
|
||||
<template #default="{ row }">
|
||||
<preview-json :json="row.res" />
|
||||
@@ -133,10 +168,25 @@ const selectObjs = ref([]) as any
|
||||
const multiple = ref(true)
|
||||
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
queryForm: {},
|
||||
queryForm: {
|
||||
paySourceType: undefined,
|
||||
bizType: undefined,
|
||||
apiUrl: "",
|
||||
createTimeList: [],
|
||||
req: "",
|
||||
res: "",
|
||||
header: "",
|
||||
error: "",
|
||||
bizId: "",
|
||||
id: "",
|
||||
},
|
||||
pageList: thirdPayLogApi.fetchList,
|
||||
})
|
||||
|
||||
const hiddenActiveCount = computed(() =>
|
||||
["req", "res", "header", "error", "bizId", "id"].filter((key) => String(state.queryForm[key] ?? "").trim() !== "").length
|
||||
)
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
||||
|
||||
@@ -151,7 +201,7 @@ const resetQuery = () => {
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
downBlobFile("/third/thirdPayLog/export", Object.assign(state.queryForm, { ids: selectObjs }), "thirdPayLog.xlsx")
|
||||
downBlobFile("/third/third-pay-log/export", { ...state.queryForm, ids: selectObjs.value }, "thirdPayLog.xlsx")
|
||||
}
|
||||
|
||||
// 多选事件
|
||||
|
||||
@@ -176,13 +176,16 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletRecharge">
|
||||
import dayjs from "dayjs"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { pageCz, czTotal } from "/@/api/wallet/wallet-transaction"
|
||||
import { pageCz, czTotal, submitCzExport } from "/@/api/wallet/wallet-transaction"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
@@ -227,7 +230,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -283,13 +286,12 @@ const getSummaries = (param: any) => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/wallet-transaction/export-cz",
|
||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
||||
"充值记录.xlsx",
|
||||
)
|
||||
}
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:cz",
|
||||
taskName: "充值记录导出", fileName: "充值记录.xlsx", submitTask: submitCzExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
</el-drawer>
|
||||
<transaction-remark-form ref="remarkFormRef" @refresh="getDataList(false)" />
|
||||
<transaction-info-form ref="infoFormRef" @refresh="getDataList(false)" />
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,7 +161,9 @@
|
||||
import dayjs from "dayjs"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { pageJz, jzTotal } from "/@/api/wallet/wallet-transaction"
|
||||
import { pageJz, jzTotal, submitJzExport } from "/@/api/wallet/wallet-transaction"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
|
||||
@@ -199,7 +202,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -239,13 +242,12 @@ const viewBalanceStatistics = async () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/wallet-transaction/export-jz",
|
||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
||||
"消费记录.xlsx",
|
||||
)
|
||||
}
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:jz",
|
||||
taskName: "记账扣款导出", fileName: "消费记录.xlsx", submitTask: submitJzExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
|
||||
@@ -163,6 +163,7 @@
|
||||
<transaction-list ref="formDialogRef" @refresh="getDataList(false)" />
|
||||
<change-balance ref="changeBalanceRef" @refresh="getDataList(false)" />
|
||||
<transfer-accounts ref="transferAccountsRef" @refresh="getDataList(false)" />
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -172,6 +173,8 @@ import * as walletManage from "/@/api/wallet/wallet-manage"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
|
||||
// 引入组件
|
||||
const TransactionList = defineAsyncComponent(() => import("../components/transaction-list.vue"))
|
||||
@@ -198,7 +201,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -213,13 +216,12 @@ const resetQuery = () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/wallet-manage/export",
|
||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
||||
"电子钱包.xlsx",
|
||||
)
|
||||
}
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:manage",
|
||||
taskName: "电子钱包导出", fileName: "电子钱包.xlsx", submitTask: walletManage.submitExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
// 转账
|
||||
const doTransferAccounts = (row: any) => {
|
||||
|
||||
@@ -146,12 +146,15 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletManage">
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { fetchListJoinQb, delObjs, qbTotal } from "/@/api/wallet/wallet-manage"
|
||||
import { fetchListJoinQb, delObjs, qbTotal, submitQbExport } from "/@/api/wallet/wallet-manage"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
@@ -178,7 +181,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -218,13 +221,12 @@ const drawerClose = () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/wallet-manage/export-qb",
|
||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
||||
"钱包记录.xlsx",
|
||||
)
|
||||
}
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:qb",
|
||||
taskName: "钱包记录导出", fileName: "钱包记录.xlsx", submitTask: submitQbExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
const getSummaries = (param: any) => {
|
||||
const { columns, data } = param
|
||||
|
||||
@@ -277,10 +277,16 @@
|
||||
</div>
|
||||
</template>
|
||||
</process-drawer>
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletRechargeApply">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitExport as submitBatchExport } from "/@/api/wallet/wallet-recharge-apply"
|
||||
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import * as walletRechargeApplyApi from "/@/api/wallet/wallet-recharge-apply"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
@@ -328,7 +334,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -340,9 +346,13 @@ const resetQuery = () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile("/wallet/wallet-recharge-apply/export", Object.assign(state.queryForm, { ids: selectObjs }), "充值/扣款申请.xlsx")
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:recharge-apply",
|
||||
taskName: "充值/扣款申请", fileName: "充值_扣款申请.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm, ids: [...selectObjs.value] })
|
||||
|
||||
const doApply = async (row: any) => {
|
||||
let loading: any = null
|
||||
|
||||
@@ -177,10 +177,16 @@
|
||||
|
||||
<!-- 审批抽屉 -->
|
||||
<process-drawer ref="processDrawerRef" @task-submit-event="taskSubmitEvent" />
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletRechargeInvoice">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitExport as submitBatchExport } from "/@/api/wallet/wallet-recharge-invoice"
|
||||
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import * as WalletRechargeInvoiceApi from "/@/api/wallet/wallet-recharge-invoice"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
@@ -219,7 +225,7 @@ const printDialogRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -242,17 +248,13 @@ const doInfo = (row: any) => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/walletRechargeInvoice/export",
|
||||
{
|
||||
...state.queryForm,
|
||||
ascs: state.ascs,
|
||||
descs: state.descs,
|
||||
},
|
||||
"充值扣款发票申请.xlsx",
|
||||
)
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:recharge-invoice",
|
||||
taskName: "充值扣款发票申请", fileName: "充值扣款发票申请.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm, ascs: [...(state.ascs || [])], descs: [...(state.descs || [])] })
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
|
||||
@@ -129,9 +129,15 @@
|
||||
</template>
|
||||
</DraggableDepTree>
|
||||
</div>
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" name="wallet-recharge-water">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitExport as submitBatchExport } from "/@/api/wallet/wallet-recharge-import"
|
||||
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
@@ -156,7 +162,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 恢复操作
|
||||
const handleRecover = async (id: string) => {
|
||||
@@ -187,9 +193,13 @@ const getSummaries = (param: any) => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile("/wallet/wallet-recharge-import/export", Object.assign(state.queryForm, { ids: selectObjs }), "充值/扣款流水.xlsx")
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:recharge-water",
|
||||
taskName: "充值/扣款流水", fileName: "充值_扣款流水.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm, ids: [...selectObjs.value] })
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
|
||||
@@ -144,10 +144,16 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletRecharge">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitThExport as submitBatchExport } from "/@/api/wallet/wallet-transaction"
|
||||
|
||||
import dayjs from "dayjs"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { pageTh, thTotal } from "/@/api/wallet/wallet-transaction"
|
||||
@@ -194,7 +200,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -242,13 +248,13 @@ const doRefund = async (row: any) => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/wallet-transaction/export-th",
|
||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
||||
"退还各自食堂记录.xlsx",
|
||||
)
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:th",
|
||||
taskName: "退还各自食堂记录", fileName: "退还各自食堂记录.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
const getSummaries = (param: any) => {
|
||||
const { columns, data } = param
|
||||
|
||||
@@ -146,10 +146,16 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletRecharge">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitTkExport as submitBatchExport } from "/@/api/wallet/wallet-transaction"
|
||||
|
||||
import dayjs from "dayjs"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { pageTk, tkTotal } from "/@/api/wallet/wallet-transaction"
|
||||
@@ -198,7 +204,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -246,13 +252,13 @@ const doRefund = async (row: any) => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/wallet-transaction/export-tk",
|
||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
||||
"退款记录.xlsx",
|
||||
)
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:tk",
|
||||
taskName: "退款记录", fileName: "退款记录.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
const getSummaries = (param: any) => {
|
||||
const { columns, data } = param
|
||||
|
||||
@@ -150,10 +150,16 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletRecharge">
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import { submitXfExport as submitBatchExport } from "/@/api/wallet/wallet-transaction"
|
||||
|
||||
import dayjs from "dayjs"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { pageXf, xfTotal } from "/@/api/wallet/wallet-transaction"
|
||||
@@ -202,7 +208,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
@@ -241,13 +247,13 @@ const viewBalanceStatistics = async () => {
|
||||
}
|
||||
|
||||
// 导出excel
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile(
|
||||
"/wallet/wallet-transaction/export-xf",
|
||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
||||
"消费记录.xlsx",
|
||||
)
|
||||
}
|
||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:xf",
|
||||
taskName: "消费记录", fileName: "消费记录.xlsx", submitTask: submitBatchExport,
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
const doRefund = (row: any) => {
|
||||
push({ path: "/hard/hard-after-sale/order-refund", query: { id: row.bizId, type: "view" } })
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
const vue = require("vue")
|
||||
const { parse, compileScript } = require("@vue/compiler-sfc")
|
||||
const root = path.join(__dirname, "../src")
|
||||
const flush = () => new Promise(setImmediate)
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b }); return { promise, resolve, reject } }
|
||||
|
||||
function load(source, mocks, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 } }).outputText
|
||||
const module = { exports: {} }
|
||||
const names = Object.keys(globals)
|
||||
new Function("module", "exports", "require", ...names, compiled)(module, module.exports, key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
||||
return module.exports
|
||||
}
|
||||
|
||||
function component(query) {
|
||||
let unmount
|
||||
let now = 0
|
||||
let timerId = 0
|
||||
const timers = new Map()
|
||||
const events = []
|
||||
const requests = []
|
||||
let exposed
|
||||
const source = compileScript(parse(fs.readFileSync(path.join(root, "components/AsyncTaskProgress.vue"), "utf8")).descriptor, { id: "test" }).content
|
||||
const mod = load(source, {
|
||||
vue: { ...vue, onBeforeUnmount: fn => { unmount = fn } },
|
||||
"/@/api/asyncTask": { getAsyncTaskProgress: async (url, params) => { requests.push({ url, params }); return query(requests.length) } },
|
||||
"/@/utils/exportDownload": { downloadExportFile() {} },
|
||||
"/@/hooks/message": { useMessage: () => ({ error() {} }) },
|
||||
}, {
|
||||
setTimeout: fn => { timers.set(++timerId, fn); return timerId },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
Date: { now: () => now },
|
||||
})
|
||||
const state = mod.default.setup({ config: { progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:xf", pollTimeoutMs: 1000 } }, {
|
||||
expose: value => { exposed = value }, emit: (...args) => events.push(args),
|
||||
})
|
||||
return { state, exposed, events, requests, timers, unmount: () => unmount(), setNow: value => { now = value },
|
||||
async tick() { const entry = timers.entries().next().value; assert.ok(entry, "下一次轮询已安排"); timers.delete(entry[0]); entry[1](); await flush() } }
|
||||
}
|
||||
const initial = { taskId: "a".repeat(32), redisKey: "wallet:export:xf", taskName: "消费记录", status: "PENDING", total: 1, completed: 0, percentage: 0, message: "等待执行", finished: false, success: false }
|
||||
const success = { ...initial, status: "SUCCESS", completed: 1, percentage: 100, finished: true, success: true, resultFileUrl: "/admin/sys-file/oss/file?fileName=test.xlsx", resultFileName: "消费记录.xlsx" }
|
||||
|
||||
test("提交互斥且冻结嵌套筛选,列表分页不限制导出", async () => {
|
||||
const submitted = deferred()
|
||||
const calls = []
|
||||
const ui = []
|
||||
const downloads = []
|
||||
const mod = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue,
|
||||
"/@/components/AsyncTaskProgress.vue": {},
|
||||
"/@/hooks/message": { useMessage: () => ({ error: value => ui.push(value) }) },
|
||||
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
||||
})
|
||||
const task = mod.useBatchExport({ redisKey: initial.redisKey, progressUrl: "/wallet/async-task/progress", taskName: "导出", fileName: "回退.xlsx", submitTask: async query => { calls.push(query); return submitted.promise } })
|
||||
task.exportProgressRef.value = { startSubmitting: () => ui.push("submit"), start: () => ui.push("start"), show: () => ui.push("show") }
|
||||
const query = vue.reactive({ current: 8, size: 10, userIds: ["1"], range: { from: "2026-01-01" }, descs: ["create_time"] })
|
||||
const pending = task.submitExport(query)
|
||||
await task.submitExport(query)
|
||||
query.userIds.push("2"); query.range.from = "2026-02-01"
|
||||
assert.equal(calls.length, 1)
|
||||
assert.deepEqual(calls[0], { userIds: ["1"], range: { from: "2026-01-01" }, descs: ["create_time"] })
|
||||
assert.deepEqual(ui, ["submit", "show"])
|
||||
submitted.resolve({ data: initial }); await pending
|
||||
assert.equal(task.exportSubmitting.value, true)
|
||||
task.handleExportSuccess(success)
|
||||
assert.equal(task.exportSubmitting.value, false)
|
||||
assert.deepEqual(downloads[0], [success.resultFileUrl, success.resultFileName])
|
||||
})
|
||||
|
||||
test("提交失败恢复按钮状态并展示受控错误", async () => {
|
||||
const errors = []
|
||||
const mod = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {},
|
||||
"/@/hooks/message": { useMessage: () => ({ error: message => errors.push(message) }) },
|
||||
"/@/utils/exportDownload": {},
|
||||
})
|
||||
const task = mod.useBatchExport({ submitTask: async () => { throw { msg: "任务繁忙" } } })
|
||||
task.exportProgressRef.value = { startSubmitting() {}, submissionFailed: message => errors.push(message) }
|
||||
await task.submitExport({})
|
||||
assert.equal(task.exportSubmitting.value, false)
|
||||
assert.deepEqual(errors, ["任务繁忙", "任务繁忙"])
|
||||
})
|
||||
|
||||
test("慢查询不重叠,成功只通知一次且停止轮询", async () => {
|
||||
const pending = deferred()
|
||||
const task = component(async count => count === 1 ? pending.promise : { data: success })
|
||||
task.exposed.startSubmitting("导出")
|
||||
assert.equal(task.state.canClose.value, false)
|
||||
task.exposed.start(initial)
|
||||
await flush()
|
||||
assert.equal(task.requests.length, 1)
|
||||
assert.equal(task.timers.size, 0)
|
||||
pending.resolve({ data: { ...initial, status: "RUNNING", total: 10, completed: 1, percentage: 10 } }); await flush()
|
||||
assert.equal(task.timers.size, 1)
|
||||
await task.tick()
|
||||
assert.equal(task.events.length, 1)
|
||||
assert.equal(task.events[0][0], "success")
|
||||
assert.equal(task.timers.size, 0)
|
||||
assert.equal(task.state.canClose.value, true)
|
||||
})
|
||||
|
||||
test("连续五次查询失败暂停,重新查询原任务后仍可下载", async () => {
|
||||
const task = component(async count => { if (count <= 5) throw { msg: "网络中断" }; return { data: success } })
|
||||
task.exposed.start(initial); await flush()
|
||||
for (let i = 0; i < 4; i++) await task.tick()
|
||||
assert.equal(task.requests.length, 5)
|
||||
assert.equal(task.state.state.polling, false)
|
||||
assert.equal(task.state.state.queryError, "网络中断")
|
||||
task.state.state.visible = false; task.exposed.show()
|
||||
assert.equal(task.state.state.visible, true)
|
||||
task.state.retry(); await flush()
|
||||
assert.equal(task.requests.length, 6)
|
||||
assert.ok(task.requests.every(item => item.params.taskId === initial.taskId))
|
||||
assert.equal(task.events[0][0], "success")
|
||||
})
|
||||
|
||||
test("卸载或新任务替换后忽略旧请求响应", async () => {
|
||||
const pending = deferred()
|
||||
const task = component(async () => pending.promise)
|
||||
task.exposed.start(initial); await flush(); task.unmount()
|
||||
pending.resolve({ data: success }); await flush()
|
||||
assert.equal(task.timers.size, 0)
|
||||
assert.equal(task.events.length, 0)
|
||||
const old = deferred()
|
||||
const replacement = component(async count => count === 1 ? old.promise : { data: { ...initial, taskId: "b".repeat(32) } })
|
||||
replacement.exposed.start(initial)
|
||||
replacement.exposed.start({ ...initial, taskId: "b".repeat(32) }); await flush()
|
||||
old.resolve({ data: success }); await flush()
|
||||
assert.equal(replacement.state.state.progress.taskId, "b".repeat(32))
|
||||
assert.equal(replacement.events.length, 0)
|
||||
})
|
||||
|
||||
test("轮询超时只暂停跟踪,后台失败才发出 failure", async () => {
|
||||
const task = component(async () => ({ data: initial }))
|
||||
task.exposed.start(initial); await flush(); task.setNow(1001)
|
||||
await task.tick()
|
||||
assert.equal(task.requests.length, 1)
|
||||
assert.match(task.state.state.queryError, /后台任务可能仍在运行/)
|
||||
assert.equal(task.events.length, 0)
|
||||
const failed = component(async () => ({ data: { ...initial, finished: true, success: false, status: "FAILED", message: "导出失败" } }))
|
||||
failed.exposed.start(initial); await flush()
|
||||
assert.equal(failed.events[0][0], "failure")
|
||||
assert.equal(failed.state.canDownload.value, false)
|
||||
})
|
||||
|
||||
test("大文件下载使用原生附件地址并适配单体路径,不创建 Blob", () => {
|
||||
const links = []
|
||||
const source = fs.readFileSync(path.join(root, "utils/exportDownload.ts"), "utf8").replaceAll("import.meta.env", '({ VITE_API_URL: "/api" })')
|
||||
const mod = load(source, { "/@/utils/other": { default: { adaptationUrl: path => path.replace("/hard/", "/admin/") } } }, {
|
||||
document: { createElement: () => { const link = { style: {}, click() {}, remove() {} }; links.push(link); return link }, body: { appendChild() {} } },
|
||||
Blob: class { constructor() { throw new Error("不能将整个大文件读入 Blob") } },
|
||||
})
|
||||
mod.downloadExportFile("/hard/file?id=large", "充值/扣款.xlsx")
|
||||
assert.equal(links[0].href, "/api/admin/file?id=large")
|
||||
assert.equal(links[0].download, "充值_扣款.xlsx")
|
||||
mod.downloadExportFile("https://files.example.com/test", "test.xlsx")
|
||||
assert.equal(links[1].href, "https://files.example.com/test")
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
|
||||
// 项目尚未配置前端测试框架,使用现有 TypeScript 与 Node 测试业务状态判断。
|
||||
const source = fs.readFileSync(path.join(__dirname, "../src/views/hard/hard-third-order/order.ts"), "utf8")
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const loaded = { exports: {} }
|
||||
new Function("module", "exports", compiled)(loaded, loaded.exports)
|
||||
const { orderState, refundState, refundDisabledReason, remainingPrice, apiResult, refundTime } = loaded.exports
|
||||
const paid = {
|
||||
id: "90071992547409931",
|
||||
orderType: "6",
|
||||
orderStatus: "5",
|
||||
payType: "3",
|
||||
walletId: "123",
|
||||
payTime: "2026-10-21 10:00:00",
|
||||
payPrice: 1000,
|
||||
refundPrice: 0,
|
||||
}
|
||||
|
||||
test("共享退款状态码仍区分部分退款与全额退款", () => {
|
||||
const partial = { ...paid, orderStatus: "10", refundPrice: 200 }
|
||||
const full = { ...partial, refundPrice: 1000 }
|
||||
assert.equal(orderState(partial).label, "已部分退款")
|
||||
assert.equal(orderState(full).label, "已全额退款")
|
||||
assert.equal(remainingPrice(partial), 800)
|
||||
assert.equal(refundDisabledReason(partial), "")
|
||||
assert.equal(refundDisabledReason(full), "订单已全额退款")
|
||||
})
|
||||
|
||||
test("待支付订单和其他业务订单不提供钱包退款", () => {
|
||||
assert.equal(remainingPrice({ ...paid, orderStatus: "1", payTime: undefined }), 0)
|
||||
assert.equal(refundDisabledReason({ ...paid, orderStatus: "1", payTime: undefined }), "订单尚未完成支付")
|
||||
assert.equal(refundDisabledReason({ ...paid, orderType: "1" }), "仅支持第三方应用支付底单")
|
||||
assert.equal(refundDisabledReason({ ...paid, payType: "10" }), "该支付方式不支持底单钱包退款")
|
||||
})
|
||||
|
||||
test("售后申请中即使底单状态尚未同步也不能重复退款", () => {
|
||||
assert.equal(refundDisabledReason(paid, [{ id: "456", status: "10" }]), "存在处理中的退款")
|
||||
assert.equal(refundState({ ...paid, refundStatus: "10", refundPrice: 200 }).label, "退款处理中")
|
||||
})
|
||||
|
||||
test("历史售后无退款时间时使用审核时间", () => {
|
||||
assert.equal(refundTime({ status: "50", auditTime: "2026-10-21 10:02:00", createTime: "2026-10-21 10:01:00" }), "2026-10-21 10:02:00")
|
||||
assert.equal(refundTime({ status: "10", auditTime: "2026-10-21 10:02:00", createTime: "2026-10-21 10:01:00" }), "2026-10-21 10:01:00")
|
||||
})
|
||||
|
||||
test("接口 HTTP 成功与业务支付成功分别判断,坏日志不误报成功", () => {
|
||||
assert.equal(apiResult({ res: '{"code":200}' }).type, "success")
|
||||
assert.equal(apiResult({ res: '{"code":505,"message":"用户余额不足"}' }).label, "用户余额不足")
|
||||
assert.equal(apiResult({ error: "timeout", res: '{"code":200}' }).type, "danger")
|
||||
assert.equal(apiResult({ res: "invalid-json" }).label, "查看响应")
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
const vue = require("vue")
|
||||
const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||
const root = path.join(__dirname, "../src")
|
||||
|
||||
function load(source, mocks, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 } }).outputText
|
||||
const mod = { exports: {} }
|
||||
new Function("module", "exports", "require", ...Object.keys(globals), compiled)(mod, mod.exports,
|
||||
key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
for (const scene of ["cz", "jz", "qb"]) {
|
||||
test(`${scene} 页面提交对应后台任务,固定筛选并保留进度和下载流程`, async () => {
|
||||
const requests = [], progress = [], downloads = []
|
||||
const submitted = { taskId: "a".repeat(32), redisKey: `wallet:export:${scene}`, status: "PENDING" }
|
||||
const request = options => { requests.push(options); return Promise.resolve({ data: submitted }) }
|
||||
const tasks = load(fs.readFileSync(path.join(root, "api/asyncTask.ts"), "utf8"), { "/@/utils/request": { default: request } })
|
||||
const apis = {}
|
||||
for (const api of ["wallet-manage", "wallet-transaction"]) {
|
||||
apis[`/@/api/wallet/${api}`] = load(fs.readFileSync(path.join(root, `api/wallet/${api}.ts`), "utf8"), {
|
||||
"/@/utils/request": { default: request }, "/@/api/asyncTask": tasks,
|
||||
})
|
||||
}
|
||||
const hook = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {},
|
||||
"/@/hooks/message": { useMessage: () => ({ error: message => assert.fail(message) }) },
|
||||
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
||||
})
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, `views/wallet/wallet-${scene}/index.vue`), "utf8")).descriptor
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: `${scene}.vue`, id: scene }).errors, [])
|
||||
const page = load(compileScript(descriptor, { id: scene }).content, {
|
||||
...apis, vue, dayjs: { default: require("dayjs") },
|
||||
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
|
||||
"/@/hooks/table": { useTable: state => {
|
||||
state.pagination = { current: 8, size: 10 }
|
||||
return { getDataList() {}, tableStyle: {} }
|
||||
} },
|
||||
"/@/hooks/message": {}, "/@/hooks/dict": { useDict: () => ({}) },
|
||||
"/@/utils/formatMoney": {}, "/@/api/admin/dept": {},
|
||||
"/@/utils/formatTime": { formatDate: () => "2026-09" },
|
||||
}, { ref: vue.ref, reactive: vue.reactive, defineAsyncComponent: vue.defineAsyncComponent, useRouter: () => ({ push() {} }) })
|
||||
const state = page.default.setup({}, { expose() {} })
|
||||
state.exportProgressRef.value = { startSubmitting: name => progress.push(name), start: value => progress.push(value), submissionFailed: message => assert.fail(message) }
|
||||
Object.assign(state.state.queryForm, { current: 8, size: 10, userId: "123", deptIds: ["456"], walletTypeIdList: ["789"], isInclude: false })
|
||||
if (scene !== "qb") Object.assign(state.state.queryForm, { bizType: "3", createTimeList: ["2026-09-01", "2026-09-30"], tagList: ["7"] })
|
||||
const before = JSON.parse(JSON.stringify(state.state.queryForm))
|
||||
const expected = { ...before }; delete expected.current; delete expected.size
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, scene === "qb" ? "/wallet/wallet-manage/export-qb-async" : `/wallet/wallet-transaction/export-${scene}-async`)
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].data, expected)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(state.state.queryForm)), before)
|
||||
assert.equal(state.exportTaskConfig.progressUrl, "/wallet/async-task/progress")
|
||||
assert.equal(state.exportTaskConfig.redisKey, submitted.redisKey)
|
||||
assert.deepEqual(progress[1], submitted)
|
||||
state.state.queryForm.deptIds.push("999")
|
||||
assert.deepEqual(requests[0].data.deptIds, ["456"])
|
||||
state.handleExportSuccess({ ...submitted, status: "SUCCESS", resultFileUrl: "https://files.example.com/export.xlsx", resultFileName: "原导出文件.xlsx" })
|
||||
assert.deepEqual(downloads, [["https://files.example.com/export.xlsx", "原导出文件.xlsx"]])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
const { test } = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
const ts = require("typescript")
|
||||
const vue = require("vue")
|
||||
const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||
const root = path.join(__dirname, "../src")
|
||||
|
||||
function load(source, mocks, globals = {}) {
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2021 } }).outputText
|
||||
const mod = { exports: {} }
|
||||
new Function("module", "exports", "require", ...Object.keys(globals), compiled)(mod, mod.exports,
|
||||
key => key in mocks ? mocks[key] : require(key), ...Object.values(globals))
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
test("电子钱包页面提交后台任务,保留筛选快照且不受当前列表页限制", async () => {
|
||||
const requests = []
|
||||
const progress = []
|
||||
const submitted = { taskId: "a".repeat(32), redisKey: "wallet:export:manage", status: "PENDING" }
|
||||
const request = options => { requests.push(options); return Promise.resolve({ data: submitted }) }
|
||||
const tasks = load(fs.readFileSync(path.join(root, "api/asyncTask.ts"), "utf8"), { "/@/utils/request": { default: request } })
|
||||
const api = load(fs.readFileSync(path.join(root, "api/wallet/wallet-manage.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request }, "/@/api/asyncTask": tasks,
|
||||
})
|
||||
const hook = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {},
|
||||
"/@/hooks/message": { useMessage: () => ({ error: message => assert.fail(message) }) },
|
||||
"/@/utils/exportDownload": {},
|
||||
})
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, "views/wallet/wallet-manage/index.vue"), "utf8")).descriptor
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: "wallet-manage.vue", id: "wallet-manage" }).errors, [])
|
||||
const page = load(compileScript(descriptor, { id: "wallet-manage" }).content, {
|
||||
vue, "/@/api/wallet/wallet-manage": api,
|
||||
"/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/batchExport": hook,
|
||||
"/@/hooks/table": { useTable: state => {
|
||||
state.pagination = { current: 3, size: 10 }
|
||||
return { getDataList() {}, tableStyle: {} }
|
||||
} },
|
||||
"/@/hooks/message": {}, "/@/hooks/dict": { useDict: () => ({}) },
|
||||
"/@/utils/formatMoney": {},
|
||||
}, { ref: vue.ref, reactive: vue.reactive, defineAsyncComponent: vue.defineAsyncComponent })
|
||||
const state = page.default.setup({}, { expose() {} })
|
||||
state.exportProgressRef.value = { startSubmitting: name => progress.push(name), start: value => progress.push(value), submissionFailed: message => assert.fail(message) }
|
||||
Object.assign(state.state.queryForm, { current: 3, size: 10, userId: "123", deptIds: ["456"], walletTypeId: "789", isInclude: false, leBalance: 100 })
|
||||
const original = JSON.parse(JSON.stringify(state.state.queryForm))
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, "/wallet/wallet-manage/export-async")
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].data, { userId: "123", deptIds: ["456"], walletTypeId: "789", isInclude: false, leBalance: 100 })
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(state.state.queryForm)), original)
|
||||
assert.equal(state.exportTaskConfig.progressUrl, "/wallet/async-task/progress")
|
||||
assert.equal(state.exportTaskConfig.redisKey, "wallet:export:manage")
|
||||
assert.deepEqual(progress, ["电子钱包导出", submitted])
|
||||
state.state.queryForm.deptIds.push("999")
|
||||
assert.deepEqual(requests[0].data.deptIds, ["456"])
|
||||
})
|
||||
+6
-2
@@ -46,10 +46,14 @@ const viteConfig = defineConfig((mode: ConfigEnv) => {
|
||||
}),
|
||||
],
|
||||
root: process.cwd(), // 项目根目录
|
||||
resolve: { alias }, // 路径别名配置
|
||||
resolve: { alias, dedupe: ["echarts"] }, // 图表包装组件与按需注册共用同一份 ECharts
|
||||
base: mode.command === "serve" ? "./" : env.VITE_PUBLIC_PATH,
|
||||
optimizeDeps: {
|
||||
include: ["element-plus/es/locale/lang/zh-cn", "element-plus/es/locale/lang/en"],
|
||||
// 统一预构建,避免懒加载图表时核心与组件分批优化后出现多个注册表。
|
||||
include: [
|
||||
"element-plus/es/locale/lang/zh-cn", "element-plus/es/locale/lang/en",
|
||||
"vue-echarts", "echarts", "echarts/core", "echarts/charts", "echarts/components", "echarts/renderers",
|
||||
],
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0", // 服务器地址
|
||||
|
||||
Reference in new issue
Block a user