Bladeren bron

feat: 批量付款 - 前端 Excel 导入/二维码展示与表单修复

alphaH 5 dagen geleden
bovenliggende
commit
dcb921ef5f

+ 44 - 6
frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue

@@ -24,11 +24,17 @@
         </el-button>
       </el-form-item>
       <el-form-item v-if="link" label="授权链接(PC 浏览器打开)">
-        <el-input :model-value="link" readonly style="max-width: 560px">
-          <template #append>
-            <el-button @click="handleCopyLink">复制</el-button>
-          </template>
-        </el-input>
+        <div style="display: flex; align-items: flex-start; gap: 12px">
+          <el-input :model-value="link" readonly style="width: 560px">
+            <template #append>
+              <el-button @click="handleCopyLink">复制</el-button>
+            </template>
+          </el-input>
+          <div class="qrcode-wrapper">
+            <canvas ref="qrcodeCanvas" class="qrcode-canvas"></canvas>
+            <div class="form-item-tip">手机支付宝扫码打开</div>
+          </div>
+        </div>
       </el-form-item>
       <el-form-item v-if="outBizNo" label="授权单号 / 状态">
         <span>{{ outBizNo }} / {{ statusText }}</span>
@@ -47,10 +53,11 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from "vue";
+import { computed, nextTick, ref } from "vue";
 import BatchPayAPI from "@/api/module_payment/batch";
 import { useEnterpriseStore } from "@/store";
 import { ElMessage } from "element-plus";
+import QRCode from "qrcode";
 
 const enterpriseStore = useEnterpriseStore();
 const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
@@ -62,6 +69,7 @@ const agreementNo = ref("");
 const status = ref("");
 const applying = ref(false);
 const querying = ref(false);
+const qrcodeCanvas = ref<HTMLCanvasElement>();
 
 const AUTH_STATUS_TEXT: Record<string, string> = {
   AUTHING: "授权中",
@@ -86,11 +94,22 @@ async function handleApply() {
     outBizNo.value = res.data.data.out_biz_no;
     status.value = res.data.data.status;
     agreementNo.value = "";
+    nextTick(() => drawQRCode());
   } finally {
     applying.value = false;
   }
 }
 
