Browse Source

feat: 批量付款新菜单页面 - 制单授权列表+表单、制单主体下拉、历史主体筛选、account 移除批量tab

alphaH 4 days ago
parent
commit
aa1d7515a3

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

@@ -1,210 +0,0 @@
-<template>
-  <el-card>
-    <template #header>
-      <div class="card-header">
-        <span>制单授权(批量付款到户有密)</span>
-      </div>
-    </template>
-    <el-form label-width="160px">
-      <el-form-item>
-        <el-button
-          v-if="!isAuthed"
-          v-hasPerm="['module_payment:account:authorize']"
-          type="primary"
-          :loading="applying"
-          @click="handleApply"
-        >
-          {{ existingAuthing ? "重新生成授权链接" : "生成授权链接" }}
-        </el-button>
-        <el-button
-          v-if="outBizNo"
-          v-hasPerm="['module_payment:account:authorize']"
-          size="small"
-          style="margin-left: 12px"
-          :loading="querying"
-          @click="handleQuery"
-        >
-          刷新状态
-        </el-button>
-      </el-form-item>
-      <el-form-item v-if="link" label="授权链接(PC 浏览器打开)">
-        <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>
-      </el-form-item>
-    </el-form>
-  </el-card>
-</template>
-
-<script setup lang="ts">
-import { computed, nextTick, onMounted, ref } from "vue";
-import BatchPayAPI from "@/api/module_payment/batch";
-import { useEnterpriseStore } from "@/store";
-import { ElMessage, ElMessageBox } from "element-plus";
-import QRCode from "qrcode";
-
-const enterpriseStore = useEnterpriseStore();
-const enterpriseId = computed(() => enterpriseStore.getCurrentEnterprise?.enterprise_id);
-
-const link = ref("");
-const outBizNo = ref("");
-const agreementNo = ref("");
-const status = ref("");
-/** 最新记录已 AUTHED(永久生效授权)→ 不再提供生成入口,仅展示状态 */
-const isAuthed = ref(false);
-const applying = ref(false);
-const querying = ref(false);
-const qrcodeCanvas = ref<HTMLCanvasElement>();
-
-const AUTH_STATUS_TEXT: Record<string, string> = {
-  AUTHING: "授权中",
-  AUTHED: "已授权",
-  // 存量兼容: 通知归一前落库的 NORMAL(支付宝生效状态)视同已授权
-  NORMAL: "已授权",
-  UNBIND: "已解绑",
-};
-
-const statusText = computed(() => {
-  const s = AUTH_STATUS_TEXT[status.value] || status.value || "-";
-  return agreementNo.value ? `${s}(协议号 ${agreementNo.value})` : s;
-});
-
-const existingAuthing = computed(() => status.value === "AUTHING");
-
-/** 进入页面时拉取该企业最新授权记录:AUTHING → 回溯展示链接,AUTHED → 展示已授权,UNBIND/无记录 → 初始空态 */
-async function loadLatest() {
-  if (!enterpriseId.value) return;
-  try {
-    const res = await BatchPayAPI.authorizeList({
-      enterprise_id: enterpriseId.value,
-      page_no: 1,
-      page_size: 1,
-    });
-    const rows = (res.data.data?.items ?? res.data.data?.list) || [];
-    const latest = rows[0];
-    if (!latest) return;
-    if (latest.status === "AUTHING") {
-      link.value = latest.authorize_link || "";
-      outBizNo.value = latest.out_biz_no;
-      status.value = "AUTHING";
-      agreementNo.value = "";
-      if (link.value) nextTick(() => drawQRCode());
-    } else if (latest.status === "AUTHED" || latest.status === "NORMAL") {
-      // NORMAL 为通知归一前落库的支付宝生效状态,视同已授权
-      outBizNo.value = latest.out_biz_no;
-      status.value = latest.status;
-      agreementNo.value = latest.agreement_no || "";
-      isAuthed.value = true;
-    }
-    // UNBIND → 保持初始空态(可正常生成新链接)
-  } catch (err) {
-    console.error("加载授权记录失败:", err);
-  }
-}
-
-onMounted(loadLatest);
-
-async function handleApply() {
-  if (!enterpriseId.value) {
-    ElMessage.warning("请选择企业");
-    return;
-  }
-  // 已有未完成授权申请:确认后作废旧链接重新生成(后端 /authorize/rebind)
-  if (existingAuthing.value) {
-    try {
-      await ElMessageBox.confirm(
-        "当前存在未完成的授权申请,重新生成将作废旧授权链接。是否继续?",
-        "重新生成授权链接",
-        { type: "warning", confirmButtonText: "重新生成", cancelButtonText: "取消" }
-      );
-    } catch {
-      return;
-    }
-  }
-  applying.value = true;
-  try {
-    const res = existingAuthing.value
-      ? await BatchPayAPI.authorizeRebind(enterpriseId.value)
-      : await BatchPayAPI.authorizeApply(enterpriseId.value);
-    link.value = res.data.data.authorize_link;
-    outBizNo.value = res.data.data.out_biz_no;
-    status.value = res.data.data.status;
-    agreementNo.value = "";
-    isAuthed.value = false;
-    nextTick(() => drawQRCode());
-    ElMessage.success("授权链接已生成,请尽快完成授权");
-  } 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;
-  try {
-    const res = await BatchPayAPI.queryAuthorize(enterpriseId.value, outBizNo.value);
-    status.value = res.data.data.status;
-    agreementNo.value = res.data.data.agreement_no || "";
-    if (status.value === "AUTHED" || status.value === "NORMAL") isAuthed.value = true;
-  } 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;
-}
-
-.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>

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

