Sfoglia il codice sorgente

feat(HAR): 门店与订单通道管理端三页

门店、商户配置、订单三个页面,接后端 /payment/har/** 的管理端接口;
API 封装在 src/api/module_payment/har.ts。

三个页面对应的权限不是一回事:商户配置那边租户只能看自己的(建号是上游
进件行为,由超管执行);门店是租户自己发起的,建店进度看 create_status;
订单页看交易,并能手动触发一次与上游的对齐(/{id}/sync)。
alphaH 3 settimane fa
parent
commit
03b276c97b

+ 323 - 0
frontend/src/api/module_payment/har.ts

@@ -0,0 +1,323 @@
+import request from "@/utils/request";
+
+const API_PATH = "/payment/har";
+
+/**
+ * 分页结果 — 后端 PageResult 全局 SNAKE_CASE 序列化后 JSON key 为 `list`
+ * (全局 PageResult<T> 声明的是 items,与后端契约不符;保留 items 兼容字段,
+ * 调用方按 `?.items || ?.list || []` 双保险解包,与 batch.ts 一致)
+ */
+export interface HarPageResult<T> {
+  items?: T;
+  list: T;
+  total: number;
+  page_no: number;
+  page_size: number;
+}
+
+/** 商户配置(后端 HarMerchantVO)— merchant_code 由运营在上游报备进件后手工录入 */
+export interface HarMerchantVO {
+  id?: string;
+  /** 归属租户。超管建号时选定,之后不可改(列表侧供超管分辨各行属于谁) */
+  tenant_id?: number;
+  /** 选填。为空 ⇒ 租户级映射(同一租户下不再区分企业) */
+  enterprise_id?: string;
+  /** 上游分配的商户编号,全局唯一 */
+  merchant_code: string;
+  merchant_name?: string;
+  contact_name?: string;
+  contact_mobile?: string;
+  description?: string;
+  created_time?: string;
+  updated_time?: string;
+}
+
+/**
+ * 商户配置保存入参(HarMerchantSaveDTO)— id 为空即新增。
+ *
+ * ★ 该端点**只有超管**能调(后端 `@perm.hasRole('SUPERUSER')`),所以调用方必然在
+ *   超管上下文里:`tenant_id` 新增时必填(替哪个租户建号),修改时后端忽略它。
+ */
+export interface HarMerchantSaveParams {
+  id?: string;
+  /** 新增必填;修改时忽略(归属不可改) */
+  tenant_id?: number;
+  enterprise_id?: string;
+  merchant_code: string;
+  merchant_name?: string;
+  contact_name?: string;
+  contact_mobile?: string;
+  description?: string;
+}
+
+/** 门店(后端 HarShopVO) */
+export interface HarShopVO {
+  id?: string;
+  enterprise_id: string;
+  merchant_code?: string;
+  /** 商户自己的门店编码,幂等键 */
+  third_shop_id: string;
+  shop_name?: string;
+  /** 上游返回的平台门店编号,创建成功才有值 */
+  shop_id?: string;
+  /** PENDING=结果未知待人工核实 / SUCCESS=上游已确认 / FAILED=上游明确拒绝 */
+  create_status: string;
+  fail_reason?: string;
+  retry_count?: number;
+  description?: string;
+  created_time?: string;
+  updated_time?: string;
+}
+
+/** 门店创建入参(HarShopCreateDTO)— 上游无门店查询接口,本地落库是唯一的可查来源 */
+export interface HarShopCreateParams {
+  third_shop_id: string;
+  shop_name: string;
+  /** 选填。为空 ⇒ 走**租户级**商户映射(enterprise_id IS NULL 的那一行) */
+  enterprise_id?: string;
+  /**
+   * 用哪一条商户配置。一个租户可以配置**多个**商户(2026-09-24),此时必须指定:
+   * 不指定而本租户有多条 ⇒ 后端 400「请在请求中指定 merchant_code」,
+   * 不会替调用方挑一条(挑错了门店挂到别的商户名下,且没有任何报错)。
+   * 取值必须是本租户的商户编号,跨租户的会被 404 挡掉。
+   */
+  merchant_code?: string;
+}
+
+/** 订单(后端 HarOrderVO)— 状态直接采用上游枚举,不做本地转义 */
+export interface HarOrderVO {
+  id?: string;
+  enterprise_id: string;
+  /** 我方订单号(雪花ID),即上游的 third_order_no */
+  order_no: string;
+  /** 企业商户自己的订单号 */
+  out_biz_no?: string;
+  upstream_order_no?: string;
+  platform_trade_no?: string;
+  merchant_code?: string;
+  shop_code?: string;
+  third_shop_code?: string;
+  price: string;
+  title?: string;
+  /** alipay_stage=花呗分期 / alipay_payment=支付宝支付 */
+  trade_type?: string;
+  stage_num?: string;
+  /** 100=商户承担 / 0=用户承担 */
+  percent?: string;
+  pay_timeout?: number;
+  extra_params?: string;
+  /** 主扫下单返回的收款链接;被扫(auth_code)下单时为空 */
+  pay_url?: string;
+  /** 0=等待支付 / 1=支付成功 / 2=退款成功 / 4=退款中 / 8=交易关闭 */
+  trade_status: string;
+  trade_time?: string;
+  refund_time?: string;
+  upstream_create_time?: string;
+  rcv_amount?: string;
+  fee_amount?: string;
+  channel_amount?: string;
+  /** PENDING=未回调 / SUCCESS / FAILED */
+  notify_status?: string;
+  created_time?: string;
+  updated_time?: string;
+}
+
+/** 下单入参(HarOrderCreateDTO)— auth_code 有值走被扫收款,为空走主扫下单 */
+export interface HarOrderCreateParams {
+  /** 选填。为空 ⇒ 走**租户级**商户映射(enterprise_id IS NULL 的那一行) */
+  enterprise_id?: string;
+  /**
+   * 用哪一条商户配置 —— 一个租户可以配置**多个**商户(2026-09-24)。
+   * 不指定而本租户有多条 ⇒ 后端 400,不替调用方挑一条:
+   * 挑错了钱会进另一个商户账号,且全程没有报错,对账时才发现。
+   */
+  merchant_code?: string;
+  out_biz_no?: string;
+  price: number | string;
+  title: string;
+  trade_type: string;
+  /** 分期时必填,3/6/12/24 */
+  stage_num?: string;
+  /** 分期时必填 */
+  percent?: string;
+  third_shop_code?: string;
+  shop_code?: string;
+  pay_timeout?: number;
+  extra_params?: string;
+  auth_code?: string;
+}
+
+/** 订单列表查询条件 — 起止日期为 yyyy-MM-dd */
+export interface HarOrderQueryParams {
+  enterprise_id?: string;
+  merchant_code?: string;
+  order_no?: string;
+  out_biz_no?: string;
+  trade_status?: string;
+  trade_type?: string;
+  start_date?: string;
+  end_date?: string;
+  page_no?: number;
+  page_size?: number;
+}
+
+export const HAR_TRADE_TYPE_OPTIONS = [
+  { label: "支付宝支付", value: "alipay_payment" },
+  { label: "花呗分期", value: "alipay_stage" },
+];
+
+/** 上游订单状态枚举,与后端 HarOrderStatus 逐字一致 */
+export const HAR_TRADE_STATUS_TEXT: Record<string, string> = {
+  "0": "等待支付",
+  "1": "支付成功",
+  "2": "退款成功",
+  "4": "退款中",
+  "8": "交易关闭",
+};
+
+export type ElTagType = "primary" | "success" | "warning" | "info" | "danger";
+
+export const HAR_TRADE_STATUS_TAG: Record<string, ElTagType> = {
+  "0": "warning",
+  "1": "success",
+  "2": "info",
+  "4": "warning",
+  "8": "info",
+};
+
+export const HAR_SHOP_STATUS_TEXT: Record<string, string> = {
+  PENDING: "待核实",
+  SUCCESS: "创建成功",
+  FAILED: "创建失败",
+};
+
+export const HAR_SHOP_STATUS_TAG: Record<string, ElTagType> = {
+  PENDING: "warning",
+  SUCCESS: "success",
+  FAILED: "danger",
+};
+
+export const HAR_NOTIFY_STATUS_TEXT: Record<string, string> = {
+  PENDING: "未回调",
+  SUCCESS: "回调成功",
+  FAILED: "回调失败",
+};
+
+const HarAPI = {
+  // ---------- 商户配置 ----------
+
+  /** 商户配置列表(分页) */
+  merchantList(params: {
+    /** 租户筛选 —— 只有超管看得见所有租户的行(后端对超管跳过租户过滤) */
+    tenant_id?: number;
+    enterprise_id?: string;
+    merchant_code?: string;
+    merchant_name?: string;
+    page_no?: number;
+    page_size?: number;
+  }) {
+    return request<ApiResponse<HarPageResult<HarMerchantVO[]>>>({
+      url: `${API_PATH}/merchant`,
+      method: "get",
+      params,
+    });
+  },
+
+  /** 新增或修改商户配置(id 有值为修改) */
+  merchantSave(data: HarMerchantSaveParams) {
+    return request<ApiResponse<HarMerchantVO>>({
+      url: `${API_PATH}/merchant`,
+      method: "post",
+      data,
+    });
+  },
+
+  /** 删除商户配置 */
+  merchantDelete(id: string) {
+    return request<ApiResponse<void>>({
+      url: `${API_PATH}/merchant/${id}`,
+      method: "delete",
+    });
+  },
+
+  // ---------- 门店 ----------
+
+  /** 门店列表(分页) */
+  shopList(params: {
+    enterprise_id?: string;
+    merchant_code?: string;
+    third_shop_id?: string;
+    create_status?: string;
+    page_no?: number;
+    page_size?: number;
+  }) {
+    return request<ApiResponse<HarPageResult<HarShopVO[]>>>({
+      url: `${API_PATH}/shop`,
+      method: "get",
+      params,
+    });
+  },
+
+  /** 门店详情 */
+  shopDetail(id: string) {
+    return request<ApiResponse<HarShopVO>>({
+      url: `${API_PATH}/shop/${id}`,
+      method: "get",
+    });
+  },
+
+  /**
+   * 创建门店(同步调用上游)。
+   * 上游没有门店查询接口,超时后无法核实远端是否已创建 —— 此时本地落 PENDING,
+   * 页面按「待核实」展示并引导人工与上游核对,不可盲目重试(third_shop_id 是幂等键)
+   */
+  shopCreate(data: HarShopCreateParams) {
+    return request<ApiResponse<HarShopVO>>({
+      url: `${API_PATH}/shop`,
+      method: "post",
+      data,
+    });
+  },
+
+  // ---------- 订单 ----------
+
+  /** 订单列表(分页) */
+  orderList(params: HarOrderQueryParams) {
+    return request<ApiResponse<HarPageResult<HarOrderVO[]>>>({
+      url: `${API_PATH}/order`,
+      method: "get",
+      params,
+    });
+  },
+
+  /** 订单详情 */
+  orderDetail(id: string) {
+    return request<ApiResponse<HarOrderVO>>({
+      url: `${API_PATH}/order/${id}`,
+      method: "get",
+    });
+  },
+
+  /**
+   * 后台下单(调用上游)。
+   * 上游异步通知未回 success 时每 5s 重试、最多 5 次(约 25s 窗口),
+   * 因此支付结果以主动查询为准,通知只是加速收敛
+   */
+  orderCreate(data: HarOrderCreateParams) {
+    return request<ApiResponse<HarOrderVO>>({
+      url: `${API_PATH}/order`,
+      method: "post",
+      data,
+    });
+  },
+
+  /** 主动查询上游并回写状态 */
+  orderSync(id: string) {
+    return request<ApiResponse<HarOrderVO>>({
+      url: `${API_PATH}/order/${id}/sync`,
+      method: "post",
+    });
+  },
+};
+
+export default HarAPI;

