فهرست منبع

feat: 批量付款 - 前端页面(授权/列表/创建/详情)

alphaH 1 هفته پیش
والد
کامیت
bb36e4a2c7

+ 175 - 0
frontend/src/api/module_payment/batch.ts

@@ -0,0 +1,175 @@
+import request from "@/utils/request";
+
+const API_PATH = "/payment/account/batch";
+
+/** 制单授权记录(后端 BatchAuthorizeEntity,全局 SNAKE_CASE 序列化) */
+export interface BatchAuthorizeVO {
+  out_biz_no: string;
+  status: string;
+  agreement_no?: string;
+  authorize_link?: string;
+  participant_id: string;
+  participant_id_type?: string;
+  authorize_expire_time?: string;
+  created_time?: string;
+}
+
+/** 批次(后端 BatchOrderEntity) */
+export interface BatchOrderVO {
+  out_batch_no: string;
+  batch_trans_id?: string;
+  total_amount: string;
+  total_count: number;
+  order_title?: string;
+  /** INIT / SUCCESS / DISUSE / FAIL */
+  status: string;
+  created_time?: string;
+  pay_url?: string;
+  enterprise_id?: string;
+  payer_uid?: string;
+  agreement_no?: string;
+  transfer_scene_name?: string;
+  time_expire?: string;
+  remark?: string;
+  error_code?: string;
+  error_msg?: string;
+}
+
+/** 批次明细(后端 BatchDetailEntity) */
+export interface BatchDetailItem {
+  out_biz_no: string;
+  amount: string;
+  remark?: string;
+  payee_identity: string;
+  /** ALIPAY_LOGON_ID / ALIPAY_USER_ID / ALIPAY_OPEN_ID(默认 LOGON_ID) */
+  payee_identity_type?: string;
+  payee_name?: string;
+  status?: string;
+  error_code?: string;
+  error_msg?: string;
+}
+
+/** 创建批次请求体(BatchCreateDTO,snake_case) */
+export interface BatchCreateParams {
+  enterprise_id?: string;
+  order_title: string;
+  payer_uid: string;
+  agreement_no?: string;
+  transfer_scene_name?: string;
+  transfer_scene_report_infos?: Array<{ info_type: string; info_content: string }>;
+  time_expire?: string;
+  remark?: string;
+  details: BatchDetailItem[];
+}
+
+export const BatchPayAPI = {
+  /** 生成制单授权链接(PC 渠道,付款方 UID 必须 2088 开头) */
+  authorizeApply(enterpriseId: string, participantId: string) {
+    return request<ApiResponse<{ authorize_link: string; out_biz_no: string; status: string }>>({
+      url: `${API_PATH}/authorize/apply`,
+      method: "post",
+      data: { enterprise_id: enterpriseId, participant_id: participantId },
+    });
+  },
+
+  /** 查询制单授权状态(AUTHED 时回写协议号) */
+  queryAuthorize(enterpriseId: string, outBizNo: string) {
+    return request<ApiResponse<{ agreement_no: string; status: string }>>({
+      url: `${API_PATH}/authorize/query`,
+      method: "get",
+      params: { enterprise_id: enterpriseId, out_biz_no: outBizNo },
+    });
+  },
+
+  /** 制单授权记录(分页) */
+  authorizeList(params: { enterprise_id?: string; page_no?: number; page_size?: number }) {
+    return request<ApiResponse<PageResult<BatchAuthorizeVO[]>>>({
+      url: `${API_PATH}/authorize/list`,
+      method: "get",
+      params,
+    });
+  },
+
+  /** 创建批次(幂等键 out_batch_no 由后端生成) */
+  batchCreate(data: BatchCreateParams) {
+    return request<ApiResponse<{ out_batch_no: string; batch_trans_id: string; status: string }>>({
+      url: `${API_PATH}/create`,
+      method: "post",
+      data,
+    });
+  },
+
+  /** 生成 PC 支付页面 — pageExecute 返回 HTML 表单(与充值/签约同款处理) */
+  renderPay(enterpriseId: string, outBatchNo: string) {
+    return request<ApiResponse<{ pay_url: string }>>({
+      url: `${API_PATH}/pay`,
+      method: "post",
+      data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
+    });
+  },
+
+  /** 查询批次状态(同步支付宝并回写 DB) */
+  batchQuery(enterpriseId: string, outBatchNo: string) {
+    return request<ApiResponse<{ out_batch_no: string; status: string }>>({
+      url: `${API_PATH}/query`,
+      method: "get",
+      params: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
+    });
+  },
+
+  /** 关闭未支付批次 */
+  batchClose(enterpriseId: string, outBatchNo: string) {
+    return request<ApiResponse<{ status: string }>>({
+      url: `${API_PATH}/close`,
+      method: "post",
+      data: { enterprise_id: enterpriseId, out_batch_no: outBatchNo },
+    });
+  },
+
+  /** 批次列表(分页,状态/时间筛选) */
+  batchList(params: {
+    enterprise_id?: string;
+    status?: string;
+    start_time?: string;
+    end_time?: string;
+    page_no?: number;
+    page_size?: number;
+  }) {
+    return request<ApiResponse<PageResult<BatchOrderVO[]>>>({
+      url: `${API_PATH}/list`,
+      method: "get",
+      params,
+    });
+  },
+
+  /** 批次详情 + 明细分页(后端要求 enterprise_id 租户隔离) */
+  batchDetail(enterpriseId: string, outBatchNo: string, pageNo = 1, pageSize = 20) {
+    return request<ApiResponse<{ order: BatchOrderVO; details: PageResult<BatchDetailItem[]> }>>({
+      url: `${API_PATH}/detail`,
+      method: "get",
+      params: {
+        enterprise_id: enterpriseId,
+        out_batch_no: outBatchNo,
+        page_no: pageNo,
+        page_size: pageSize,
+      },
+    });
+  },
+
+  /** 批次报表导出(xlsx 下载,接口直出字节流非 Result 包装) */
+  batchExport(params: {
+    enterprise_id?: string;
+    status?: string;
+    start_time?: string;
+    end_time?: string;
+  }) {
+    return request({
+      url: `${API_PATH}/export`,
+      method: "get",
+      params,
+      responseType: "blob",
+    });
+  },
+};
+
+export default BatchPayAPI;