+/** 授权链接二维码(复用 InviteDialog 的 qrcode 库用法) */
+async function drawQRCode() {
+  if (!qrcodeCanvas.value || !link.value) return;
+  try {
+    await QRCode.toCanvas(qrcodeCanvas.value, link.value, { width: 160, margin: 1 });
+  } catch (err) {
+    console.error("授权链接二维码生成失败:", err);
+  }
+}
+
 async function handleQuery() {
   if (!enterpriseId.value || !outBizNo.value) return;
   querying.value = true;
@@ -119,4 +138,23 @@ async function handleCopyLink() {
   justify-content: space-between;
   align-items: center;
 }
+
+.qrcode-wrapper {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+
+.qrcode-canvas {
+  width: 160px;
+  height: 160px;
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+}
+
+.form-item-tip {
+  font-size: 12px;
+  color: #909399;
+}
 </style>

+ 184 - 10
frontend/src/views/module_payment/account/components/BatchPayCreate.vue

@@ -74,9 +74,29 @@
     </el-form>
 
     <el-divider content-position="left">收款明细(1-1000 笔,单笔金额 ≥ 1 元)</el-divider>
+    <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 16px">
+      <el-button type="primary" plain icon="Download" @click="downloadTemplate">
+        下载明细模板
+      </el-button>
+      <el-upload
+        action=""
+        :auto-upload="false"
+        :on-change="handleImportFileChange"
+        accept=".xlsx,.xls"
+        :file-list="importFileList"
+        :limit="1"
+      >
+        <el-button type="primary" plain icon="Upload">导入 Excel 明细</el-button>
+        <template #tip>
+          <div class="el-upload__tip">
+            模板列:收款方账号 / 账号类型 / 收款方姓名 / 金额(元) / 备注;解析失败不影响已有明细
+          </div>
+        </template>
+      </el-upload>
+    </div>
     <el-table :data="detailRows" border stripe>
       <template #empty>
-        <el-empty description="暂无明细,请添加明细行" />
+        <el-empty description="暂无明细,请添加明细行或导入 Excel" />
       </template>
       <el-table-column label="明细单号" min-width="200">
         <template #default="{ row }">
@@ -161,6 +181,7 @@ import BatchPayAPI from "@/api/module_payment/batch";
 import { useEnterpriseStore } from "@/store";
 import { ElMessage } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
+import * as ExcelJS from "exceljs";
 
 const emit = defineEmits<{ created: [] }>();
 
@@ -207,6 +228,8 @@ const IDENTITY_TYPE_OPTIONS = [
   { value: "ALIPAY_OPEN_ID", label: "支付宝账户(OpenID)" },
 ];
 
+const IDENTITY_TYPE_CODES = IDENTITY_TYPE_OPTIONS.map((o) => o.value);
+
 interface DetailRow {
   out_biz_no: string;
   payee_identity: string;
@@ -218,12 +241,17 @@ interface DetailRow {
 
 const detailRows = ref<DetailRow[]>([]);
 const reportInfos = ref<Array<{ info_type: string; info_content: string }>>([]);
+const importFileList = ref<any[]>([]);
 
 let detailSeq = 0;
-function addDetailRow() {
+function genOutBizNo() {
   detailSeq += 1;
+  return `D${Date.now()}${String(detailSeq).padStart(2, "0")}`;
+}
+
+function addDetailRow() {
   detailRows.value.push({
-    out_biz_no: `D${Date.now()}${String(detailSeq).padStart(2, "0")}`,
+    out_biz_no: genOutBizNo(),
     payee_identity: "",
     payee_identity_type: "ALIPAY_LOGON_ID",
     payee_name: "",
@@ -232,6 +260,136 @@ function addDetailRow() {
   });
 }
 
+/** 下载 Excel 模板(类型列带下拉校验 + 示例行) */
+async function downloadTemplate() {
+  const workbook = new ExcelJS.Workbook();
+  const worksheet = workbook.addWorksheet("批量付款明细模板");
+  worksheet.columns = [
+    { header: "收款方账号(必填)", key: "payee_identity", width: 28 },
+    {
+      header: "账号类型(ALIPAY_LOGON_ID / ALIPAY_USER_ID / ALIPAY_OPEN_ID)",
+      key: "payee_identity_type",
+      width: 32,
+    },
+    { header: "收款方姓名(LOGON_ID 必填,其余选填)", key: "payee_name", width: 22 },
+    { header: "金额(元)(必填,≥1)", key: "amount", width: 16 },
+    { header: "备注(选填)", key: "remark", width: 24 },
+  ];
+  worksheet.addRow({
+    payee_identity: "13800000000",
+    payee_identity_type: "ALIPAY_LOGON_ID",
+    payee_name: "张三",
+    amount: 100,
+    remark: "8月佣金",
+  });
+  // 类型列下拉数据校验(2-1001 行,覆盖 1000 条明细上限)
+  for (let row = 2; row <= 1001; row++) {
+    worksheet.getCell(`B${row}`).dataValidation = {
+      type: "list",
+      formulae: [`"${IDENTITY_TYPE_CODES.join(",")}"`],
+      allowBlank: true,
+    };
+  }
+  const buffer = await workbook.xlsx.writeBuffer();
+  const blob = new Blob([buffer], {
+    type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+  });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement("a");
+  a.href = url;
+  a.download = "批量付款明细模板.xlsx";
+  a.click();
+  URL.revokeObjectURL(url);
+}
+
+/** 单元格取值(兼容 rich text 对象) */
+function cellText(cell: ExcelJS.Cell): string {
+  const v = cell.value;
+  if (v == null) return "";
+  if (typeof v === "object" && "text" in v) return String((v as { text: string }).text).trim();
+  return String(v).trim();
+}
+
+/** 解析 Excel 并追加到明细行;任何错误都不破坏现有手填行 */
+async function parseImportFile(file: File) {
+  let workbook: ExcelJS.Workbook;
+  try {
+    workbook = new ExcelJS.Workbook();
+    await workbook.xlsx.load(await file.arrayBuffer());
+  } catch (e) {
+    console.error(e);
+    ElMessage.error("文件解析失败,请使用模板 .xlsx 格式");
+    return;
+  }
+  const worksheet = workbook.worksheets[0];
+  const parsed: DetailRow[] = [];
+  const errors: string[] = [];
+
+  worksheet.eachRow((row, rowNumber) => {
+    if (rowNumber === 1) return; // 表头
+    const payeeIdentity = cellText(row.getCell(1));
+    const identityTypeRaw = cellText(row.getCell(2));
+    const payeeName = cellText(row.getCell(3));
+    const amountRaw = cellText(row.getCell(4));
+    const remark = cellText(row.getCell(5));
+
+    // 整行空白跳过
+    if (!payeeIdentity && !identityTypeRaw && !payeeName && !amountRaw && !remark) return;
+
+    const rowErrors: string[] = [];
+    const identityType = identityTypeRaw
+      ? IDENTITY_TYPE_CODES.find((c) => c === identityTypeRaw) || ""
+      : "ALIPAY_LOGON_ID";
+    if (!payeeIdentity) rowErrors.push("收款方账号为空");
+    if (identityTypeRaw && !identityType) rowErrors.push(`账号类型非法: ${identityTypeRaw}`);
+    if (identityType === "ALIPAY_LOGON_ID" && !payeeName) rowErrors.push("收款方姓名为空");
+    const amount = Number(amountRaw);
+    if (!amountRaw || Number.isNaN(amount)) rowErrors.push("金额非法");
+    else if (amount <= 0) rowErrors.push("金额必须大于 0");
+
+    if (rowErrors.length) {
+      errors.push(`第 ${rowNumber} 行: ${rowErrors.join("; ")}`);
+      return;
+    }
+    parsed.push({
+      out_biz_no: "",
+      payee_identity: payeeIdentity,
+      payee_identity_type: identityType,
+      payee_name: payeeName,
+      amount,
+      remark,
+    });
+  });
+
+  if (errors.length) {
+    ElMessage.error(
+      `Excel 校验失败 ${errors.length} 处(最多显示前 3 处): ${errors.slice(0, 3).join(" | ")}`
+    );
+    return;
+  }
+  if (parsed.length === 0) {
+    ElMessage.warning("Excel 中未解析到有效明细行");
+    return;
+  }
+  if (detailRows.value.length + parsed.length > 1000) {
+    ElMessage.warning(
+      `导入后将超过 1000 笔上限(现有 ${detailRows.value.length} 笔 + 导入 ${parsed.length} 笔),请分批导入`
+    );
+    return;
+  }
+  parsed.forEach((r) => {
+    r.out_biz_no = genOutBizNo();
+    detailRows.value.push(r);
+  });
+  ElMessage.success(`已导入 ${parsed.length} 条明细`);
+}
+
+async function handleImportFileChange(file: any) {
+  importFileList.value = [file.raw];
+  await parseImportFile(file.raw);
+  importFileList.value = [];
+}
+
 async function handleSubmit() {
   if (!enterpriseId.value) {
     ElMessage.warning("请先选择企业");
@@ -247,13 +405,23 @@ async function handleSubmit() {
     ElMessage.warning("每批明细最多 1000 笔");
     return;
   }
+  // 后端规则:仅 LOGON_ID 时姓名必填(校验姓名一致),UID/OPEN_ID 选填
   const invalidRow = detailRows.value.find(
-    (r) => !r.out_biz_no || !r.payee_identity || !r.payee_name || r.amount == null || r.amount <= 0
+    (r) =>
+      !r.out_biz_no ||
+      !r.payee_identity ||
+      (r.payee_identity_type === "ALIPAY_LOGON_ID" && !r.payee_name) ||
+      r.amount == null ||
+      r.amount <= 0
   );
   if (invalidRow) {
-    ElMessage.warning("收款明细存在未填写完整的行(明细单号/账号/姓名/金额必填)");
+    ElMessage.warning("收款明细存在未填写完整的行(明细单号/账号/金额必填,LOGON_ID 需姓名)");
     return;
   }
+  // 过滤 info_type 与 info_content 均为空的行
+  const sceneReportInfos = reportInfos.value
+    .filter((r) => r.info_type.trim() !== "" || r.info_content.trim() !== "")
+    .map((r) => ({ info_type: r.info_type.trim(), info_content: r.info_content.trim() }));
   submitting.value = true;
   try {
     const res = await BatchPayAPI.batchCreate({
@@ -262,9 +430,7 @@ async function handleSubmit() {
       payer_uid: form.payer_uid,
       agreement_no: form.agreement_no || undefined,
       transfer_scene_name: form.transfer_scene_name || undefined,
-      transfer_scene_report_infos: reportInfos.value.length
-        ? reportInfos.value.map((r) => ({ info_type: r.info_type, info_content: r.info_content }))
-        : undefined,
+      transfer_scene_report_infos: sceneReportInfos.length ? sceneReportInfos : undefined,
       time_expire: form.time_expire || undefined,
       remark: form.remark || undefined,
       details: detailRows.value.map((r) => ({
@@ -278,19 +444,27 @@ async function handleSubmit() {
     });
     createdResult.value = res.data.data;
     emit("created");
+    // 成功后清空表单,保留成功提示
+    clearForm();
   } finally {
     submitting.value = false;
   }
 }
 
-function handleReset() {
+function clearForm() {
   formRef.value?.resetFields();
   detailRows.value = [];
   reportInfos.value = [];
-  createdResult.value = null;
+  importFileList.value = [];
+  detailSeq = 0;
   addDetailRow();
 }
 
+function handleReset() {
+  clearForm();
+  createdResult.value = null;
+}
+
 onMounted(addDetailRow);
 </script>