+ 405 - 0
frontend/src/views/module_payment/har/merchant/index.vue

@@ -0,0 +1,405 @@
+<template>
+  <!-- 单根节点:AppMain 的 <transition mode="out-in"> 要求组件根为元素,
+       多根 fragment 会导致切换动画无法完成、后续路由挂载卡死 -->
+  <div class="har-merchant">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>商户配置</span>
+          <!-- 只有超管能建号:判据用的是 sys_user.is_superuser,与后端
+               @PreAuthorize("@perm.hasRole('SUPERUSER')") 是同一个 —— 不要改用
+               v-hasPerm(那判的是角色授权,与端点实际校验的身份不是一回事)。 -->
+          <el-button v-if="isSuperAdmin" type="primary" icon="Plus" @click="openCreate">
+            新增配置
+          </el-button>
+        </div>
+      </template>
+
+      <el-alert
+        class="mb-4"
+        type="info"
+        :closable="false"
+        show-icon
+        :title="
+          isSuperAdmin
+            ? '商户编号由运营在上游完成进件报备后录入,须与上游一致,否则下单会被上游拒绝。新增时请选择归属租户。'
+            : '商户编号由运营在上游完成进件报备后录入。如需新增或修改,请联系平台运营。'
+        "
+      />
+
+      <div class="mb-4">
+        <el-form :inline="true" :model="searchForm">
+          <!-- 租户筛选只对超管有意义:非超管的查询被后端按租户钉死,只会返回自己那一行 -->
+          <el-form-item v-if="isSuperAdmin" label="租户">
+            <el-select
+              v-model="searchForm.tenant_id"
+              placeholder="全部租户"
+              clearable
+              filterable
+              style="width: 180px"
+            >
+              <el-option
+                v-for="t in tenantList"
+                :key="t.id"
+                :label="t.name || String(t.id)"
+                :value="t.id"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="企业">
+            <el-select
+              v-model="searchForm.enterprise_id"
+              placeholder="全部企业"
+              clearable
+              filterable
+              style="width: 220px"
+            >
+              <el-option
+                v-for="e in enterpriseStore.getEnterpriseList"
+                :key="e.enterprise_id"
+                :label="e.name || e.enterprise_id"
+                :value="e.enterprise_id"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="商户编号">
+            <el-input
+              v-model="searchForm.merchant_code"
+              placeholder="商户编号"
+              clearable
+              style="width: 180px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="商户名称">
+            <el-input
+              v-model="searchForm.merchant_name"
+              placeholder="商户名称"
+              clearable
+              style="width: 180px"
+              @keyup.enter="handleSearch"
+            />
+          </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 v-if="isSuperAdmin" label="租户" min-width="140">
+          <template #default="{ row }">{{ tenantName(row.tenant_id) }}</template>
+        </el-table-column>
+        <el-table-column label="企业" min-width="160">
+          <template #default="{ row }">{{ enterpriseName(row.enterprise_id) }}</template>
+        </el-table-column>
+        <el-table-column prop="merchant_code" label="商户编号" min-width="160" />
+        <el-table-column prop="merchant_name" label="商户名称" min-width="160">
+          <template #default="{ row }">{{ row.merchant_name || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="contact_name" label="联系人" width="120">
+          <template #default="{ row }">{{ row.contact_name || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="contact_mobile" label="联系电话" width="140">
+          <template #default="{ row }">{{ row.contact_mobile || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="description" label="备注" min-width="160" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.description || "-" }}</template>
+        </el-table-column>
+        <el-table-column label="更新时间" width="170">
+          <template #default="{ row }">
+            {{ row.updated_time ? dayjs(row.updated_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button v-if="isSuperAdmin" size="small" type="primary" @click="openEdit(row)">
+              编辑
+            </el-button>
+            <el-button v-if="isSuperAdmin" size="small" type="danger" @click="handleDelete(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-dialog
+      v-model="dialogVisible"
+      :title="form.id ? '编辑商户配置' : '新增商户配置'"
+      width="560px"
+      @closed="handleDialogClosed"
+    >
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="租户" prop="tenant_id">
+          <el-select
+            v-model="form.tenant_id"
+            placeholder="选择该配置归属的租户"
+            filterable
+            :disabled="!!form.id"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="t in tenantList"
+              :key="t.id"
+              :label="t.name || String(t.id)"
+              :value="t.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="企业" prop="enterprise_id">
+          <el-select
+            v-model="form.enterprise_id"
+            placeholder="留空即租户级配置"
+            clearable
+            filterable
+            :disabled="!!form.id"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="e in enterpriseStore.getEnterpriseList"
+              :key="e.enterprise_id"
+              :label="e.name || e.enterprise_id"
+              :value="e.enterprise_id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="商户编号" prop="merchant_code">
+          <el-input
+            v-model="form.merchant_code"
+            placeholder="上游报备进件后分配的商户编号"
+            maxlength="20"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="商户名称" prop="merchant_name">
+          <el-input v-model="form.merchant_name" maxlength="128" />
+        </el-form-item>
+        <el-form-item label="联系人" prop="contact_name">
+          <el-input v-model="form.contact_name" maxlength="64" />
+        </el-form-item>
+        <el-form-item label="联系电话" prop="contact_mobile">
+          <el-input v-model="form.contact_mobile" maxlength="32" />
+        </el-form-item>
+        <el-form-item label="备注" prop="description">
+          <el-input v-model="form.description" type="textarea" :rows="3" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, reactive, ref } from "vue";
+import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
+import dayjs from "dayjs";
+import HarAPI, { type HarMerchantSaveParams, type HarMerchantVO } from "@/api/module_payment/har";
+import TenantAPI, { type TenantTable } from "@/api/module_system/tenant";
+import { useEnterpriseStore, useUserStore } from "@/store";
+
+const enterpriseStore = useEnterpriseStore();
+const userStore = useUserStore();
+
+/**
+ * 超管判据 = `sys_user.is_superuser`,与后端 `@perm.hasRole('SUPERUSER')` 同一个来源。
+ *
+ * ★ 不要换成 `v-hasPerm`:那读的是角色/菜单权限串,与端点实际校验的**身份**不是一回事
+ *   (后端已把 save/delete 从 `@perm.hasAny('module_payment:har:merchant:save')`
+ *   改成 `@perm.hasRole('SUPERUSER')`,权限串对这两个端点不再起作用)。
+ */
+const isSuperAdmin = computed(() => userStore.basicInfo.is_superuser === true);
+
+/** 租户下拉数据。只有超管会用到,非超管不请求(那个接口对他们也未必有权限) */
+const tenantList = ref<TenantTable[]>([]);
+
+const tenantName = (id?: number) => {
+  if (id === undefined || id === null) {
+    return "-";
+  }
+  const t = tenantList.value.find((x) => x.id === id);
+  return t ? t.name || String(id) : String(id);
+};
+
+const list = ref<HarMerchantVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+
+const dialogVisible = ref(false);
+const saving = ref(false);
+const formRef = ref<FormInstance>();
+
+const searchForm = reactive({
+  tenant_id: undefined as number | undefined,
+  enterprise_id: undefined as string | undefined,
+  merchant_code: "",
+  merchant_name: "",
+});
+
+const defaultForm = (): HarMerchantSaveParams => ({
+  id: undefined,
+  tenant_id: undefined,
+  enterprise_id: "",
+  merchant_code: "",
+  merchant_name: "",
+  contact_name: "",
+  contact_mobile: "",
+  description: "",
+});
+
+const form = reactive<HarMerchantSaveParams>(defaultForm());
+
+/**
+ * 租户只在**新增**时必填:后端对修改一律忽略 tenant_id(归属不可改,
+ * 改了会让已建的门店/订单指向另一个租户),所以编辑态校验它没有意义 ——
+ * 旧数据若没有 tenant_id,卡在这里会让超管连备注都改不了。
+ */
+const rules = computed<FormRules>(() => ({
+  tenant_id: form.id ? [] : [{ required: true, message: "请选择租户", trigger: "change" }],
+  merchant_code: [{ required: true, message: "请填写商户编号", trigger: "blur" }],
+}));
+
+const enterpriseName = (id?: string) => {
+  const e = enterpriseStore.getEnterpriseList.find((x) => x.enterprise_id === id);
+  return e ? e.name || id : id || "-";
+};
+
+async function load() {
+  loading.value = true;
+  try {
+    const res = await HarAPI.merchantList({
+      tenant_id: searchForm.tenant_id || undefined,
+      enterprise_id: searchForm.enterprise_id || undefined,
+      merchant_code: searchForm.merchant_code || undefined,
+      merchant_name: searchForm.merchant_name || undefined,
+      page_no: pageNo.value,
+      page_size: pageSize.value,
+    });
+    list.value = res.data.data?.items || res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  pageNo.value = 1;
+  load();
+}
+
+function handleReset() {
+  searchForm.tenant_id = undefined;
+  searchForm.enterprise_id = undefined;
+  searchForm.merchant_code = "";
+  searchForm.merchant_name = "";
+  handleSearch();
+}
+
+function openCreate() {
+  Object.assign(form, defaultForm());
+  dialogVisible.value = true;
+}
+
+function openEdit(row: HarMerchantVO) {
+  Object.assign(form, defaultForm(), {
+    id: row.id,
+    tenant_id: row.tenant_id,
+    enterprise_id: row.enterprise_id,
+    merchant_code: row.merchant_code,
+    merchant_name: row.merchant_name,
+    contact_name: row.contact_name,
+    contact_mobile: row.contact_mobile,
+    description: row.description,
+  });
+  dialogVisible.value = true;
+}
+
+function handleDialogClosed() {
+  formRef.value?.clearValidate();
+  Object.assign(form, defaultForm());
+}
+
+async function handleSubmit() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) {
+    return;
+  }
+  saving.value = true;
+  try {
+    await HarAPI.merchantSave({ ...form });
+    ElMessage.success("保存成功");
+    dialogVisible.value = false;
+    load();
+  } finally {
+    saving.value = false;
+  }
+}
+
+async function handleDelete(row: HarMerchantVO) {
+  try {
+    await ElMessageBox.confirm(
+      `删除后该企业的商户编号(${row.merchant_code})将无法下单,确定删除?`,
+      "提示",
+      { type: "warning" }
+    );
+  } catch {
+    return;
+  }
+  await HarAPI.merchantDelete(row.id as string);
+  ElMessage.success("删除成功");
+  load();
+}
+
+onMounted(async () => {
+  load();
+  try {
+    await enterpriseStore.loadEnterpriseList();
+  } catch {
+    // 企业列表加载失败不阻塞列表查询(筛选下拉仅展示为空)
+  }
+  if (isSuperAdmin.value) {
+    try {
+      const res = await TenantAPI.listTenant({ page_no: 1, page_size: 100 });
+      // 后端 PageResult 的字段名是 `list`(PageResult.java:21),而全局 TS 声明写的是
+      // `items`(types/global.d.ts:28)—— 两边不一致,只读 `items` 会**静默**拿到空数组,
+      // 表现为超管的下拉是空的、于是根本建不了号。两个键都读。
+      const page = res.data.data as unknown as {
+        list?: TenantTable[];
+        items?: TenantTable[];
+      };
+      tenantList.value = page?.list || page?.items || [];
+    } catch {
+      // 租户列表加载失败不阻塞列表查询(租户列退化成显示 tenant_id 数字)
+    }
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 701 - 0
frontend/src/views/module_payment/har/order/index.vue

@@ -0,0 +1,701 @@
+<template>
+  <!-- 单根节点(含下单/详情弹层):AppMain 的 <transition mode="out-in"> 要求组件根为元素 -->
+  <div class="har-order">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>订单管理</span>
+          <el-button
+            v-hasPerm="['module_payment:har:order:create']"
+            type="primary"
+            icon="Plus"
+            @click="openCreate"
+          >
+            创建订单
+          </el-button>
+        </div>
+      </template>
+
+      <div class="mb-4">
+        <el-form :inline="true" :model="searchForm">
+          <el-form-item label="企业">
+            <el-select
+              v-model="searchForm.enterprise_id"
+              placeholder="全部企业"
+              clearable
+              filterable
+              style="width: 190px"
+            >
+              <el-option
+                v-for="e in enterpriseStore.getEnterpriseList"
+                :key="e.enterprise_id"
+                :label="e.name || e.enterprise_id"
+                :value="e.enterprise_id"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="商户编号">
+            <el-input
+              v-model="searchForm.merchant_code"
+              placeholder="商户编号"
+              clearable
+              style="width: 170px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="订单号">
+            <el-input
+              v-model="searchForm.order_no"
+              placeholder="我方订单号"
+              clearable
+              style="width: 180px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="商户订单号">
+            <el-input
+              v-model="searchForm.out_biz_no"
+              placeholder="企业自己的订单号"
+              clearable
+              style="width: 180px"
+              @keyup.enter="handleSearch"
+            />
+          </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 HAR_TRADE_STATUS_TEXT"
+                :key="value"
+                :label="text"
+                :value="value"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="交易方式">
+            <el-select
+              v-model="searchForm.trade_type"
+              placeholder="全部"
+              clearable
+              style="width: 140px"
+            >
+              <el-option
+                v-for="t in HAR_TRADE_TYPE_OPTIONS"
+                :key="t.value"
+                :label="t.label"
+                :value="t.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="190" show-overflow-tooltip />
+        <el-table-column prop="out_biz_no" label="商户订单号" min-width="160" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.out_biz_no || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="title" label="商品名称" min-width="140" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.title || "-" }}</template>
+        </el-table-column>
+        <el-table-column label="金额(元)" width="110">
+          <template #default="{ row }">¥{{ row.price }}</template>
+        </el-table-column>
+        <el-table-column label="交易状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="HAR_TRADE_STATUS_TAG[row.trade_status] || 'info'">
+              {{ HAR_TRADE_STATUS_TEXT[row.trade_status] || row.trade_status }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="交易方式" width="110">
+          <template #default="{ row }">{{ tradeTypeText(row.trade_type) }}</template>
+        </el-table-column>
+        <el-table-column prop="merchant_code" label="商户编号" min-width="140">
+          <template #default="{ row }">{{ row.merchant_code || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="third_shop_code" label="门店编码" min-width="130">
+          <template #default="{ row }">{{ row.third_shop_code || "-" }}</template>
+        </el-table-column>
+        <el-table-column label="交易时间" width="170">
+          <template #default="{ row }">
+            {{ row.trade_time ? dayjs(row.trade_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              v-hasPerm="['module_payment:har:order:detail']"
+              size="small"
+              @click="openDetail(row)"
+            >
+              详情
+            </el-button>
+            <el-button
+              v-hasPerm="['module_payment:har:order:sync']"
+              size="small"
+              type="primary"
+              :loading="syncingId === row.id"
+              @click="handleSync(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-dialog v-model="createVisible" title="创建订单" width="640px" @closed="handleCreateClosed">
+      <el-alert
+        class="mb-4"
+        type="info"
+        :closable="false"
+        show-icon
+        title="填写付款码即为被扫收款(用户出示付款码);留空则生成收款链接,由用户扫码支付。"
+      />
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
+        <el-form-item label="商户" prop="merchant_code">
+          <!-- 可清空 + 可输入:本租户配了多个商户时这里就是「钱进哪一家」的开关;
+               列表拉不到时(未授权/接口异常)仍可手工敲编号,归属由后端按本租户校验 -->
+          <el-select
+            v-model="form.merchant_code"
+            placeholder="留空即按企业与租户自动匹配"
+            clearable
+            filterable
+            allow-create
+            default-first-option
+            style="width: 100%"
+          >
+            <el-option
+              v-for="m in merchantOptions"
+              :key="m.merchant_code"
+              :label="merchantLabel(m)"
+              :value="m.merchant_code"
+            />
+          </el-select>
+          <div v-if="merchantOptions.length > 1" class="form-tip">
+            本租户配置了多个商户,请选择这笔款项收进哪一个商户
+          </div>
+        </el-form-item>
+        <el-form-item label="企业" prop="enterprise_id">
+          <el-select
+            v-model="form.enterprise_id"
+            placeholder="留空即按租户下单"
+            clearable
+            filterable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="e in enterpriseStore.getEnterpriseList"
+              :key="e.enterprise_id"
+              :label="e.name || e.enterprise_id"
+              :value="e.enterprise_id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="商户订单号" prop="out_biz_no">
+          <el-input
+            v-model="form.out_biz_no"
+            placeholder="企业自己的订单号,便于对账,最长 64 位"
+            maxlength="64"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="金额(元)" prop="price">
+          <el-input-number
+            v-model="form.price"
+            :min="0.01"
+            :precision="2"
+            :step="1"
+            controls-position="right"
+            style="width: 200px"
+          />
+        </el-form-item>
+        <el-form-item label="商品名称" prop="title">
+          <el-input v-model="form.title" maxlength="64" show-word-limit />
+        </el-form-item>
+        <el-form-item label="交易方式" prop="trade_type">
+          <el-select v-model="form.trade_type" style="width: 100%" @change="handleTradeTypeChange">
+            <el-option
+              v-for="t in HAR_TRADE_TYPE_OPTIONS"
+              :key="t.value"
+              :label="t.label"
+              :value="t.value"
+            />
+          </el-select>
+        </el-form-item>
+
+        <template v-if="form.trade_type === 'alipay_stage'">
+          <el-form-item label="分期数" prop="stage_num">
+            <el-select v-model="form.stage_num" placeholder="请选择分期数" style="width: 100%">
+              <el-option v-for="n in STAGE_NUM_OPTIONS" :key="n" :label="`${n} 期`" :value="n" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="手续费承担方" prop="percent">
+            <el-select v-model="form.percent" style="width: 100%">
+              <el-option label="商户承担" value="100" />
+              <el-option label="用户承担" value="0" />
+            </el-select>
+          </el-form-item>
+        </template>
+
+        <el-form-item label="门店编码" prop="third_shop_code">
+          <el-input
+            v-model="form.third_shop_code"
+            placeholder="商户侧门店编码,最长 32 位"
+            maxlength="32"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="平台门店编号" prop="shop_code">
+          <el-input v-model="form.shop_code" placeholder="上游返回的平台门店编号" />
+        </el-form-item>
+        <el-form-item label="付款码" prop="auth_code">
+          <el-input
+            v-model="form.auth_code"
+            placeholder="扫用户付款码得到,填写即走被扫收款"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="支付超时" prop="pay_timeout">
+          <el-input-number
+            v-model="form.pay_timeout"
+            :min="2"
+            :max="60"
+            controls-position="right"
+            style="width: 200px"
+          />
+          <span class="ml-2 text-gray-400">分钟,仅主扫下单有效</span>
+        </el-form-item>
+        <el-form-item label="扩展参数" prop="extra_params">
+          <el-input
+            v-model="form.extra_params"
+            type="textarea"
+            :rows="2"
+            placeholder="上游透传字段,原样提交"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleCreate">提交</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 订单详情 -->
+    <el-dialog v-model="detailVisible" title="订单详情" width="720px">
+      <el-descriptions v-if="detail" :column="2" border>
+        <el-descriptions-item label="订单号" :span="2">{{ detail.order_no }}</el-descriptions-item>
+        <el-descriptions-item label="商户订单号">
+          {{ detail.out_biz_no || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="交易状态">
+          <el-tag :type="HAR_TRADE_STATUS_TAG[detail.trade_status] || 'info'">
+            {{ HAR_TRADE_STATUS_TEXT[detail.trade_status] || detail.trade_status }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="商品名称">{{ detail.title || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="交易方式">
+          {{ tradeTypeText(detail.trade_type) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="订单金额">¥{{ detail.price }}</el-descriptions-item>
+        <el-descriptions-item label="实收金额">
+          {{ detail.rcv_amount != null ? `¥${detail.rcv_amount}` : "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="手续费">
+          {{ detail.fee_amount != null ? `¥${detail.fee_amount}` : "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="渠道成本">
+          {{ detail.channel_amount != null ? `¥${detail.channel_amount}` : "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="商户编号">
+          {{ detail.merchant_code || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="门店编码">
+          {{ detail.third_shop_code || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="平台门店编号">
+          {{ detail.shop_code || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="上游订单号" :span="2">
+          {{ detail.upstream_order_no || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="支付宝交易号" :span="2">
+          {{ detail.platform_trade_no || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="detail.trade_type === 'alipay_stage'" label="分期数">
+          {{ detail.stage_num ? `${detail.stage_num} 期` : "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="detail.trade_type === 'alipay_stage'" label="手续费承担方">
+          {{ detail.percent === "100" ? "商户承担" : detail.percent === "0" ? "用户承担" : "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="支付超时">
+          {{ detail.pay_timeout != null ? `${detail.pay_timeout} 分钟` : "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="企业回调">
+          {{ HAR_NOTIFY_STATUS_TEXT[detail.notify_status || ""] || detail.notify_status || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="下单时间">
+          {{ formatTime(detail.created_time) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="支付时间">
+          {{ formatTime(detail.trade_time) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="退款时间">
+          {{ formatTime(detail.refund_time) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="上游创建时间">
+          {{ formatTime(detail.upstream_create_time) }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="detail.pay_url" label="收款链接" :span="2">
+          <div class="pay-url">
+            <span class="pay-url__text">{{ detail.pay_url }}</span>
+            <el-button size="small" type="primary" @click="openPayUrl(detail.pay_url)">
+              打开
+            </el-button>
+            <el-button size="small" @click="copyPayUrl(detail.pay_url)">复制</el-button>
+          </div>
+        </el-descriptions-item>
+        <el-descriptions-item v-if="detail.extra_params" label="扩展参数" :span="2">
+          {{ detail.extra_params }}
+        </el-descriptions-item>
+      </el-descriptions>
+      <template #footer>
+        <el-button
+          v-hasPerm="['module_payment:har:order:sync']"
+          :loading="syncingId === detail?.id"
+          @click="detail && handleSync(detail)"
+        >
+          同步上游状态
+        </el-button>
+        <el-button @click="detailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from "vue";
+import { ElMessage, type FormInstance, type FormRules } from "element-plus";
+import dayjs from "dayjs";
+import HarAPI, {
+  HAR_NOTIFY_STATUS_TEXT,
+  HAR_TRADE_STATUS_TAG,
+  HAR_TRADE_STATUS_TEXT,
+  HAR_TRADE_TYPE_OPTIONS,
+  type HarMerchantVO,
+  type HarOrderCreateParams,
+  type HarOrderVO,
+} from "@/api/module_payment/har";
+import { useEnterpriseStore } from "@/store/modules/enterprise.store";
+
+const enterpriseStore = useEnterpriseStore();
+
+/** 本租户可见的商户配置 —— 下单时用哪一条由这里选 */
+const merchantOptions = ref<HarMerchantVO[]>([]);
+
+/** 规范 5.1 给定的分期期数 */
+const STAGE_NUM_OPTIONS = ["3", "6", "12", "24"];
+
+const list = ref<HarOrderVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+const syncingId = ref<string | undefined>();
+
+const createVisible = ref(false);
+const saving = ref(false);
+const formRef = ref<FormInstance>();
+
+const detailVisible = ref(false);
+const detail = ref<HarOrderVO | null>(null);
+
+const dateRange = ref<string[] | null>(null);
+
+const searchForm = reactive({
+  enterprise_id: undefined as string | undefined,
+  merchant_code: "",
+  order_no: "",
+  out_biz_no: "",
+  trade_status: "",
+  trade_type: "",
+  start_date: undefined as string | undefined,
+  end_date: undefined as string | undefined,
+});
+
+/** price 在后端入参里是 BigDecimal(数字或字符串皆可),表单里固定为数字,提交时再断言 */
+type OrderForm = Omit<HarOrderCreateParams, "price"> & { price?: number };
+
+const defaultForm = (): OrderForm => ({
+  // 用 undefined 而不是空串:空串对 enterprise_id 是有含义的(=租户级),
+  // 对 merchant_code 不是 —— 留空只表示「没指定」,后端按 StrUtil.isNotBlank 分流
+  merchant_code: undefined,
+  enterprise_id: "",
+  out_biz_no: "",
+  price: undefined,
+  title: "",
+  trade_type: "alipay_payment",
+  stage_num: "",
+  percent: "",
+  third_shop_code: "",
+  shop_code: "",
+  auth_code: "",
+  pay_timeout: undefined,
+  extra_params: "",
+});
+
+const form = reactive<OrderForm>(defaultForm());
+
+const rules: FormRules = {
+  // 企业**非必填**:留空即按租户下单(用 enterprise_id IS NULL 的那条租户级商户映射),
+  // 与 HarOrderCreateDTO 上「选填」的注释、后端 HarOrderService#create 的分流一致。
+  // 商户同理非必填:本租户只有一条配置时留空即为那条;有多条时后端会 400 要求指定。
+  price: [{ required: true, message: "请填写金额", trigger: "blur" }],
+  title: [{ required: true, message: "请填写商品名称", trigger: "blur" }],
+  trade_type: [{ required: true, message: "请选择交易方式", trigger: "change" }],
+};
+
+const merchantLabel = (m: HarMerchantVO) =>
+  m.merchant_name ? `${m.merchant_name}(${m.merchant_code})` : m.merchant_code;
+
+async function loadMerchantOptions() {
+  try {
+    const res = await HarAPI.merchantList({ page_size: 100 });
+    merchantOptions.value = res.data.data?.items || res.data.data?.list || [];
+  } catch {
+    // 拉不到(未授权/接口异常)不阻塞下单:下拉留空,仍可手工输入编号,
+    // 归属由后端按本租户校验(跨租户的会被 404 挡掉)
+    merchantOptions.value = [];
+  }
+}
+
+const tradeTypeText = (v?: string) =>
+  HAR_TRADE_TYPE_OPTIONS.find((t) => t.value === v)?.label || v || "-";
+
+const formatTime = (v?: string) => (v ? dayjs(v).format("YYYY-MM-DD HH:mm:ss") : "-");
+
+async function load() {
+  loading.value = true;
+  try {
+    const res = await HarAPI.orderList({
+      enterprise_id: searchForm.enterprise_id || undefined,
+      merchant_code: searchForm.merchant_code || undefined,
+      order_no: searchForm.order_no || undefined,
+      out_biz_no: searchForm.out_biz_no || undefined,
+      trade_status: searchForm.trade_status || undefined,
+      trade_type: searchForm.trade_type || undefined,
+      start_date: searchForm.start_date || undefined,
+      end_date: searchForm.end_date || undefined,
+      page_no: pageNo.value,
+      page_size: pageSize.value,
+    });
+    list.value = res.data.data?.items || res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    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.enterprise_id = undefined;
+  searchForm.merchant_code = "";
+  searchForm.order_no = "";
+  searchForm.out_biz_no = "";
+  searchForm.trade_status = "";
+  searchForm.trade_type = "";
+  searchForm.start_date = undefined;
+  searchForm.end_date = undefined;
+  dateRange.value = null;
+  handleSearch();
+}
+
+function openCreate() {
+  Object.assign(form, defaultForm());
+  createVisible.value = true;
+}
+
+function handleCreateClosed() {
+  formRef.value?.clearValidate();
+  Object.assign(form, defaultForm());
+}
+
+function handleTradeTypeChange(v: string) {
+  // 非分期时清掉分期字段,避免把无关参数透传给上游
+  if (v !== "alipay_stage") {
+    form.stage_num = "";
+    form.percent = "";
+  } else if (!form.percent) {
+    form.percent = "100";
+  }
+}
+
+async function handleCreate() {
+  if (form.trade_type === "alipay_stage" && (!form.stage_num || !form.percent)) {
+    ElMessage.warning("花呗分期必须选择分期数与手续费承担方");
+    return;
+  }
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) {
+    return;
+  }
+  saving.value = true;
+  try {
+    const res = await HarAPI.orderCreate({
+      ...form,
+      // 已过表单必填校验,此处 price 必然有值
+      price: form.price as number,
+      out_biz_no: form.out_biz_no || undefined,
+      stage_num: form.stage_num || undefined,
+      percent: form.percent || undefined,
+      third_shop_code: form.third_shop_code || undefined,
+      shop_code: form.shop_code || undefined,
+      auth_code: form.auth_code || undefined,
+      extra_params: form.extra_params || undefined,
+    });
+    const created = res.data.data;
+    createVisible.value = false;
+    detail.value = created || null;
+    detailVisible.value = true;
+    ElMessage.success(
+      created?.pay_url ? "下单成功,请在详情中打开收款链接" : "下单成功,等待用户付款"
+    );
+    load();
+  } finally {
+    saving.value = false;
+  }
+}
+
+async function openDetail(row: HarOrderVO) {
+  const res = await HarAPI.orderDetail(row.id as string);
+  detail.value = res.data.data;
+  detailVisible.value = true;
+}
+
+async function handleSync(row: HarOrderVO) {
+  syncingId.value = row.id;
+  try {
+    const res = await HarAPI.orderSync(row.id as string);
+    const updated = res.data.data;
+    ElMessage.success(
+      `已同步,当前状态:${HAR_TRADE_STATUS_TEXT[updated.trade_status] || updated.trade_status}`
+    );
+    // 详情打开着就同步刷新,避免弹层里看到的还是旧状态
+    if (detailVisible.value && detail.value?.id === row.id) {
+      detail.value = updated;
+    }
+    load();
+  } finally {
+    syncingId.value = undefined;
+  }
+}
+
+function openPayUrl(url?: string) {
+  if (!url) {
+    return;
+  }
+  const w = window.open(url, "_blank");
+  if (!w) {
+    ElMessage.warning("收款链接弹窗被浏览器拦截,请允许弹窗后重试");
+  }
+}
+
+async function copyPayUrl(url?: string) {
+  if (!url) {
+    return;
+  }
+  try {
+    await navigator.clipboard.writeText(url);
+    ElMessage.success("已复制收款链接");
+  } catch {
+    ElMessage.warning("浏览器拒绝了复制操作,请手动选中复制");
+  }
+}
+
+onMounted(async () => {
+  load();
+  loadMerchantOptions();
+  try {
+    await enterpriseStore.loadEnterpriseList();
+  } catch {
+    // 企业列表加载失败不阻塞列表查询
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.form-tip {
+  font-size: 12px;
+  line-height: 1.6;
+  color: var(--el-text-color-secondary);
+}
+
+.pay-url {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+
+  &__text {
+    flex: 1;
+    word-break: break-all;
+    color: var(--el-text-color-regular);
+  }
+}
+</style>

+ 417 - 0
frontend/src/views/module_payment/har/shop/index.vue

@@ -0,0 +1,417 @@
+<template>
+  <!-- 单根节点(含详情弹层):AppMain 的 <transition mode="out-in"> 要求组件根为元素 -->
+  <div class="har-shop">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>门店管理</span>
+          <el-button
+            v-hasPerm="['module_payment:har:shop:create']"
+            type="primary"
+            icon="Plus"
+            @click="openCreate"
+          >
+            创建门店
+          </el-button>
+        </div>
+      </template>
+
+      <el-alert
+        class="mb-4"
+        type="warning"
+        :closable="false"
+        show-icon
+        title="上游未提供门店查询接口。提交超时的门店会停留在「待核实」,需人工与上游核对是否已创建,不可直接重试。"
+      />
+
+      <div class="mb-4">
+        <el-form :inline="true" :model="searchForm">
+          <el-form-item label="企业">
+            <el-select
+              v-model="searchForm.enterprise_id"
+              placeholder="全部企业"
+              clearable
+              filterable
+              style="width: 200px"
+            >
+              <el-option
+                v-for="e in enterpriseStore.getEnterpriseList"
+                :key="e.enterprise_id"
+                :label="e.name || e.enterprise_id"
+                :value="e.enterprise_id"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="商户编号">
+            <el-input
+              v-model="searchForm.merchant_code"
+              placeholder="商户编号"
+              clearable
+              style="width: 170px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="门店编码">
+            <el-input
+              v-model="searchForm.third_shop_id"
+              placeholder="商户门店编码"
+              clearable
+              style="width: 170px"
+              @keyup.enter="handleSearch"
+            />
+          </el-form-item>
+          <el-form-item label="创建状态">
+            <el-select
+              v-model="searchForm.create_status"
+              placeholder="全部"
+              clearable
+              style="width: 140px"
+            >
+              <el-option
+                v-for="(text, value) in HAR_SHOP_STATUS_TEXT"
+                :key="value"
+                :label="text"
+                :value="value"
+              />
+            </el-select>
+          </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 label="企业" min-width="150">
+          <template #default="{ row }">{{ enterpriseName(row.enterprise_id) }}</template>
+        </el-table-column>
+        <el-table-column prop="merchant_code" label="商户编号" min-width="150">
+          <template #default="{ row }">{{ row.merchant_code || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="third_shop_id" label="门店编码" min-width="150" />
+        <el-table-column prop="shop_name" label="门店名称" min-width="150">
+          <template #default="{ row }">{{ row.shop_name || "-" }}</template>
+        </el-table-column>
+        <el-table-column prop="shop_id" label="平台门店编号" min-width="140">
+          <template #default="{ row }">{{ row.shop_id || "-" }}</template>
+        </el-table-column>
+        <el-table-column label="创建状态" width="110">
+          <template #default="{ row }">
+            <el-tag :type="HAR_SHOP_STATUS_TAG[row.create_status] || 'info'">
+              {{ HAR_SHOP_STATUS_TEXT[row.create_status] || row.create_status }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="fail_reason" label="失败原因" min-width="180" show-overflow-tooltip>
+          <template #default="{ row }">{{ row.fail_reason || "-" }}</template>
+        </el-table-column>
+        <el-table-column 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="90" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              v-hasPerm="['module_payment:har:shop: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-dialog v-model="createVisible" title="创建门店" width="520px" @closed="handleCreateClosed">
+      <el-alert
+        class="mb-4"
+        type="info"
+        :closable="false"
+        show-icon
+        title="门店编码提交后即为该门店的唯一标识,重复提交会被后台拒绝,请勿反复尝试。"
+      />
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
+        <el-form-item label="商户" prop="merchant_code">
+          <!-- 可清空 + 可输入:本租户配了多个商户时这里就是「用哪一条」的开关;
+               列表拉不到时(未授权/接口异常)仍可手工敲编号,取值由后端按本租户校验 -->
+          <el-select
+            v-model="form.merchant_code"
+            placeholder="留空即按企业与租户自动匹配"
+            clearable
+            filterable
+            allow-create
+            default-first-option
+            style="width: 100%"
+          >
+            <el-option
+              v-for="m in merchantOptions"
+              :key="m.merchant_code"
+              :label="merchantLabel(m)"
+              :value="m.merchant_code"
+            />
+          </el-select>
+          <div v-if="merchantOptions.length > 1" class="form-tip">
+            本租户配置了多个商户,请选择这家门店挂在哪一个商户下
+          </div>
+        </el-form-item>
+        <el-form-item label="企业" prop="enterprise_id">
+          <el-select
+            v-model="form.enterprise_id"
+            placeholder="留空即租户级门店"
+            clearable
+            filterable
+            style="width: 100%"
+          >
+            <el-option
+              v-for="e in enterpriseStore.getEnterpriseList"
+              :key="e.enterprise_id"
+              :label="e.name || e.enterprise_id"
+              :value="e.enterprise_id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="门店编码" prop="third_shop_id">
+          <el-input
+            v-model="form.third_shop_id"
+            placeholder="商户侧门店唯一编码"
+            maxlength="32"
+            show-word-limit
+          />
+        </el-form-item>
+        <el-form-item label="门店名称" prop="shop_name">
+          <el-input v-model="form.shop_name" maxlength="128" show-word-limit />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="createVisible = false">取消</el-button>
+        <el-button type="primary" :loading="saving" @click="handleCreate">提交</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 门店详情 -->
+    <el-dialog v-model="detailVisible" title="门店详情" width="620px">
+      <el-descriptions v-if="detail" :column="1" border>
+        <el-descriptions-item label="企业">
+          {{ enterpriseName(detail.enterprise_id) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="商户编号">
+          {{ detail.merchant_code || "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="门店编码">{{ detail.third_shop_id }}</el-descriptions-item>
+        <el-descriptions-item label="门店名称">{{ detail.shop_name || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="平台门店编号">
+          {{ detail.shop_id || "未返回(创建未成功)" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建状态">
+          <el-tag :type="HAR_SHOP_STATUS_TAG[detail.create_status] || 'info'">
+            {{ HAR_SHOP_STATUS_TEXT[detail.create_status] || detail.create_status }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item v-if="detail.fail_reason" label="失败原因">
+          {{ detail.fail_reason }}
+        </el-descriptions-item>
+        <el-descriptions-item v-if="detail.retry_count" label="重试次数">
+          {{ detail.retry_count }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ detail.created_time ? dayjs(detail.created_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
+        </el-descriptions-item>
+        <el-descriptions-item label="更新时间">
+          {{ detail.updated_time ? dayjs(detail.updated_time).format("YYYY-MM-DD HH:mm:ss") : "-" }}
+        </el-descriptions-item>
+      </el-descriptions>
+      <template #footer>
+        <el-button @click="detailVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from "vue";
+import { ElMessage, type FormInstance, type FormRules } from "element-plus";
+import dayjs from "dayjs";
+import HarAPI, {
+  HAR_SHOP_STATUS_TAG,
+  HAR_SHOP_STATUS_TEXT,
+  type HarMerchantVO,
+  type HarShopCreateParams,
+  type HarShopVO,
+} from "@/api/module_payment/har";
+import { useEnterpriseStore } from "@/store/modules/enterprise.store";
+
+const enterpriseStore = useEnterpriseStore();
+
+/** 本租户可见的商户配置 —— 建店时用哪一条由这里选 */
+const merchantOptions = ref<HarMerchantVO[]>([]);
+
+const list = ref<HarShopVO[]>([]);
+const total = ref(0);
+const pageNo = ref(1);
+const pageSize = ref(20);
+const loading = ref(false);
+
+const createVisible = ref(false);
+const saving = ref(false);
+const formRef = ref<FormInstance>();
+
+const detailVisible = ref(false);
+const detail = ref<HarShopVO | null>(null);
+
+const searchForm = reactive({
+  enterprise_id: undefined as string | undefined,
+  merchant_code: "",
+  third_shop_id: "",
+  create_status: "",
+});
+
+const defaultForm = (): HarShopCreateParams => ({
+  // 用 undefined 而不是空串:空串对 enterprise_id 是有含义的(=租户级),
+  // 对 merchant_code 不是 —— 留空只表示「没指定」,后端按 StrUtil.isNotBlank 分流
+  merchant_code: undefined,
+  enterprise_id: "",
+  third_shop_id: "",
+  shop_name: "",
+});
+
+const form = reactive<HarShopCreateParams>(defaultForm());
+
+const rules: FormRules = {
+  // 企业**非必填**:留空即按租户建店(enterprise_id 落 NULL 的租户级映射),
+  // 与 HarShopCreateDTO 上「选填」的注释、后端 HarShopService#create 的分流一致。
+  // 商户同理非必填:本租户只有一条配置时留空即为那条;有多条时后端会 400 要求指定。
+  third_shop_id: [{ required: true, message: "请填写门店编码", trigger: "blur" }],
+  shop_name: [{ required: true, message: "请填写门店名称", trigger: "blur" }],
+};
+
+const merchantLabel = (m: HarMerchantVO) =>
+  m.merchant_name ? `${m.merchant_name}(${m.merchant_code})` : m.merchant_code;
+
+async function loadMerchantOptions() {
+  try {
+    const res = await HarAPI.merchantList({ page_size: 100 });
+    merchantOptions.value = res.data.data?.items || res.data.data?.list || [];
+  } catch {
+    // 拉不到(未授权/接口异常)不阻塞建店:下拉留空,仍可手工输入编号,
+    // 归属由后端按本租户校验(跨租户的会被 404 挡掉)
+    merchantOptions.value = [];
+  }
+}
+
+const enterpriseName = (id?: string) => {
+  const e = enterpriseStore.getEnterpriseList.find((x) => x.enterprise_id === id);
+  return e ? e.name || id : id || "-";
+};
+
+async function load() {
+  loading.value = true;
+  try {
+    const res = await HarAPI.shopList({
+      enterprise_id: searchForm.enterprise_id || undefined,
+      merchant_code: searchForm.merchant_code || undefined,
+      third_shop_id: searchForm.third_shop_id || undefined,
+      create_status: searchForm.create_status || undefined,
+      page_no: pageNo.value,
+      page_size: pageSize.value,
+    });
+    list.value = res.data.data?.items || res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  pageNo.value = 1;
+  load();
+}
+
+function handleReset() {
+  searchForm.enterprise_id = undefined;
+  searchForm.merchant_code = "";
+  searchForm.third_shop_id = "";
+  searchForm.create_status = "";
+  handleSearch();
+}
+
+function openCreate() {
+  Object.assign(form, defaultForm());
+  createVisible.value = true;
+}
+
+function handleCreateClosed() {
+  formRef.value?.clearValidate();
+  Object.assign(form, defaultForm());
+}
+
+async function handleCreate() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) {
+    return;
+  }
+  saving.value = true;
+  try {
+    const res = await HarAPI.shopCreate({ ...form });
+    const created = res.data.data;
+    if (created?.create_status === "PENDING") {
+      // 上游无查询接口,超时后本地只能标记 PENDING,是否已创建需人工核对
+      ElMessage.warning("未取得上游确认,门店已记录为「待核实」,请勿重复提交,等待人工核对结果");
+    } else {
+      ElMessage.success(`门店创建成功,平台门店编号:${created?.shop_id || "-"}`);
+    }
+    createVisible.value = false;
+    load();
+  } finally {
+    saving.value = false;
+  }
+}
+
+async function openDetail(row: HarShopVO) {
+  const res = await HarAPI.shopDetail(row.id as string);
+  detail.value = res.data.data;
+  detailVisible.value = true;
+}
+
+onMounted(async () => {
+  load();
+  loadMerchantOptions();
+  try {
+    await enterpriseStore.loadEnterpriseList();
+  } catch {
+    // 企业列表加载失败不阻塞列表查询
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.form-tip {
+  font-size: 12px;
+  line-height: 1.6;
+  color: var(--el-text-color-secondary);
+}
+</style>