|
|
@@ -0,0 +1,857 @@
|
|
|
+<template>
|
|
|
+ <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素 -->
|
|
|
+ <div class="unified-order">
|
|
|
+ <el-card>
|
|
|
+ <template #header>
|
|
|
+ <div class="card-header">
|
|
|
+ <span>订单管理</span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <div class="mb-4">
|
|
|
+ <el-form :inline="true" :model="searchForm">
|
|
|
+ <el-form-item label="订单号">
|
|
|
+ <el-input
|
|
|
+ v-model="searchForm.order_no"
|
|
|
+ placeholder="平台订单号"
|
|
|
+ clearable
|
|
|
+ style="width: 190px"
|
|
|
+ @keyup.enter="handleSearch"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="商户订单号">
|
|
|
+ <el-input
|
|
|
+ v-model="searchForm.out_trade_no"
|
|
|
+ placeholder="调用方订单号"
|
|
|
+ clearable
|
|
|
+ style="width: 190px"
|
|
|
+ @keyup.enter="handleSearch"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="商户号">
|
|
|
+ <el-input
|
|
|
+ v-model="searchForm.mch_no"
|
|
|
+ placeholder="上游商户号"
|
|
|
+ clearable
|
|
|
+ style="width: 170px"
|
|
|
+ @keyup.enter="handleSearch"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="支付方式">
|
|
|
+ <el-select
|
|
|
+ v-model="searchForm.way_code"
|
|
|
+ placeholder="全部"
|
|
|
+ clearable
|
|
|
+ style="width: 170px"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="o in UNIFIED_WAY_CODE_OPTIONS"
|
|
|
+ :key="o.value"
|
|
|
+ :label="o.label"
|
|
|
+ :value="o.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-select
|
|
|
+ v-model="searchForm.trade_status"
|
|
|
+ placeholder="全部"
|
|
|
+ clearable
|
|
|
+ style="width: 140px"
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="(text, value) in UNIFIED_ORDER_STATUS_TEXT"
|
|
|
+ :key="value"
|
|
|
+ :label="text"
|
|
|
+ :value="value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="创建日期">
|
|
|
+ <el-date-picker
|
|
|
+ v-model="dateRange"
|
|
|
+ type="daterange"
|
|
|
+ range-separator="至"
|
|
|
+ start-placeholder="开始日期"
|
|
|
+ end-placeholder="结束日期"
|
|
|
+ value-format="YYYY-MM-DD"
|
|
|
+ style="width: 250px"
|
|
|
+ @change="handleDateChange"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" @click="handleSearch">查询</el-button>
|
|
|
+ <el-button @click="handleReset">重置</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-table v-loading="loading" :data="list" border stripe>
|
|
|
+ <template #empty>
|
|
|
+ <el-empty description="暂无数据" />
|
|
|
+ </template>
|
|
|
+ <el-table-column prop="order_no" label="订单号" min-width="200" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="mch_no" label="商户号" min-width="150" show-overflow-tooltip />
|
|
|
+ <el-table-column label="方式" width="130">
|
|
|
+ <template #default="{ row }">{{ wayCodeText(row.way_code) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="金额(元)" width="110">
|
|
|
+ <template #default="{ row }">¥{{ fenToYuan(row.amount) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="状态" width="110">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag :type="ORDER_STATUS_TAG[row.trade_status] || 'info'">
|
|
|
+ {{ orderStatusText(row.trade_status) }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column
|
|
|
+ prop="upstream_pay_order_id"
|
|
|
+ label="上游单号"
|
|
|
+ min-width="200"
|
|
|
+ show-overflow-tooltip
|
|
|
+ >
|
|
|
+ <template #default="{ row }">{{ row.upstream_pay_order_id || "-" }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="支付时间" width="170">
|
|
|
+ <template #default="{ row }">{{ formatTime(row.pay_time) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="回调状态" width="120">
|
|
|
+ <template #default="{ row }">{{ notifyStatusText(row.notify_status) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="创建时间" width="170">
|
|
|
+ <template #default="{ row }">{{ formatTime(row.created_time) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="100" fixed="right">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button
|
|
|
+ v-hasPerm="['module_payment:unified:order:detail']"
|
|
|
+ size="small"
|
|
|
+ @click="openDetail(row)"
|
|
|
+ >
|
|
|
+ 详情
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <div class="mt-4 flex justify-end">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="pageNo"
|
|
|
+ v-model:page-size="pageSize"
|
|
|
+ :total="total"
|
|
|
+ :page-sizes="[10, 20, 50, 100]"
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ @size-change="() => load()"
|
|
|
+ @current-change="() => load()"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 订单详情抽屉 -->
|
|
|
+ <el-drawer
|
|
|
+ v-model="drawerVisible"
|
|
|
+ :title="detail ? `订单详情 · ${detail.order_no}` : '订单详情'"
|
|
|
+ size="760px"
|
|
|
+ @closed="handleDrawerClosed"
|
|
|
+ >
|
|
|
+ <div v-loading="loadingDetail" class="detail-body">
|
|
|
+ <el-empty v-if="!detail && !loadingDetail" description="暂无详情" />
|
|
|
+ <template v-if="detail">
|
|
|
+ <el-descriptions :column="2" border>
|
|
|
+ <el-descriptions-item label="订单号" :span="2">
|
|
|
+ {{ detail.order_no }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="商户订单号">
|
|
|
+ {{ detail.out_trade_no || "-" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="商户号">{{ detail.mch_no || "-" }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="支付方式">
|
|
|
+ {{ wayCodeDetail(detail.way_code) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="订单状态">
|
|
|
+ <el-tag :type="ORDER_STATUS_TAG[detail.trade_status] || 'info'">
|
|
|
+ {{ detail.trade_status }} / {{ orderStatusText(detail.trade_status) }}
|
|
|
+ </el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="订单金额">
|
|
|
+ {{ money(detail.amount) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="已退金额">
|
|
|
+ {{ money(detail.refunded_amount) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <!-- 列表不算可退金额(恒为 null),只有详情接口给值 -->
|
|
|
+ <el-descriptions-item label="可退金额">
|
|
|
+ {{ money(detail.refundable_amount) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="回调状态">
|
|
|
+ {{ notifyStatusDetail(detail.notify_status) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="上游支付单号" :span="2">
|
|
|
+ {{ detail.upstream_pay_order_id || "-" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="上游渠道单号" :span="2">
|
|
|
+ {{ detail.upstream_channel_order_no || "-" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="pay_data_type">
|
|
|
+ {{ detail.pay_data_type || "-" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="错误码">{{ detail.err_code || "-" }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="错误信息" :span="2">
|
|
|
+ {{ detail.err_msg || "-" }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="支付时间">
|
|
|
+ {{ formatTime(detail.pay_time) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="下次查询时间">
|
|
|
+ {{ formatTime(detail.next_query_time) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="创建时间">
|
|
|
+ {{ formatTime(detail.created_time) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="更新时间">
|
|
|
+ {{ formatTime(detail.updated_time) }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+
|
|
|
+ <!-- pay_data:一律按文本展示;ALI_WAP/ALI_PC 的 form 是上游 HTML,禁止 v-html -->
|
|
|
+ <div class="detail-section">
|
|
|
+ <div class="detail-section__title">
|
|
|
+ <span>支付数据(pay_data)</span>
|
|
|
+ <el-button v-if="payDataText" size="small" @click="copyPayData">复制</el-button>
|
|
|
+ </div>
|
|
|
+ <template v-if="payDataText">
|
|
|
+ <div v-if="qrCodeDataUrl" class="pay-data__qr">
|
|
|
+ <img :src="qrCodeDataUrl" alt="支付二维码" />
|
|
|
+ </div>
|
|
|
+ <!-- codeImgUrl 只接受 http/https;其他协议在 watch 里记 WARN 并降级为文本 -->
|
|
|
+ <div v-else-if="codeImgUrl" class="pay-data__qr">
|
|
|
+ <img :src="codeImgUrl" alt="支付二维码" referrerpolicy="no-referrer" />
|
|
|
+ </div>
|
|
|
+ <pre class="pay-data__pre">{{ payDataText }}</pre>
|
|
|
+ </template>
|
|
|
+ <div v-else class="text-muted">无</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 退款列表(spec §9.2:详情抽屉要能看到该订单的退款) -->
|
|
|
+ <div class="detail-section">
|
|
|
+ <div class="detail-section__title">
|
|
|
+ <span>退款记录</span>
|
|
|
+ </div>
|
|
|
+ <el-table :data="refundList" border size="small" empty-text="暂无退款">
|
|
|
+ <el-table-column
|
|
|
+ prop="refund_no"
|
|
|
+ label="退款单号"
|
|
|
+ min-width="180"
|
|
|
+ show-overflow-tooltip
|
|
|
+ />
|
|
|
+ <el-table-column
|
|
|
+ prop="out_refund_no"
|
|
|
+ label="商户退款单号"
|
|
|
+ min-width="180"
|
|
|
+ show-overflow-tooltip
|
|
|
+ />
|
|
|
+ <el-table-column label="金额(元)" width="110">
|
|
|
+ <template #default="{ row }">{{ money(row.amount) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="状态" width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag :type="REFUND_STATE_TAG[row.state] || 'info'">
|
|
|
+ {{ refundStateText(row.state) }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="错误信息" min-width="150" show-overflow-tooltip>
|
|
|
+ <template #default="{ row }">{{ row.err_msg || "-" }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="退款时间" width="170">
|
|
|
+ <template #default="{ row }">{{ formatTime(row.success_time) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="创建时间" width="170">
|
|
|
+ <template #default="{ row }">{{ formatTime(row.created_time) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="回调状态" width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ {{ notifyStatusText(row.notify_status) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!--
|
|
|
+ upstream_data_json:后端 UnifiedOrderVO 目前没有暴露该字段(只有实体有),
|
|
|
+ 这里按本地扩展类型渲染 —— 后端补上字段后页面无需改动即可显示。
|
|
|
+ -->
|
|
|
+ <el-collapse v-if="detail.upstream_data_json" class="detail-section">
|
|
|
+ <el-collapse-item title="上游原始数据(upstream_data_json)" name="upstream">
|
|
|
+ <pre class="pay-data__pre">{{ detail.upstream_data_json }}</pre>
|
|
|
+ </el-collapse-item>
|
|
|
+ </el-collapse>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <div class="drawer-footer">
|
|
|
+ <el-button
|
|
|
+ v-hasPerm="['module_payment:unified:order:sync']"
|
|
|
+ :loading="syncing"
|
|
|
+ :disabled="!detailId"
|
|
|
+ @click="handleSync"
|
|
|
+ >
|
|
|
+ 同步
|
|
|
+ </el-button>
|
|
|
+ <!-- 列表行的 refundable_amount 恒为 null,退款只能放在拿到详情之后再判 -->
|
|
|
+ <el-button
|
|
|
+ v-hasPerm="['module_payment:unified:order:refund']"
|
|
|
+ type="danger"
|
|
|
+ :disabled="!canRefund || refunding"
|
|
|
+ @click="openRefund"
|
|
|
+ >
|
|
|
+ 退款
|
|
|
+ </el-button>
|
|
|
+ <el-button @click="drawerVisible = false">关闭</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-drawer>
|
|
|
+
|
|
|
+ <!-- 退款弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="refundVisible"
|
|
|
+ title="发起退款"
|
|
|
+ width="520px"
|
|
|
+ :close-on-click-modal="!refunding"
|
|
|
+ :close-on-press-escape="!refunding"
|
|
|
+ @closed="handleRefundClosed"
|
|
|
+ >
|
|
|
+ <el-form ref="refundFormRef" :model="refundForm" :rules="refundRules" label-width="110px">
|
|
|
+ <el-form-item label="退款单号" prop="out_refund_no">
|
|
|
+ <el-input v-model="refundForm.out_refund_no" disabled />
|
|
|
+ <div class="form-hint">幂等键:弹窗打开时生成一次,提交失败重试沿用同一个单号</div>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="退款金额(元)" prop="refund_amount">
|
|
|
+ <el-input v-model="refundForm.refund_amount" placeholder="最多两位小数" />
|
|
|
+ <div class="form-hint">可退金额 {{ money(detail?.refundable_amount) }}</div>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="退款原因" prop="refund_reason">
|
|
|
+ <el-input
|
|
|
+ v-model="refundForm.refund_reason"
|
|
|
+ type="textarea"
|
|
|
+ :rows="2"
|
|
|
+ maxlength="128"
|
|
|
+ show-word-limit
|
|
|
+ placeholder="退款原因"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button :disabled="refunding" @click="refundVisible = false">取消</el-button>
|
|
|
+ <el-button type="danger" :loading="refunding" :disabled="refunding" @click="submitRefund">
|
|
|
+ 确认退款
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+// ★ 与 sys_menu.route_name 逐字一致(`db/unified-menu.sql`):keep-alive 的 include
|
|
|
+// 匹配组件名,不写这一行会按文件名推成 "index",`keep_alive=true` 静默失效(bf09016)。
|
|
|
+defineOptions({
|
|
|
+ name: "payment-unified-order",
|
|
|
+ inheritAttrs: false,
|
|
|
+});
|
|
|
+
|
|
|
+import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
|
|
+import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
|
|
+import dayjs from "dayjs";
|
|
|
+import QRCode from "qrcode";
|
|
|
+import UnifiedAPI, {
|
|
|
+ type UnifiedOrderVO,
|
|
|
+ type UnifiedRefundVO,
|
|
|
+} from "@/api/module_payment/unified";
|
|
|
+import {
|
|
|
+ UNIFIED_NOTIFY_STATUS_TEXT,
|
|
|
+ UNIFIED_ORDER_STATUS_TEXT,
|
|
|
+ UNIFIED_REFUND_STATE_TEXT,
|
|
|
+ UNIFIED_WAY_CODE_OPTIONS,
|
|
|
+ buildOutRefundNo,
|
|
|
+ fenToYuan,
|
|
|
+ isSafeHttpUrl,
|
|
|
+ yuanToFen,
|
|
|
+} from "@/utils/unifiedPay";
|
|
|
+import { useLiveRefetch } from "@/composables";
|
|
|
+
|
|
|
+/**
|
|
|
+ * 详情专用的本地扩展类型。
|
|
|
+ *
|
|
|
+ * ★ 后端 `UnifiedOrderVO` 目前**没有** `upstream_data_json`(只有实体有)。
|
|
|
+ * 这里先声明为可选字段:后端补上后页面原样生效;没有时整块折叠区不渲染。
|
|
|
+ */
|
|
|
+type UnifiedOrderDetail = UnifiedOrderVO & { upstream_data_json?: string };
|
|
|
+
|
|
|
+type TagType = "primary" | "success" | "warning" | "info" | "danger";
|
|
|
+
|
|
|
+/** 订单状态 → 标签色(表外状态按 info 兜底,文本仍显示原值) */
|
|
|
+const ORDER_STATUS_TAG: Record<string, TagType> = {
|
|
|
+ "0": "info",
|
|
|
+ "1": "warning",
|
|
|
+ "2": "success",
|
|
|
+ "3": "danger",
|
|
|
+ "4": "info",
|
|
|
+ "5": "warning",
|
|
|
+ "6": "info",
|
|
|
+};
|
|
|
+
|
|
|
+/** 退款状态 → 标签色 */
|
|
|
+const REFUND_STATE_TAG: Record<string, TagType> = {
|
|
|
+ "0": "info",
|
|
|
+ "1": "warning",
|
|
|
+ "2": "success",
|
|
|
+ "3": "danger",
|
|
|
+ "4": "info",
|
|
|
+};
|
|
|
+
|
|
|
+const list = ref<UnifiedOrderVO[]>([]);
|
|
|
+const total = ref(0);
|
|
|
+const pageNo = ref(1);
|
|
|
+const pageSize = ref(20);
|
|
|
+const loading = ref(false);
|
|
|
+
|
|
|
+const drawerVisible = ref(false);
|
|
|
+/** 当前抽屉里的订单主键(打开时立刻写入,用于丢弃迟到的旧请求响应) */
|
|
|
+const detailId = ref<string | null>(null);
|
|
|
+const detail = ref<UnifiedOrderDetail | null>(null);
|
|
|
+const refundList = ref<UnifiedRefundVO[]>([]);
|
|
|
+const loadingDetail = ref(false);
|
|
|
+const syncing = ref(false);
|
|
|
+
|
|
|
+const refundVisible = ref(false);
|
|
|
+const refunding = ref(false);
|
|
|
+const refundFormRef = ref<FormInstance>();
|
|
|
+
|
|
|
+const dateRange = ref<string[] | null>(null);
|
|
|
+
|
|
|
+const searchForm = reactive({
|
|
|
+ order_no: "",
|
|
|
+ out_trade_no: "",
|
|
|
+ mch_no: "",
|
|
|
+ way_code: undefined as string | undefined,
|
|
|
+ trade_status: undefined as string | undefined,
|
|
|
+ start_date: undefined as string | undefined,
|
|
|
+ end_date: undefined as string | undefined,
|
|
|
+});
|
|
|
+
|
|
|
+const refundForm = reactive({
|
|
|
+ out_refund_no: "",
|
|
|
+ refund_amount: "",
|
|
|
+ refund_reason: "",
|
|
|
+});
|
|
|
+
|
|
|
+const refundRules: FormRules = {
|
|
|
+ refund_amount: [{ required: true, message: "请填写退款金额", trigger: "blur" }],
|
|
|
+ refund_reason: [{ required: true, message: "请填写退款原因", trigger: "blur" }],
|
|
|
+};
|
|
|
+
|
|
|
+const orderStatusText = (v?: string) => (v ? UNIFIED_ORDER_STATUS_TEXT[v] || v : "-");
|
|
|
+const refundStateText = (v?: string) => (v ? UNIFIED_REFUND_STATE_TEXT[v] || v : "-");
|
|
|
+const notifyStatusText = (v?: string) => (v ? UNIFIED_NOTIFY_STATUS_TEXT[v] || v : "-");
|
|
|
+
|
|
|
+/** 详情里把原值也带出来:状态表外的值不能因为「查不到中文」就消失 */
|
|
|
+const notifyStatusDetail = (v?: string) => (v ? `${notifyStatusText(v)}(${v})` : "-");
|
|
|
+
|
|
|
+const wayCodeText = (v?: string) => {
|
|
|
+ if (!v) {
|
|
|
+ return "-";
|
|
|
+ }
|
|
|
+ return UNIFIED_WAY_CODE_OPTIONS.find((o) => o.value === v)?.label || v;
|
|
|
+};
|
|
|
+
|
|
|
+const wayCodeDetail = (v?: string) => {
|
|
|
+ if (!v) {
|
|
|
+ return "-";
|
|
|
+ }
|
|
|
+ const opt = UNIFIED_WAY_CODE_OPTIONS.find((o) => o.value === v);
|
|
|
+ return opt ? `${opt.label}(${v})` : v;
|
|
|
+};
|
|
|
+
|
|
|
+const formatTime = (v?: string) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm:ss") : "-");
|
|
|
+
|
|
|
+/** 金额(分)→ 展示文本;null/undefined 是「没有这个值」,不是 0 元 */
|
|
|
+const money = (fen?: number | null) =>
|
|
|
+ fen === null || fen === undefined ? "-" : `¥${fenToYuan(fen)}`;
|
|
|
+
|
|
|
+/** 可退金额来自详情接口;列表恒为 null,不能用列表行判断 */
|
|
|
+const canRefund = computed(
|
|
|
+ () => detail.value?.trade_status === "2" && (detail.value?.refundable_amount ?? 0) > 0
|
|
|
+);
|
|
|
+
|
|
|
+// ─────────────────── pay_data 展示 ───────────────────
|
|
|
+
|
|
|
+/** pay_data 原样文本:始终展示(form 是上游 HTML,只能当文本) */
|
|
|
+const payDataText = computed(() => detail.value?.pay_data || "");
|
|
|
+
|
|
|
+/** codeUrl 生成的二维码 dataURL */
|
|
|
+const qrCodeDataUrl = ref("");
|
|
|
+let qrSeq = 0;
|
|
|
+
|
|
|
+/** codeImgUrl 里唯一允许进 <img src> 的形状:http/https */
|
|
|
+const codeImgUrl = computed(() => {
|
|
|
+ const d = detail.value;
|
|
|
+ if (!d || (d.pay_data_type || "").toLowerCase() !== "codeimgurl") {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ return isSafeHttpUrl(d.pay_data) ? d.pay_data || "" : "";
|
|
|
+});
|
|
|
+
|
|
|
+// 详情变化时刷新二维码;seq 用来丢弃迟到的异步结果(A 单的码不能渲染到 B 单上)
|
|
|
+watch(
|
|
|
+ () => [detail.value?.pay_data, detail.value?.pay_data_type] as const,
|
|
|
+ async () => {
|
|
|
+ const seq = ++qrSeq;
|
|
|
+ qrCodeDataUrl.value = "";
|
|
|
+ const data = detail.value?.pay_data || "";
|
|
|
+ const type = (detail.value?.pay_data_type || "").toLowerCase();
|
|
|
+ if (!data) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (type === "codeimgurl" && !isSafeHttpUrl(data)) {
|
|
|
+ console.warn("不安全的 codeImgUrl,已按文本展示", data);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (type !== "codeurl") {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const url = await QRCode.toDataURL(data, { width: 260, margin: 1 });
|
|
|
+ if (seq === qrSeq) {
|
|
|
+ qrCodeDataUrl.value = url;
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.warn("pay_data 二维码生成失败,已按文本展示", e);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { immediate: true }
|
|
|
+);
|
|
|
+
|
|
|
+async function copyPayData() {
|
|
|
+ if (!payDataText.value) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(payDataText.value);
|
|
|
+ ElMessage.success("已复制");
|
|
|
+ } catch {
|
|
|
+ ElMessage.warning("浏览器拒绝了复制操作,请手动选中复制");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─────────────────── 列表 ───────────────────
|
|
|
+
|
|
|
+async function load(silent = false) {
|
|
|
+ if (!silent) {
|
|
|
+ loading.value = true;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await UnifiedAPI.orderPage(
|
|
|
+ {
|
|
|
+ order_no: searchForm.order_no || undefined,
|
|
|
+ out_trade_no: searchForm.out_trade_no || undefined,
|
|
|
+ mch_no: searchForm.mch_no || undefined,
|
|
|
+ way_code: searchForm.way_code || undefined,
|
|
|
+ trade_status: searchForm.trade_status || undefined,
|
|
|
+ start_date: searchForm.start_date || undefined,
|
|
|
+ end_date: searchForm.end_date || undefined,
|
|
|
+ page_no: pageNo.value,
|
|
|
+ page_size: pageSize.value,
|
|
|
+ },
|
|
|
+ silent
|
|
|
+ );
|
|
|
+ // 后端分页键是 `list`,全局 TS 声明写的是 `items` —— 两个都读,
|
|
|
+ // 只读 items 会静默拿到空数组(HAR 订单页同一个坑)。
|
|
|
+ list.value = res.data.data?.list || res.data.data?.items || [];
|
|
|
+ total.value = res.data.data?.total || 0;
|
|
|
+ } finally {
|
|
|
+ if (!silent) {
|
|
|
+ loading.value = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleSearch() {
|
|
|
+ pageNo.value = 1;
|
|
|
+ load();
|
|
|
+}
|
|
|
+
|
|
|
+function handleDateChange() {
|
|
|
+ if (dateRange.value && dateRange.value.length === 2) {
|
|
|
+ searchForm.start_date = dateRange.value[0];
|
|
|
+ searchForm.end_date = dateRange.value[1];
|
|
|
+ } else {
|
|
|
+ searchForm.start_date = undefined;
|
|
|
+ searchForm.end_date = undefined;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleReset() {
|
|
|
+ searchForm.order_no = "";
|
|
|
+ searchForm.out_trade_no = "";
|
|
|
+ searchForm.mch_no = "";
|
|
|
+ searchForm.way_code = undefined;
|
|
|
+ searchForm.trade_status = undefined;
|
|
|
+ searchForm.start_date = undefined;
|
|
|
+ searchForm.end_date = undefined;
|
|
|
+ dateRange.value = null;
|
|
|
+ handleSearch();
|
|
|
+}
|
|
|
+
|
|
|
+// ─────────────────── 详情 ───────────────────
|
|
|
+
|
|
|
+/** 退款列表:始终静默(R67);只在还是同一订单时覆盖 */
|
|
|
+async function loadRefunds(id: string) {
|
|
|
+ try {
|
|
|
+ const res = await UnifiedAPI.orderRefunds(id, true);
|
|
|
+ if (detailId.value === id) {
|
|
|
+ refundList.value = res.data.data || [];
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 静默:退款列表拉取失败不遮盖详情主信息
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function openDetail(row: UnifiedOrderVO) {
|
|
|
+ const id = row.id;
|
|
|
+ if (!id) {
|
|
|
+ ElMessage.warning("订单缺少主键,无法查看详情");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ detailId.value = id;
|
|
|
+ detail.value = null;
|
|
|
+ refundList.value = [];
|
|
|
+ drawerVisible.value = true;
|
|
|
+ loadingDetail.value = true;
|
|
|
+ try {
|
|
|
+ const res = await UnifiedAPI.orderDetail(id);
|
|
|
+ if (detailId.value !== id) {
|
|
|
+ return; // 期间又打开了别的订单:丢弃这次响应
|
|
|
+ }
|
|
|
+ detail.value = res.data.data;
|
|
|
+ await loadRefunds(id);
|
|
|
+ } catch {
|
|
|
+ // 拦截器已用后端原话提示;详情没拿到就收起抽屉,别留一个空壳
|
|
|
+ if (detailId.value === id) {
|
|
|
+ drawerVisible.value = false;
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ if (detailId.value === id) {
|
|
|
+ loadingDetail.value = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 用户主动操作后的详情重载(非静默:失败要让操作员看见) */
|
|
|
+async function reloadOpenDetail() {
|
|
|
+ const id = detailId.value;
|
|
|
+ if (!id) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await UnifiedAPI.orderDetail(id);
|
|
|
+ if (detailId.value === id) {
|
|
|
+ detail.value = res.data.data;
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 拦截器已提示;保留旧详情
|
|
|
+ }
|
|
|
+ await loadRefunds(id);
|
|
|
+}
|
|
|
+
|
|
|
+/** 实时联动用的静默刷新;只有同一个订单还开着才覆盖 */
|
|
|
+async function refreshOpenDetail() {
|
|
|
+ const id = detailId.value;
|
|
|
+ if (!drawerVisible.value || !id) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await UnifiedAPI.orderDetail(id, true);
|
|
|
+ if (drawerVisible.value && detailId.value === id) {
|
|
|
+ detail.value = res.data.data;
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ // 静默
|
|
|
+ }
|
|
|
+ await loadRefunds(id);
|
|
|
+}
|
|
|
+
|
|
|
+function handleDrawerClosed() {
|
|
|
+ // 关闭动画期间又打开了别的订单:这次 closed 是上一轮的,不能把新状态清掉
|
|
|
+ if (drawerVisible.value) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ detailId.value = null;
|
|
|
+ detail.value = null;
|
|
|
+ refundList.value = [];
|
|
|
+ loadingDetail.value = false;
|
|
|
+}
|
|
|
+
|
|
|
+async function handleSync() {
|
|
|
+ const id = detailId.value;
|
|
|
+ if (!id) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ syncing.value = true;
|
|
|
+ try {
|
|
|
+ await UnifiedAPI.orderSync(id);
|
|
|
+ ElMessage.success("同步成功");
|
|
|
+ await reloadOpenDetail();
|
|
|
+ await load();
|
|
|
+ } catch {
|
|
|
+ // 拦截器已弹后端原话(如「上游同步失败」),这里不再叠一条
|
|
|
+ } finally {
|
|
|
+ syncing.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─────────────────── 退款 ───────────────────
|
|
|
+
|
|
|
+/** 六位随机数字(补零);`buildOutRefundNo` 负责拼时间戳与 `R` 前缀 */
|
|
|
+function random6(): string {
|
|
|
+ return String(Math.floor(Math.random() * 1000000)).padStart(6, "0");
|
|
|
+}
|
|
|
+
|
|
|
+function openRefund() {
|
|
|
+ const d = detail.value;
|
|
|
+ if (!d || !canRefund.value) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // ★ 幂等键只在弹窗打开时生成一次;提交失败重试必须沿用同一个号
|
|
|
+ refundForm.out_refund_no = buildOutRefundNo(new Date(), random6());
|
|
|
+ refundForm.refund_amount = fenToYuan(d.refundable_amount);
|
|
|
+ refundForm.refund_reason = "";
|
|
|
+ refundVisible.value = true;
|
|
|
+ nextTick(() => refundFormRef.value?.clearValidate());
|
|
|
+}
|
|
|
+
|
|
|
+function handleRefundClosed() {
|
|
|
+ refundFormRef.value?.clearValidate();
|
|
|
+}
|
|
|
+
|
|
|
+async function submitRefund() {
|
|
|
+ const id = detailId.value;
|
|
|
+ if (!id) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const valid = await refundFormRef.value?.validate().catch(() => false);
|
|
|
+ if (!valid) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const fen = yuanToFen(refundForm.refund_amount);
|
|
|
+ if (fen === null || fen <= 0) {
|
|
|
+ ElMessage.warning("退款金额格式不正确(最多两位小数,且需大于 0)");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const refundable = detail.value?.refundable_amount ?? 0;
|
|
|
+ if (fen > refundable) {
|
|
|
+ ElMessage.warning(`退款金额不能超过可退金额 ¥${fenToYuan(refundable)}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ refunding.value = true;
|
|
|
+ try {
|
|
|
+ await UnifiedAPI.orderRefund(id, {
|
|
|
+ out_refund_no: refundForm.out_refund_no,
|
|
|
+ refund_amount: fen,
|
|
|
+ refund_reason: refundForm.refund_reason.trim(),
|
|
|
+ });
|
|
|
+ // 提交成功只代表受理:到账以退款状态收敛成「退款成功」为准
|
|
|
+ ElMessage.success("退款已受理");
|
|
|
+ refundVisible.value = false;
|
|
|
+ await reloadOpenDetail();
|
|
|
+ await load();
|
|
|
+ } catch {
|
|
|
+ // 失败保留弹窗:修正后重试沿用同一个 out_refund_no(幂等键)
|
|
|
+ } finally {
|
|
|
+ refunding.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ─────────────────── 实时联动 ───────────────────
|
|
|
+
|
|
|
+/** 退款弹窗/提交期间降级为手动刷新;详情抽屉要跟着实时变,不进 busy */
|
|
|
+const liveBusy = computed(() => refundVisible.value || refunding.value);
|
|
|
+
|
|
|
+useLiveRefetch(
|
|
|
+ ["unified_order", "unified_refund"],
|
|
|
+ async () => {
|
|
|
+ await refreshOpenDetail();
|
|
|
+ await load(true);
|
|
|
+ },
|
|
|
+ { busy: liveBusy }
|
|
|
+);
|
|
|
+
|
|
|
+onMounted(() => load());
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.card-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+
|
|
|
+.detail-body {
|
|
|
+ min-height: 200px;
|
|
|
+}
|
|
|
+
|
|
|
+.detail-section {
|
|
|
+ margin-top: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.detail-section__title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.pay-data__qr {
|
|
|
+ margin-bottom: 8px;
|
|
|
+
|
|
|
+ img {
|
|
|
+ width: 220px;
|
|
|
+ height: 220px;
|
|
|
+ object-fit: contain;
|
|
|
+ border: 1px solid var(--el-border-color);
|
|
|
+ border-radius: 4px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.pay-data__pre {
|
|
|
+ max-height: 260px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ margin: 0;
|
|
|
+ overflow: auto;
|
|
|
+ font-family: inherit;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.6;
|
|
|
+ color: var(--el-text-color-regular);
|
|
|
+ word-break: break-all;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ background: var(--el-fill-color-light);
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.form-hint {
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.5;
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+}
|
|
|
+
|
|
|
+.text-muted {
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+}
|
|
|
+
|
|
|
+.drawer-footer {
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+</style>
|