+ 122 - 0
frontend/src/views/module_payment/account/components/BatchPayAuthorize.vue

@@ -0,0 +1,122 @@
+<template>
+  <el-card>
+    <template #header>
+      <div class="card-header">
+        <span>制单授权(批量付款到户有密)</span>
+      </div>
+    </template>
+    <el-form label-width="160px">
+      <el-form-item label="付款方支付宝UID">
+        <el-input
+          v-model="participantId"
+          placeholder="企业财务的支付宝 UID(2088 开头)"
+          style="max-width: 420px"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button
+          v-hasPerm="['module_payment:account:authorize']"
+          type="primary"
+          :loading="applying"
+          @click="handleApply"
+        >
+          生成授权链接
+        </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>
+      </el-form-item>
+      <el-form-item v-if="outBizNo" label="授权单号 / 状态">
+        <span>{{ outBizNo }} / {{ statusText }}</span>
+        <el-button
+          v-hasPerm="['module_payment:account:authorize']"
+          size="small"
+          style="margin-left: 12px"
+          :loading="querying"
+          @click="handleQuery"
+        >
+          刷新状态
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from "vue";
+import BatchPayAPI from "@/api/module_payment/batch";
+import { useEnterpriseStore } from "@/store";
+import { ElMessage } from "element-plus";
+
+const enterpriseStore = useEnterpriseStore();
+const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
+
+const participantId = ref("");
+const link = ref("");
+const outBizNo = ref("");
+const agreementNo = ref("");
+const status = ref("");
+const applying = ref(false);
+const querying = ref(false);
+
+const AUTH_STATUS_TEXT: Record<string, string> = {
+  AUTHING: "授权中",
+  AUTHED: "已授权",
+  UNBIND: "已解绑",
+};
+
+const statusText = computed(() => {
+  const s = AUTH_STATUS_TEXT[status.value] || status.value || "-";
+  return agreementNo.value ? `${s}(协议号 ${agreementNo.value})` : s;
+});
+
+async function handleApply() {
+  if (!enterpriseId.value || !participantId.value) {
+    ElMessage.warning("请选择企业并填写付款方支付宝 UID");
+    return;
+  }
+  applying.value = true;
+  try {
+    const res = await BatchPayAPI.authorizeApply(enterpriseId.value, participantId.value);
+    link.value = res.data.data.authorize_link;
+    outBizNo.value = res.data.data.out_biz_no;
+    status.value = res.data.data.status;
+    agreementNo.value = "";
+  } finally {
+    applying.value = false;
+  }
+}
+
+async function handleQuery() {
+  if (!enterpriseId.value || !outBizNo.value) return;
+  querying.value = true;
+  try {
+    const res = await BatchPayAPI.queryAuthorize(enterpriseId.value, outBizNo.value);
+    status.value = res.data.data.status;
+    agreementNo.value = res.data.data.agreement_no || "";
+  } finally {
+    querying.value = false;
+  }
+}
+
+async function handleCopyLink() {
+  try {
+    await navigator.clipboard.writeText(link.value);
+    ElMessage.success("授权链接已复制");
+  } catch {
+    ElMessage.warning("复制失败,请手动复制");
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 310 - 0
frontend/src/views/module_payment/account/components/BatchPayCreate.vue

@@ -0,0 +1,310 @@
+<template>
+  <el-card>
+    <template #header>
+      <div class="card-header">
+        <span>创建批量付款批次</span>
+      </div>
+    </template>
+    <el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
+      <el-form-item label="批次标题" prop="order_title">
+        <el-input
+          v-model="form.order_title"
+          placeholder="展示在付款方账单,如:8月佣金发放"
+          style="max-width: 420px"
+        />
+      </el-form-item>
+      <el-form-item label="付款方支付宝UID" prop="payer_uid">
+        <el-input v-model="form.payer_uid" placeholder="2088 开头" style="max-width: 420px" />
+      </el-form-item>
+      <el-form-item label="协议号">
+        <el-input
+          v-model="form.agreement_no"
+          placeholder="选填,指定制单授权协议号"
+          style="max-width: 420px"
+        />
+      </el-form-item>
+      <el-form-item label="转账场景">
+        <el-select
+          v-model="form.transfer_scene_name"
+          placeholder="请选择转账场景"
+          clearable
+          style="max-width: 420px"
+        >
+          <el-option v-for="s in SCENE_OPTIONS" :key="s" :label="s" :value="s" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="场景报备信息">
+        <div
+          v-for="(r, idx) in reportInfos"
+          :key="idx"
+          style="display: flex; gap: 8px; margin-bottom: 8px"
+        >
+          <el-input v-model="r.info_type" placeholder="报备信息类型" style="width: 200px" />
+          <el-input v-model="r.info_content" placeholder="报备信息内容" style="width: 320px" />
+          <el-button type="danger" plain @click="reportInfos.splice(idx, 1)">删除</el-button>
+        </div>
+        <el-button
+          type="primary"
+          plain
+          @click="reportInfos.push({ info_type: '', info_content: '' })"
+        >
+          添加报备信息
+        </el-button>
+        <div class="form-item-tip">2026 年新接入商户选填,按支付宝要求填写</div>
+      </el-form-item>
+      <el-form-item label="超时时间">
+        <el-date-picker
+          v-model="form.time_expire"
+          type="datetime"
+          placeholder="选填,默认30天"
+          format="YYYY-MM-DD HH:mm"
+          value-format="YYYY-MM-DD HH:mm"
+          style="max-width: 420px"
+        />
+      </el-form-item>
+      <el-form-item label="备注">
+        <el-input
+          v-model="form.remark"
+          type="textarea"
+          :rows="2"
+          placeholder="选填,业务备注"
+          style="max-width: 420px"
+        />
+      </el-form-item>
+    </el-form>
+
+    <el-divider content-position="left">收款明细(1-1000 笔,单笔金额 ≥ 1 元)</el-divider>
+    <el-table :data="detailRows" border stripe>
+      <template #empty>
+        <el-empty description="暂无明细,请添加明细行" />
+      </template>
+      <el-table-column label="明细单号" min-width="200">
+        <template #default="{ row }">
+          <el-input v-model="row.out_biz_no" placeholder="明细外部单号" />
+        </template>
+      </el-table-column>
+      <el-table-column label="收款方账号" min-width="190">
+        <template #default="{ row }">
+          <el-input v-model="row.payee_identity" placeholder="手机号/邮箱/UID/OpenID" />
+        </template>
+      </el-table-column>
+      <el-table-column label="账号类型" width="210">
+        <template #default="{ row }">
+          <el-select v-model="row.payee_identity_type" style="width: 100%">
+            <el-option
+              v-for="o in IDENTITY_TYPE_OPTIONS"
+              :key="o.value"
+              :label="o.label"
+              :value="o.value"
+            />
+          </el-select>
+        </template>
+      </el-table-column>
+      <el-table-column label="收款方姓名" width="140">
+        <template #default="{ row }">
+          <el-input v-model="row.payee_name" placeholder="LOGON_ID 必填" />
+        </template>
+      </el-table-column>
+      <el-table-column label="金额(元)" width="150">
+        <template #default="{ row }">
+          <el-input-number
+            v-model="row.amount"
+            :min="1"
+            :precision="2"
+            :controls="false"
+            style="width: 100%"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="备注" min-width="140">
+        <template #default="{ row }">
+          <el-input v-model="row.remark" placeholder="展示在收款方账单" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="70" fixed="right">
+        <template #default="{ $index }">
+          <el-button type="danger" link @click="detailRows.splice($index, 1)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <div style="margin-top: 12px">
+      <el-button type="primary" plain @click="addDetailRow">添加明细行</el-button>
+    </div>
+
+    <div style="margin-top: 20px">
+      <el-button
+        v-hasPerm="['module_payment:account:transfer']"
+        type="primary"
+        :loading="submitting"
+        @click="handleSubmit"
+      >
+        创建批次
+      </el-button>
+      <el-button @click="handleReset">重置</el-button>
+    </div>
+
+    <el-alert
+      v-if="createdResult"
+      type="success"
+      :closable="false"
+      style="margin-top: 16px"
+      :title="`批次创建成功:${createdResult.out_batch_no}`"
+    >
+      支付宝批次号:{{ createdResult.batch_trans_id || "-" }},状态:{{ createdResult.status }}
+    </el-alert>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from "vue";
+import BatchPayAPI from "@/api/module_payment/batch";
+import { useEnterpriseStore } from "@/store";
+import { ElMessage } from "element-plus";
+import type { FormInstance, FormRules } from "element-plus";
+
+const emit = defineEmits<{ created: [] }>();
+
+const enterpriseStore = useEnterpriseStore();
+const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
+
+const formRef = ref<FormInstance>();
+const submitting = ref(false);
+const createdResult = ref<{
+  out_batch_no: string;
+  batch_trans_id: string;
+  status: string;
+} | null>(null);
+
+const form = reactive({
+  order_title: "",
+  payer_uid: "",
+  agreement_no: "",
+  transfer_scene_name: "",
+  time_expire: "",
+  remark: "",
+});
+
+const rules: FormRules = {
+  order_title: [{ required: true, message: "请输入批次标题", trigger: "blur" }],
+  payer_uid: [{ required: true, message: "请输入付款方支付宝 UID", trigger: "blur" }],
+};
+
+/** 26 年新接入商户可选转账场景(与后端 BatchCreateDTO 注释一致) */
+const SCENE_OPTIONS = [
+  "现金营销",
+  "企业退款",
+  "佣金报酬",
+  "二手回收",
+  "业务结算",
+  "公益补助",
+  "行政补贴和退款",
+  "保险理赔",
+];
+
+const IDENTITY_TYPE_OPTIONS = [
+  { value: "ALIPAY_LOGON_ID", label: "支付宝账户(手机号/邮箱)" },
+  { value: "ALIPAY_USER_ID", label: "支付宝账户(UID)" },
+  { value: "ALIPAY_OPEN_ID", label: "支付宝账户(OpenID)" },
+];
+
+interface DetailRow {
+  out_biz_no: string;
+  payee_identity: string;
+  payee_identity_type: string;
+  payee_name: string;
+  amount: number | null;
+  remark: string;
+}
+
+const detailRows = ref<DetailRow[]>([]);
+const reportInfos = ref<Array<{ info_type: string; info_content: string }>>([]);
+
+let detailSeq = 0;
+function addDetailRow() {
+  detailSeq += 1;
+  detailRows.value.push({
+    out_biz_no: `D${Date.now()}${String(detailSeq).padStart(2, "0")}`,
+    payee_identity: "",
+    payee_identity_type: "ALIPAY_LOGON_ID",
+    payee_name: "",
+    amount: null,
+    remark: "",
+  });
+}
+
+async function handleSubmit() {
+  if (!enterpriseId.value) {
+    ElMessage.warning("请先选择企业");
+    return;
+  }
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+  if (detailRows.value.length === 0) {
+    ElMessage.warning("请至少添加一条收款明细");
+    return;
+  }
+  if (detailRows.value.length > 1000) {
+    ElMessage.warning("每批明细最多 1000 笔");
+    return;
+  }
+  const invalidRow = detailRows.value.find(
+    (r) => !r.out_biz_no || !r.payee_identity || !r.payee_name || r.amount == null || r.amount <= 0
+  );
+  if (invalidRow) {
+    ElMessage.warning("收款明细存在未填写完整的行(明细单号/账号/姓名/金额必填)");
+    return;
+  }
+  submitting.value = true;
+  try {
+    const res = await BatchPayAPI.batchCreate({
+      enterprise_id: enterpriseId.value,
+      order_title: form.order_title,
+      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,
+      time_expire: form.time_expire || undefined,
+      remark: form.remark || undefined,
+      details: detailRows.value.map((r) => ({
+        out_biz_no: r.out_biz_no,
+        amount: String(r.amount),
+        remark: r.remark || undefined,
+        payee_identity: r.payee_identity,
+        payee_identity_type: r.payee_identity_type,
+        payee_name: r.payee_name,
+      })),
+    });
+    createdResult.value = res.data.data;
+    emit("created");
+  } finally {
+    submitting.value = false;
+  }
+}
+
+function handleReset() {
+  formRef.value?.resetFields();
+  detailRows.value = [];
+  reportInfos.value = [];
+  createdResult.value = null;
+  addDetailRow();
+}
+
+onMounted(addDetailRow);
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.form-item-tip {
+  width: 100%;
+  margin-top: 4px;
+  font-size: 12px;
+  color: #909399;
+}
+</style>

+ 197 - 0
frontend/src/views/module_payment/account/components/BatchPayDetail.vue

@@ -0,0 +1,197 @@
+<template>
+  <el-card v-loading="loading">
+    <template v-if="order" #header>
+      <div class="card-header">
+        <span>批次详情 {{ order.out_batch_no }}</span>
+        <div>
+          <el-button
+            v-if="order.status === 'INIT'"
+            v-hasPerm="['module_payment:account:transfer']"
+            type="primary"
+            :loading="payLoading"
+            @click="handlePay"
+          >
+            生成支付链接
+          </el-button>
+          <el-button
+            v-if="order.status === 'INIT'"
+            v-hasPerm="['module_payment:account:transfer']"
+            type="danger"
+            @click="handleClose"
+          >
+            关闭批次
+          </el-button>
+          <el-button icon="Refresh" @click="load">刷新</el-button>
+        </div>
+      </div>
+    </template>
+
+    <el-descriptions v-if="order" :column="3" border style="margin-bottom: 16px">
+      <el-descriptions-item label="标题">{{ order.order_title || "-" }}</el-descriptions-item>
+      <el-descriptions-item label="总金额(元)">¥{{ order.total_amount }}</el-descriptions-item>
+      <el-descriptions-item label="笔数">{{ order.total_count }}</el-descriptions-item>
+      <el-descriptions-item label="状态">
+        <el-tag :type="STATUS_TAG[order.status] || 'info'">
+          {{ STATUS_TEXT[order.status] || order.status }}
+        </el-tag>
+      </el-descriptions-item>
+      <el-descriptions-item label="支付宝批次号">
+        {{ order.batch_trans_id || "-" }}
+      </el-descriptions-item>
+      <el-descriptions-item label="创建时间">
+        {{ order.created_time ? dayjs(order.created_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
+      </el-descriptions-item>
+      <el-descriptions-item label="转账场景">
+        {{ order.transfer_scene_name || "-" }}
+      </el-descriptions-item>
+      <el-descriptions-item label="付款方UID">{{ order.payer_uid || "-" }}</el-descriptions-item>
+      <el-descriptions-item label="备注">{{ order.remark || "-" }}</el-descriptions-item>
+      <el-descriptions-item v-if="order.error_msg" label="错误信息">
+        <span style="color: #f56c6c">{{ order.error_msg }}</span>
+      </el-descriptions-item>
+    </el-descriptions>
+
+    <el-table v-if="order" :data="details" border stripe>
+      <template #empty>
+        <el-empty description="暂无数据" />
+      </template>
+      <el-table-column prop="out_biz_no" label="明细单号" min-width="200" />
+      <el-table-column prop="payee_name" label="收款方姓名" width="120">
+        <template #default="{ row }">{{ row.payee_name || "-" }}</template>
+      </el-table-column>
+      <el-table-column prop="payee_identity" label="收款方账号" width="200" />
+      <el-table-column prop="amount" label="金额(元)" width="110">
+        <template #default="{ row }">¥{{ row.amount }}</template>
+      </el-table-column>
+      <el-table-column label="状态" width="90">
+        <template #default="{ row }">
+          <el-tag :type="DETAIL_STATUS_TAG[row.status] || 'info'">
+            {{ DETAIL_STATUS_TEXT[row.status] || row.status }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="error_msg" label="失败原因" min-width="140">
+        <template #default="{ row }">{{ row.error_msg || "-" }}</template>
+      </el-table-column>
+    </el-table>
+    <div v-if="order" 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>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref } from "vue";
+import BatchPayAPI, { type BatchDetailItem, type BatchOrderVO } from "@/api/module_payment/batch";
+import { useEnterpriseStore } from "@/store";
+import { ElMessage, ElMessageBox } from "element-plus";
+import dayjs from "dayjs";
+
+const props = defineProps<{ outBatchNo: string; enterpriseId?: string }>();
+
+const enterpriseStore = useEnterpriseStore();
+const currentEnterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
+const enterpriseId = computed(() => props.enterpriseId || currentEnterpriseId.value);
+
+const order = ref<BatchOrderVO | null>(null);
+const details = ref<BatchDetailItem[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const payLoading = ref(false);
+
+type TagType = "primary" | "success" | "warning" | "info" | "danger";
+const STATUS_TAG: Record<string, TagType> = {
+  INIT: "warning",
+  SUCCESS: "success",
+  DISUSE: "info",
+  FAIL: "danger",
+};
+const STATUS_TEXT: Record<string, string> = {
+  INIT: "受理中",
+  SUCCESS: "成功",
+  DISUSE: "已关闭",
+  FAIL: "失败",
+};
+const DETAIL_STATUS_TAG: Record<string, TagType> = {
+  INIT: "info",
+  SUCCESS: "success",
+  FAIL: "danger",
+};
+const DETAIL_STATUS_TEXT: Record<string, string> = {
+  INIT: "处理中",
+  SUCCESS: "成功",
+  FAIL: "失败",
+};
+
+async function load() {
+  if (!enterpriseId.value) {
+    ElMessage.warning("未获取到企业信息");
+    return;
+  }
+  loading.value = true;
+  try {
+    const res = await BatchPayAPI.batchDetail(
+      enterpriseId.value,
+      props.outBatchNo,
+      pageNo.value,
+      pageSize.value
+    );
+    order.value = res.data.data?.order || null;
+    details.value = res.data.data?.details?.items || [];
+    total.value = res.data.data?.details?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function handlePay() {
+  if (!order.value || !enterpriseId.value) return;
+  payLoading.value = true;
+  try {
+    const res = await BatchPayAPI.renderPay(enterpriseId.value, order.value.out_batch_no);
+    // pageExecute 返回 HTML 表单,自带自动提交(与充值/签约链接处理一致)
+    const payHtml = res.data.data?.pay_url || "";
+    if (!payHtml) {
+      ElMessage.warning("未获取到支付页面");
+      return;
+    }
+    document.open();
+    document.write(payHtml);
+    document.close();
+  } finally {
+    payLoading.value = false;
+  }
+}
+
+async function handleClose() {
+  if (!order.value || !enterpriseId.value) return;
+  try {
+    await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
+  } catch {
+    return;
+  }
+  await BatchPayAPI.batchClose(enterpriseId.value, order.value.out_batch_no);
+  ElMessage.success("批次已关闭");
+  load();
+}
+
+onMounted(load);
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 295 - 0
frontend/src/views/module_payment/account/components/BatchPayList.vue

@@ -0,0 +1,295 @@
+<template>
+  <el-card>
+    <template #header>
+      <div class="card-header">
+        <span>批量付款批次</span>
+        <el-button
+          v-hasPerm="['module_payment:account:transfer:list']"
+          type="primary"
+          icon="Download"
+          :loading="exportLoading"
+          @click="handleExport"
+        >
+          导出报表
+        </el-button>
+      </div>
+    </template>
+    <div class="mb-4">
+      <el-form :inline="true" :model="searchForm">
+        <el-form-item v-if="isPlatformUser" label="企业">
+          <el-select
+            v-model="searchForm.enterprise_id"
+            placeholder="选择企业"
+            clearable
+            filterable
+            style="width: 180px"
+          >
+            <el-option
+              v-for="e in enterpriseStore.getEnterpriseList"
+              :key="e.enterprise_id"
+              :label="e.name"
+              :value="e.enterprise_id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 140px">
+            <el-option v-for="(t, s) in STATUS_TEXT" :key="s" :label="t" :value="s" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="时间范围">
+          <el-date-picker
+            v-model="searchForm.dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="YYYY-MM-DD"
+            style="width: 260px"
+            @change="handleDateChange"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleSearchReset">重置</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="out_batch_no" label="批次号" min-width="200" />
+      <el-table-column prop="order_title" label="标题" min-width="140">
+        <template #default="{ row }">{{ row.order_title || "-" }}</template>
+      </el-table-column>
+      <el-table-column prop="total_amount" label="总金额(元)" width="110">
+        <template #default="{ row }">¥{{ row.total_amount }}</template>
+      </el-table-column>
+      <el-table-column prop="total_count" label="笔数" width="70" />
+      <el-table-column label="状态" width="90">
+        <template #default="{ row }">
+          <el-tag :type="STATUS_TAG[row.status] || 'info'">
+            {{ STATUS_TEXT[row.status] || row.status }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="created_time" label="创建时间" width="170">
+        <template #default="{ row }">
+          {{ row.created_time ? dayjs(row.created_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="210" fixed="right">
+        <template #default="{ row }">
+          <el-button
+            v-if="row.status === 'INIT'"
+            v-hasPerm="['module_payment:account:transfer']"
+            size="small"
+            type="primary"
+            :loading="payingNo === row.out_batch_no"
+            @click="handlePay(row)"
+          >
+            支付
+          </el-button>
+          <el-button
+            v-hasPerm="['module_payment:account:transfer:detail']"
+            size="small"
+            @click="emit('view', row.out_batch_no, row.enterprise_id)"
+          >
+            详情
+          </el-button>
+          <el-button
+            v-if="row.status === 'INIT'"
+            v-hasPerm="['module_payment:account:transfer']"
+            size="small"
+            type="danger"
+            @click="handleClose(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>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from "vue";
+import BatchPayAPI, { type BatchOrderVO } from "@/api/module_payment/batch";
+import { useEnterpriseStore, useUserStore } from "@/store";
+import { ElMessage, ElMessageBox } from "element-plus";
+import dayjs from "dayjs";
+
+const emit = defineEmits<{ view: [outBatchNo: string, enterpriseId?: string] }>();
+
+const enterpriseStore = useEnterpriseStore();
+const userStore = useUserStore();
+const isPlatformUser = computed(() => userStore.is_platform_user);
+const currentEnterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
+
+const list = ref<BatchOrderVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const exportLoading = ref(false);
+const payingNo = ref("");
+
+const searchForm = reactive({
+  enterprise_id: undefined as string | undefined,
+  status: "",
+  dateRange: null as string[] | null,
+  start_time: undefined as string | undefined,
+  end_time: undefined as string | undefined,
+});
+
+type TagType = "primary" | "success" | "warning" | "info" | "danger";
+const STATUS_TAG: Record<string, TagType> = {
+  INIT: "warning",
+  SUCCESS: "success",
+  DISUSE: "info",
+  FAIL: "danger",
+};
+const STATUS_TEXT: Record<string, string> = {
+  INIT: "受理中",
+  SUCCESS: "成功",
+  DISUSE: "已关闭",
+  FAIL: "失败",
+};
+
+async function load() {
+  loading.value = true;
+  try {
+    const res = await BatchPayAPI.batchList({
+      enterprise_id: searchForm.enterprise_id || undefined,
+      status: searchForm.status || undefined,
+      start_time: searchForm.start_time || undefined,
+      end_time: searchForm.end_time || undefined,
+      page_no: pageNo.value,
+      page_size: pageSize.value,
+    });
+    list.value = res.data.data?.items || [];
+    total.value = res.data.data.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  pageNo.value = 1;
+  load();
+}
+
+function handleDateChange() {
+  if (searchForm.dateRange && searchForm.dateRange.length === 2) {
+    searchForm.start_time = searchForm.dateRange[0];
+    searchForm.end_time = searchForm.dateRange[1];
+  } else {
+    searchForm.start_time = undefined;
+    searchForm.end_time = undefined;
+  }
+}
+
+function handleSearchReset() {
+  searchForm.enterprise_id = undefined;
+  searchForm.status = "";
+  searchForm.dateRange = null;
+  searchForm.start_time = undefined;
+  searchForm.end_time = undefined;
+  handleSearch();
+}
+
+async function handlePay(row: BatchOrderVO) {
+  const enterpriseId = row.enterprise_id || currentEnterpriseId.value;
+  if (!enterpriseId) {
+    ElMessage.warning("未获取到企业信息,无法生成支付页面");
+    return;
+  }
+  payingNo.value = row.out_batch_no;
+  try {
+    const res = await BatchPayAPI.renderPay(enterpriseId, row.out_batch_no);
+    // pageExecute 返回 HTML 表单,自带自动提交(与充值/签约链接处理一致)
+    const payHtml = res.data.data?.pay_url || "";
+    if (!payHtml) {
+      ElMessage.warning("未获取到支付页面");
+      return;
+    }
+    document.open();
+    document.write(payHtml);
+    document.close();
+  } finally {
+    payingNo.value = "";
+  }
+}
+
+async function handleClose(row: BatchOrderVO) {
+  const enterpriseId = row.enterprise_id || currentEnterpriseId.value;
+  if (!enterpriseId) {
+    ElMessage.warning("未获取到企业信息,无法关闭批次");
+    return;
+  }
+  try {
+    await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
+  } catch {
+    return;
+  }
+  await BatchPayAPI.batchClose(enterpriseId, row.out_batch_no);
+  ElMessage.success("批次已关闭");
+  load();
+}
+
+async function handleExport() {
+  exportLoading.value = true;
+  try {
+    const res = await BatchPayAPI.batchExport({
+      enterprise_id: searchForm.enterprise_id || undefined,
+      status: searchForm.status || undefined,
+      start_time: searchForm.start_time || undefined,
+      end_time: searchForm.end_time || undefined,
+    });
+    const blob = new Blob([res.data], {
+      type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+    });
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement("a");
+    a.href = url;
+    a.download = "批量付款报表.xlsx";
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+    ElMessage.success("导出成功");
+  } catch {
+    ElMessage.error("导出失败,请稍后重试");
+  } finally {
+    exportLoading.value = false;
+  }
+}
+
+onMounted(() => {
+  if (!isPlatformUser.value) {
+    searchForm.enterprise_id = currentEnterpriseId.value;
+  }
+  load();
+});
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 49 - 0
frontend/src/views/module_payment/account/index.vue

@@ -397,6 +397,33 @@
         </div>
       </el-tab-pane>
 
+      <el-tab-pane v-if="hasTransferPermission" label="批量付款" name="batch-pay">
+        <div class="tab-content">
+          <template v-if="!currentBatchOutNo">
+            <el-tabs v-model="batchPaySubTab">
+              <el-tab-pane label="制单授权" name="authorize">
+                <BatchPayAuthorize />
+              </el-tab-pane>
+              <el-tab-pane label="批次列表" name="list">
+                <BatchPayList :key="batchListKey" @view="handleViewBatchPay" />
+              </el-tab-pane>
+              <el-tab-pane label="创建批次" name="create">
+                <BatchPayCreate @created="handleBatchPayCreated" />
+              </el-tab-pane>
+            </el-tabs>
+          </template>
+          <template v-else>
+            <div style="margin-bottom: 12px">
+              <el-button @click="currentBatchOutNo = ''">返回批次列表</el-button>
+            </div>
+            <BatchPayDetail
+              :out-batch-no="currentBatchOutNo"
+              :enterprise-id="currentBatchEnterpriseId"
+            />
+          </template>
+        </div>
+      </el-tab-pane>
+
       <el-tab-pane label="消费记录" name="consume-record">
         <div class="tab-content">
           <el-card>
@@ -758,6 +785,10 @@ import AccountOverview from "./components/AccountOverview.vue";
 import TransferDetail from "./components/TransferDetail.vue";
 import ConsumeDetail from "./components/ConsumeDetail.vue";
 import F2fTradeRecord from "./components/F2fTradeRecord.vue";
+import BatchPayAuthorize from "./components/BatchPayAuthorize.vue";
+import BatchPayList from "./components/BatchPayList.vue";
+import BatchPayCreate from "./components/BatchPayCreate.vue";
+import BatchPayDetail from "./components/BatchPayDetail.vue";
 import TenantAPI, { TenantTable } from "@/api/module_system/tenant";
 import { ref, reactive, computed, onMounted, watch } from "vue";
 import { Refresh, Loading, Plus, QuestionFilled } from "@element-plus/icons-vue";
@@ -1141,6 +1172,12 @@ const allTenantData = ref<TenantTable[]>([]);
 const transferDetailVisible = ref(false);
 const currentTransferOutBizNo = ref("");
 
+// 批量付款到户有密
+const batchPaySubTab = ref("list");
+const batchListKey = ref(0);
+const currentBatchOutNo = ref("");
+const currentBatchEnterpriseId = ref<string | undefined>(undefined);
+
 // 批量转账相关
 const batchTransferVisible = ref(false);
 const batchTransferResultVisible = ref(false);
@@ -1867,6 +1904,18 @@ function handleViewTransferDetail(outBizNo: string) {
   transferDetailVisible.value = true;
 }
 
+function handleViewBatchPay(outBatchNo: string, enterpriseId?: string) {
+  currentBatchOutNo.value = outBatchNo;
+  currentBatchEnterpriseId.value = enterpriseId;
+}
+
+function handleBatchPayCreated() {
+  // 创建成功后回到批次列表并强制重新加载
+  currentBatchOutNo.value = "";
+  batchListKey.value += 1;
+  batchPaySubTab.value = "list";
+}
+
 function downloadFile(url: string) {
   window.open(url, "_blank");
 }