@@ -397,33 +397,6 @@
         </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>
@@ -785,10 +758,6 @@ 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";
@@ -1172,12 +1141,6 @@ 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);
@@ -1904,18 +1867,6 @@ 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");
 }

+ 385 - 0
frontend/src/views/module_payment/batch/components/AuthorizeList.vue

@@ -0,0 +1,385 @@
+<template>
+  <el-card>
+    <template #header>
+      <div class="card-header">
+        <span>制单授权(账号级:一个租户可维护多个授权主体)</span>
+        <el-button
+          v-hasPerm="['module_payment:batch:authorize']"
+          type="primary"
+          :loading="applying"
+          @click="openApplyDialog"
+        >
+          新增授权
+        </el-button>
+      </div>
+    </template>
+
+    <el-table v-loading="loading" :data="list" border stripe>
+      <template #empty>
+        <el-empty description="暂无授权主体,点击右上角「新增授权」生成授权链接" />
+      </template>
+      <el-table-column prop="participant_name" label="主体名称" min-width="140">
+        <template #default="{ row }">{{ row.participant_name || "-" }}</template>
+      </el-table-column>
+      <el-table-column prop="participant_id" label="支付宝UID" min-width="160" />
+      <el-table-column label="服务商" min-width="140">
+        <template #default="{ row }">
+          {{ providerName(row.service_provider_id) }}
+        </template>
+      </el-table-column>
+      <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="agreement_no" label="协议号" min-width="170">
+        <template #default="{ row }">{{ row.agreement_no || "-" }}</template>
+      </el-table-column>
+      <el-table-column label="授权链接" min-width="120">
+        <template #default="{ row }">
+          <el-button v-if="row.authorize_link" size="small" link type="primary" @click="showLink(row)">
+            查看
+          </el-button>
+          <span v-else>-</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="220" fixed="right">
+        <template #default="{ row }">
+          <template v-if="row.status === 'AUTHING'">
+            <el-button
+              v-hasPerm="['module_payment:batch:authorize']"
+              size="small"
+              @click="handleRebind(row)"
+            >
+              重新生成链接
+            </el-button>
+            <el-button
+              v-hasPerm="['module_payment:batch:authorize']"
+              size="small"
+              :loading="queryingNo === row.out_biz_no"
+              @click="handleRefresh(row)"
+            >
+              刷新状态
+            </el-button>
+          </template>
+          <span v-else-if="row.status === 'AUTHED' || row.status === 'NORMAL'" class="authed-tip">
+            已生效(可直接制单)
+          </span>
+        </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-dialog v-model="applyVisible" title="新增制单授权" width="520px" :close-on-click-modal="false">
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
+        <el-form-item label="主体名称" prop="participant_name">
+          <el-input
+            v-model="form.participant_name"
+            placeholder="如:张三(个人)/ 某某公司"
+            style="max-width: 360px"
+          />
+        </el-form-item>
+        <el-form-item label="支付宝账号" prop="participant_id">
+          <el-input
+            v-model="form.participant_id"
+            placeholder="支付宝用户ID(2088开头)"
+            style="max-width: 360px"
+          />
+        </el-form-item>
+        <el-form-item label="服务商" prop="service_provider_id">
+          <el-select
+            v-model="form.service_provider_id"
+            placeholder="选择服务商"
+            filterable
+            style="max-width: 360px"
+          >
+            <el-option v-for="p in providerOptions" :key="p.id" :label="p.name" :value="p.id" />
+          </el-select>
+          <div class="form-item-tip">
+            无法自动获取服务商时手动选择(与新增企业页一致)
+          </div>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="applyVisible = false">取消</el-button>
+        <el-button type="primary" :loading="applying" @click="handleApply">生成授权链接</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 授权链接展示(复制 + 二维码 + 刷新状态) -->
+    <el-dialog v-model="linkVisible" title="授权链接(PC 浏览器打开 / 手机支付宝扫码)" width="640px">
+      <div style="display: flex; align-items: flex-start; gap: 16px">
+        <el-input :model-value="currentLink" readonly style="flex: 1">
+          <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>
+      <div v-if="currentOutBizNo" style="margin-top: 12px">
+        授权单号 / 状态:{{ currentOutBizNo }} / {{ currentStatusText }}
+      </div>
+      <template #footer>
+        <el-button
+          v-if="currentStatus === 'AUTHING'"
+          :loading="querying"
+          @click="handleQueryStatus"
+        >
+          刷新状态
+        </el-button>
+        <el-button type="primary" @click="linkVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed, nextTick, onMounted, reactive, ref } from "vue";
+import BatchPayAPI, { type BatchAuthorizeVO } from "@/api/module_payment/batch";
+import ProviderAPI, { type ServiceProviderOption } from "@/api/module_system/service_provider";
+import { ElMessage, ElMessageBox } from "element-plus";
+import type { FormInstance, FormRules } from "element-plus";
+import QRCode from "qrcode";
+
+const list = ref<BatchAuthorizeVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const applying = ref(false);
+const querying = ref(false);
+const queryingNo = ref("");
+
+const providerOptions = ref<ServiceProviderOption[]>([]);
+const providerName = (id?: number) =>
+  (providerOptions.value.find((p) => p.id === id)?.name) || "-";
+
+const STATUS_TAG: Record<string, "primary" | "success" | "warning" | "info" | "danger"> = {
+  AUTHING: "warning",
+  AUTHED: "success",
+  NORMAL: "success",
+  UNBIND: "info",
+};
+const STATUS_TEXT: Record<string, string> = {
+  AUTHING: "授权中",
+  AUTHED: "已授权",
+  // 存量兼容: 通知归一前落库的 NORMAL(支付宝生效状态)视同已授权
+  NORMAL: "已授权",
+  UNBIND: "已解绑",
+};
+
+async function load() {
+  loading.value = true;
+  try {
+    const res = await BatchPayAPI.authorizeList({ page_no: pageNo.value, page_size: pageSize.value });
+    // 双保险解包: 后端 PageResult 序列化字段是 list(全局类型声明 items 与实际不符,与存量一致)
+    list.value = res.data.data?.items || res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+onMounted(async () => {
+  load();
+  try {
+    const res = await ProviderAPI.options();
+    providerOptions.value = res.data.data || [];
+  } catch { /* 服务商下拉加载失败不阻塞列表 */ }
+});
+
+// ==================== 新增授权 ====================
+
+const applyVisible = ref(false);
+const formRef = ref<FormInstance>();
+const form = reactive({
+  participant_name: "",
+  participant_id: "",
+  service_provider_id: null as number | null,
+});
+const rules: FormRules = {
+  participant_name: [{ required: true, message: "请输入主体名称", trigger: "blur" }],
+  participant_id: [{ required: true, message: "请输入支付宝账号", trigger: "blur" }],
+  service_provider_id: [{ required: true, message: "请选择服务商", trigger: "change" }],
+};
+
+function openApplyDialog() {
+  formRef.value?.resetFields();
+  applyVisible.value = true;
+}
+
+async function handleApply() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return;
+  applying.value = true;
+  try {
+    const res = await BatchPayAPI.authorizeApply({
+      participant_name: form.participant_name.trim(),
+      participant_id: form.participant_id.trim(),
+      service_provider_id: form.service_provider_id!,
+    });
+    applyVisible.value = false;
+    // 提交成功 → 展示授权链接弹层
+    currentLink.value = res.data.data.authorize_link;
+    currentOutBizNo.value = res.data.data.out_biz_no;
+    currentStatus.value = res.data.data.status;
+    linkVisible.value = true;
+    nextTick(() => drawQRCode());
+    ElMessage.success("授权链接已生成,请尽快完成授权");
+    load();
+  } finally {
+    applying.value = false;
+  }
+}
+
+// ==================== 授权链接展示 ====================
+
+const linkVisible = ref(false);
+const currentLink = ref("");
+const currentOutBizNo = ref("");
+const currentStatus = ref("");
+const qrcodeCanvas = ref<HTMLCanvasElement>();
+
+const currentStatusText = computed(() => STATUS_TEXT[currentStatus.value] || currentStatus.value || "-");
+
+function showLink(row: BatchAuthorizeVO) {
+  currentLink.value = row.authorize_link || "";
+  currentOutBizNo.value = row.out_biz_no;
+  currentStatus.value = row.status;
+  linkVisible.value = true;
+  nextTick(() => drawQRCode());
+}
+
+/** 授权链接二维码(复用原 BatchPayAuthorize 的 qrcode 库用法) */
+async function drawQRCode() {
+  if (!qrcodeCanvas.value || !currentLink.value) return;
+  try {
+    await QRCode.toCanvas(qrcodeCanvas.value, currentLink.value, { width: 160, margin: 1 });
+  } catch (err) {
+    console.error("授权链接二维码生成失败:", err);
+  }
+}
+
+async function handleCopyLink() {
+  try {
+    await navigator.clipboard.writeText(currentLink.value);
+    ElMessage.success("授权链接已复制");
+  } catch {
+    ElMessage.warning("复制失败,请手动复制");
+  }
+}
+
+/** 链接弹层内的状态刷新:AUTHED 后回写协议号并提示完成 */
+async function handleQueryStatus() {
+  if (!currentOutBizNo.value) return;
+  querying.value = true;
+  try {
+    const res = await BatchPayAPI.queryAuthorize(currentOutBizNo.value);
+    currentStatus.value = res.data.data.status;
+    if (res.data.data.status === "AUTHED" || res.data.data.status === "NORMAL") {
+      ElMessage.success("授权已完成,可直接制单");
+      linkVisible.value = false;
+    }
+    load();
+  } finally {
+    querying.value = false;
+  }
+}
+
+// ==================== 行操作 ====================
+
+/** AUTHING 行: 重新生成(作废旧链接换新 out_biz_no) */
+async function handleRebind(row: BatchAuthorizeVO) {
+  if (row.id == null) {
+    ElMessage.warning("记录缺少 id,无法重新生成");
+    return;
+  }
+  try {
+    await ElMessageBox.confirm(
+      "重新生成将作废当前授权链接并换新单号,是否继续?",
+      "重新生成授权链接",
+      { type: "warning", confirmButtonText: "重新生成", cancelButtonText: "取消" }
+    );
+  } catch {
+    return;
+  }
+  applying.value = true;
+  try {
+    const res = await BatchPayAPI.authorizeRebind(row.id);
+    currentLink.value = res.data.data.authorize_link;
+    currentOutBizNo.value = res.data.data.out_biz_no;
+    currentStatus.value = res.data.data.status;
+    linkVisible.value = true;
+    nextTick(() => drawQRCode());
+    ElMessage.success("已重新生成授权链接");
+    load();
+  } finally {
+    applying.value = false;
+  }
+}
+
+/** AUTHING 行: 刷新状态(AUTHED 后协议号回写本地) */
+async function handleRefresh(row: BatchAuthorizeVO) {
+  queryingNo.value = row.out_biz_no;
+  try {
+    const res = await BatchPayAPI.queryAuthorize(row.out_biz_no);
+    if (res.data.data.status === "AUTHED" || res.data.data.status === "NORMAL") {
+      ElMessage.success("授权已完成,可直接制单");
+    } else {
+      ElMessage.info(`当前状态:${STATUS_TEXT[res.data.data.status] || res.data.data.status}`);
+    }
+    load();
+  } finally {
+    queryingNo.value = "";
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.authed-tip {
+  font-size: 12px;
+  color: #909399;
+}
+
+.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 {
+  width: 100%;
+  font-size: 12px;
+  color: #909399;
+}
+</style>

+ 38 - 13
frontend/src/views/module_payment/account/components/BatchPayCreate.vue → frontend/src/views/module_payment/batch/components/BatchPayCreate.vue

@@ -6,6 +6,24 @@
       </div>
     </template>
     <el-form ref="formRef" :model="form" :rules="rules" label-width="140px">
+      <el-form-item label="付款主体" prop="participant_id">
+        <el-select
+          v-model="form.participant_id"
+          placeholder="选择已授权主体"
+          filterable
+          style="max-width: 420px"
+        >
+          <el-option
+            v-for="s in authedSubjects"
+            :key="s.participant_id"
+            :label="`${s.participant_name}(${s.participant_id})`"
+            :value="s.participant_id"
+          />
+        </el-select>
+        <div class="form-item-tip">
+          仅展示已签约(AUTHED)主体;未签约请先到「制单授权」完成授权
+        </div>
+      </el-form-item>
       <el-form-item label="批次标题" prop="order_title">
         <el-input
           v-model="form.order_title"
@@ -175,7 +193,7 @@
 
     <div style="margin-top: 20px">
       <el-button
-        v-hasPerm="['module_payment:account:transfer']"
+        v-hasPerm="['module_payment:batch:create']"
         type="primary"
         :loading="submitting"
         @click="handleSubmit"
@@ -199,17 +217,13 @@
 
 <script setup lang="ts">
 import { computed, onMounted, reactive, ref, watch } from "vue";
-import BatchPayAPI from "@/api/module_payment/batch";
-import { useEnterpriseStore } from "@/store";
+import BatchPayAPI, { type BatchAuthorizeVO } from "@/api/module_payment/batch";
 import { ElMessage } from "element-plus";
 import type { FormInstance, FormRules } from "element-plus";
 import * as ExcelJS from "exceljs";
 
 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<{
@@ -219,6 +233,7 @@ const createdResult = ref<{
 } | null>(null);
 
 const form = reactive({
+  participant_id: "",
   order_title: "",
   transfer_scene_name: "",
   time_expire: "",
@@ -226,11 +241,27 @@ const form = reactive({
 });
 
 const rules: FormRules = {
+  participant_id: [{ required: true, message: "请选择付款主体", trigger: "change" }],
   order_title: [{ required: true, message: "请输入批次标题", trigger: "blur" }],
   // I3: 26 年新接入商户必传转账场景(后端 DTO 同步 @NotBlank 校验)
   transfer_scene_name: [{ required: true, message: "请选择转账场景", trigger: "change" }],
 };
 
+/** 已签约(AUTHED)主体 — 制单付款方候选 */
+const authedSubjects = ref<BatchAuthorizeVO[]>([]);
+
+onMounted(async () => {
+  addDetailRow();
+  // 主体量小,page_size=100 一次拉全(后端 list 按 id 倒序返回)
+  try {
+    const res = await BatchPayAPI.authorizeList({ page_no: 1, page_size: 100 });
+    const rows = res.data.data?.items || res.data.data?.list || [];
+    authedSubjects.value = rows.filter((r) => r.status === "AUTHED" || r.status === "NORMAL");
+  } catch {
+    // 主体加载失败不阻塞制单(后端制单预检仍会拦截未授权主体)
+  }
+});
+
 /** 26 年新接入商户可选转账场景(与后端 BatchCreateDTO 注释一致) */
 const SCENE_OPTIONS = [
   "现金营销",
@@ -561,10 +592,6 @@ async function handleImportFileChange(file: any) {
 }
 
 async function handleSubmit() {
-  if (!enterpriseId.value) {
-    ElMessage.warning("请先选择企业");
-    return;
-  }
   const valid = await formRef.value?.validate().catch(() => false);
   if (!valid) return;
   // 场景报备信息必填(后端 DTO @NotEmpty 同步校验):每行完整 + 该场景全部信息类型齐全
@@ -613,7 +640,7 @@ async function handleSubmit() {
   submitting.value = true;
   try {
     const res = await BatchPayAPI.batchCreate({
-      enterprise_id: enterpriseId.value,
+      participant_id: form.participant_id,
       order_title: form.order_title,
       transfer_scene_name: form.transfer_scene_name || undefined,
       transfer_scene_report_infos: sceneReportInfos.length ? sceneReportInfos : undefined,
@@ -650,8 +677,6 @@ function handleReset() {
   clearForm();
   createdResult.value = null;
 }
-
-onMounted(addDetailRow);
 </script>
 
 <style lang="scss" scoped>

+ 12 - 28
frontend/src/views/module_payment/account/components/BatchPayDetail.vue → frontend/src/views/module_payment/batch/components/BatchPayDetail.vue

@@ -6,7 +6,7 @@
         <div>
           <el-button
             v-if="order.status === 'INIT' || order.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             type="primary"
             :loading="payLoading"
             @click="handlePay"
@@ -15,13 +15,14 @@
           </el-button>
           <el-button
             v-if="order.status === 'INIT' || order.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             type="danger"
             @click="handleClose"
           >
             关闭批次
           </el-button>
           <el-button icon="Refresh" :loading="refreshing" @click="refresh">刷新</el-button>
+          <el-button @click="emit('close')">返回</el-button>
         </div>
       </div>
     </template>
@@ -89,17 +90,13 @@
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted, ref } from "vue";
+import { 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 props = defineProps<{ outBatchNo: string }>();
+const emit = defineEmits<{ close: [] }>();
 
 const order = ref<BatchOrderVO | null>(null);
 const details = ref<BatchDetailItem[]>([]);
@@ -139,18 +136,9 @@ const DETAIL_STATUS_TEXT: Record<string, string> = {
 };
 
 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
-    );
+    const res = await BatchPayAPI.batchDetail(props.outBatchNo, pageNo.value, pageSize.value);
     order.value = res.data.data?.order || null;
     // 双保险解包: 后端 PageResult 序列化字段是 list(与存量 index.vue 一致)
     details.value = res.data.data?.details?.items || res.data.data?.details?.list || [];
@@ -162,13 +150,9 @@ async function load() {
 
 /** 刷新: 先调 batchQuery 同步支付宝侧批次+明细状态,再拉取本地详情展示(I1 手动兜底) */
 async function refresh() {
-  if (!enterpriseId.value) {
-    ElMessage.warning("未获取到企业信息");
-    return;
-  }
   refreshing.value = true;
   try {
-    await BatchPayAPI.batchQuery(enterpriseId.value, props.outBatchNo);
+    await BatchPayAPI.batchQuery(props.outBatchNo);
   } catch {
     // 同步失败不阻塞本地展示(batchQuery 失败原因已由 request.ts 提示)
   } finally {
@@ -178,10 +162,10 @@ async function refresh() {
 }
 
 async function handlePay() {
-  if (!order.value || !enterpriseId.value) return;
+  if (!order.value) return;
   payLoading.value = true;
   try {
-    const res = await BatchPayAPI.renderPay(enterpriseId.value, order.value.out_batch_no);
+    const res = await BatchPayAPI.renderPay(order.value.out_batch_no);
     // render.pay 返回支付宝收银台短链接 initialize_code(后端实证: 成功响应 {"code":"10000","initialize_code":"https://p.tb.cn/..."}),
     // 直接新窗口打开,无需 document.write
     const payUrl = res.data.data?.pay_url || "";
@@ -199,13 +183,13 @@ async function handlePay() {
 }
 
 async function handleClose() {
-  if (!order.value || !enterpriseId.value) return;
+  if (!order.value) return;
   try {
     await ElMessageBox.confirm("关闭后该批次不可再支付,确定关闭?", "提示", { type: "warning" });
   } catch {
     return;
   }
-  await BatchPayAPI.batchClose(enterpriseId.value, order.value.out_batch_no);
+  await BatchPayAPI.batchClose(order.value.out_batch_no);
   ElMessage.success("批次已关闭");
   load();
 }

+ 39 - 42
frontend/src/views/module_payment/account/components/BatchPayList.vue → frontend/src/views/module_payment/batch/components/BatchPayList.vue

@@ -4,7 +4,7 @@
       <div class="card-header">
         <span>批量付款批次</span>
         <el-button
-          v-hasPerm="['module_payment:account:transfer:list']"
+          v-hasPerm="['module_payment:batch:list']"
           type="primary"
           icon="Download"
           :loading="exportLoading"
@@ -16,19 +16,19 @@
     </template>
     <div class="mb-4">
       <el-form :inline="true" :model="searchForm">
-        <el-form-item v-if="isPlatformUser" label="企业">
+        <el-form-item label="付款主体">
           <el-select
-            v-model="searchForm.enterprise_id"
-            placeholder="选择企业"
+            v-model="searchForm.participant_id"
+            placeholder="全部主体"
             clearable
             filterable
-            style="width: 180px"
+            style="width: 220px"
           >
             <el-option
-              v-for="e in enterpriseStore.getEnterpriseList"
-              :key="e.enterprise_id"
-              :label="e.name"
-              :value="e.enterprise_id"
+              v-for="s in allSubjects"
+              :key="s.participant_id"
+              :label="`${s.participant_name}(${s.participant_id})`"
+              :value="s.participant_id"
             />
           </el-select>
         </el-form-item>
@@ -64,6 +64,11 @@
       <el-table-column prop="order_title" label="标题" min-width="140">
         <template #default="{ row }">{{ row.order_title || "-" }}</template>
       </el-table-column>
+      <el-table-column label="付款主体" min-width="150">
+        <template #default="{ row }">
+          {{ subjectName(row.payer_uid) }}
+        </template>
+      </el-table-column>
       <el-table-column prop="total_amount" label="总金额(元)" width="110">
         <template #default="{ row }">¥{{ row.total_amount }}</template>
       </el-table-column>
@@ -84,7 +89,7 @@
         <template #default="{ row }">
           <el-button
             v-if="row.status === 'INIT' || row.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             size="small"
             type="primary"
             :loading="payingNo === row.out_batch_no"
@@ -93,15 +98,15 @@
             支付
           </el-button>
           <el-button
-            v-hasPerm="['module_payment:account:transfer:detail']"
+            v-hasPerm="['module_payment:batch:detail']"
             size="small"
-            @click="emit('view', row.out_batch_no, row.enterprise_id)"
+            @click="emit('view', row.out_batch_no)"
           >
             详情
           </el-button>
           <el-button
             v-if="row.status === 'INIT' || row.status === 'WAIT_PAY'"
-            v-hasPerm="['module_payment:account:transfer']"
+            v-hasPerm="['module_payment:batch:create']"
             size="small"
             type="danger"
             @click="handleClose(row)"
@@ -126,18 +131,12 @@
 </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 { onMounted, reactive, ref } from "vue";
+import BatchPayAPI, { type BatchAuthorizeVO, type BatchOrderVO } from "@/api/module_payment/batch";
 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 emit = defineEmits<{ view: [outBatchNo: string] }>();
 
 const list = ref<BatchOrderVO[]>([]);
 const total = ref(0);
@@ -147,8 +146,13 @@ const loading = ref(false);
 const exportLoading = ref(false);
 const payingNo = ref("");
 
+/** 全部主体(含已解绑 UNBIND —— 历史批次按 payer_uid 筛选,不要求当前可制单) */
+const allSubjects = ref<BatchAuthorizeVO[]>([]);
+const subjectName = (uid?: string) =>
+  allSubjects.value.find((s) => s.participant_id === uid)?.participant_name || uid || "-";
+
 const searchForm = reactive({
-  enterprise_id: undefined as string | undefined,
+  participant_id: undefined as string | undefined,
   status: "",
   dateRange: null as string[] | null,
   start_time: undefined as string | undefined,
@@ -177,7 +181,7 @@ async function load() {
   loading.value = true;
   try {
     const res = await BatchPayAPI.batchList({
-      enterprise_id: searchForm.enterprise_id || undefined,
+      participant_id: searchForm.participant_id || undefined,
       status: searchForm.status || undefined,
       start_time: searchForm.start_time || undefined,
       end_time: searchForm.end_time || undefined,
@@ -208,7 +212,7 @@ function handleDateChange() {
 }
 
 function handleSearchReset() {
-  searchForm.enterprise_id = undefined;
+  searchForm.participant_id = undefined;
   searchForm.status = "";
   searchForm.dateRange = null;
   searchForm.start_time = undefined;
@@ -217,14 +221,9 @@ function handleSearchReset() {
 }
 
 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);
+    const res = await BatchPayAPI.renderPay(row.out_batch_no);
     // render.pay 返回支付宝收银台短链接 initialize_code(后端实证: 成功响应 {"code":"10000","initialize_code":"https://p.tb.cn/..."}),
     // 直接新窗口打开,无需 document.write
     const payUrl = res.data.data?.pay_url || "";
@@ -242,17 +241,12 @@ async function handlePay(row: BatchOrderVO) {
 }
 
 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);
+  await BatchPayAPI.batchClose(row.out_batch_no);
   ElMessage.success("批次已关闭");
   load();
 }
@@ -261,7 +255,7 @@ async function handleExport() {
   exportLoading.value = true;
   try {
     const res = await BatchPayAPI.batchExport({
-      enterprise_id: searchForm.enterprise_id || undefined,
+      participant_id: searchForm.participant_id || undefined,
       status: searchForm.status || undefined,
       start_time: searchForm.start_time || undefined,
       end_time: searchForm.end_time || undefined,
@@ -285,11 +279,14 @@ async function handleExport() {
   }
 }
 
-onMounted(() => {
-  if (!isPlatformUser.value) {
-    searchForm.enterprise_id = currentEnterpriseId.value;
-  }
+onMounted(async () => {
   load();
+  try {
+    const res = await BatchPayAPI.authorizeList({ page_no: 1, page_size: 100 });
+    allSubjects.value = res.data.data?.items || res.data.data?.list || [];
+  } catch {
+    // 主体加载失败不阻塞列表(筛选下拉仅展示为空)
+  }
 });
 </script>
 

+ 38 - 0
frontend/src/views/module_payment/batch/index.vue

@@ -0,0 +1,38 @@
+<template>
+  <el-tabs v-model="activeTab" class="batch-tabs" type="card">
+    <el-tab-pane label="制单授权" name="authorize">
+      <AuthorizeList />
+    </el-tab-pane>
+    <el-tab-pane label="批量制单" name="create">
+      <BatchPayCreate @created="handleBatchPayCreated" />
+    </el-tab-pane>
+    <el-tab-pane label="制单历史" name="list">
+      <BatchPayList :key="batchListKey" @view="handleViewBatchPay" />
+    </el-tab-pane>
+  </el-tabs>
+  <!-- 批次详情: 制单历史列表行触发(同 account/index.vue 模式) -->
+  <BatchPayDetail
+    v-if="detailBatchNo"
+    :out-batch-no="detailBatchNo"
+    @close="detailBatchNo = ''"
+  />
+</template>
+
+<script setup lang="ts">
+import { ref } from "vue";
+import AuthorizeList from "./components/AuthorizeList.vue";
+import BatchPayCreate from "./components/BatchPayCreate.vue";
+import BatchPayList from "./components/BatchPayList.vue";
+import BatchPayDetail from "./components/BatchPayDetail.vue";
+
+const activeTab = ref("authorize");
+const detailBatchNo = ref("");
+const batchListKey = ref(0);
+function handleViewBatchPay(outBatchNo: string) {
+  detailBatchNo.value = outBatchNo;
+}
+function handleBatchPayCreated() {
+  activeTab.value = "list";
+  batchListKey.value++;
+}
+</script>