1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getMerchantOnboardingDetail, refreshMerchantOnboarding, refreshMerchantPaymentCapability, submitMerchantRealname } from '../api/admin'
import { hasAdminPermission } from '../utils/permissions'
import { ElMessage } from 'element-plus'
 
const route = useRoute()
const loading = ref(false)
const detail = ref<Record<string, any>>()
const syncing = ref(false)
const stageLabel = (value: string) => ({ confirmed: '已确认', pending: '处理中', action_required: '待办理', missing: '待补齐', failed: '失败', unknown: '未确认' }[value] || value)
async function refreshProgress(retry = false) {
  if (syncing.value) return
  syncing.value = true
  try {
    const progress = await refreshMerchantOnboarding(String(route.params.id), retry)
    if (detail.value) detail.value.progress = progress
  } finally { syncing.value = false }
}
 
function realnameStage() {
  const stages = detail.value?.progress?.stages
  return Array.isArray(stages) ? stages.find((stage: Record<string, any>) => stage.key === 'realname') : undefined
}
 
// 查询通道状态:重新读一次斗拱侧的收款能力快照(含结算卡绑定状态)。进件通过后**新增**的结算卡
// 只有绑定回调能推进,回调一丢就永远停在处理中——这条查询路径就是它的对等物。
async function refreshChannelStatus() {
  if (syncing.value) return
  syncing.value = true
  try {
    await refreshMerchantPaymentCapability(String(route.params.id))
    ElMessage.success('已按通道最新状态刷新')
    await load()
  } catch (error: any) {
    ElMessage.error(error?.message || '查询通道状态失败,请稍后重试')
  } finally { syncing.value = false }
}
// 只有「还没发起」或「已被通道驳回」才给发起入口:处理中、待本人办理、已确认都不能重复发起,
// 未决申请重复提交会给同一商户报出两份实名申请。
function realnameActionable() {
  const status = realnameStage()?.status
  return status === 'missing' || status === 'failed'
}
async function startRealname() {
  if (syncing.value) return
  syncing.value = true
  try {
    const progress = await submitMerchantRealname(String(route.params.id), realnameStage()?.status === 'failed')
    if (detail.value) detail.value.progress = progress
    ElMessage.success('实名认证已提交,请按提示完成办理')
  } catch (error: any) {
    ElMessage.error(error?.message || '发起实名失败,请稍后重试')
  } finally { syncing.value = false }
}
 
function text(value: unknown) {
  return value === undefined || value === null || value === '' ? '-' : String(value)
}
 
function dateText(value: unknown) {
  const result = text(value)
  return /^\d{8}$/.test(result) ? `${result.slice(0, 4)}-${result.slice(4, 6)}-${result.slice(6, 8)}` : result
}
 
/** 后端给的是 ISO 串(2026-09-18T14:35:49),去掉 T 和毫秒,和列表页的写法保持一致。 */
function dateTimeText(value: unknown) {
  if (value === undefined || value === null || value === '') return '-'
  return String(value).replace('T', ' ').replace(/\.\d+$/, '')
}
 
function statusLabel(value: unknown) {
  return ({ pending: '待审核', approved: '已通过', rejected: '已驳回' } as Record<string, string>)[String(value || '')] || text(value)
}
 
function subjectTypeLabel(value: unknown) {
  const normalized = String(value || '').toLowerCase()
  if (normalized === 'enterprise' || normalized === 'company' || normalized === '企业') return '企业'
  return normalized === 'individual' || normalized === 'person' || normalized === '个人' ? '个人' : text(value)
}
 
function isIndividual(value: unknown) {
  const normalized = String(value || '').toLowerCase()
  return normalized === 'individual' || normalized === 'person' || normalized === '个人'
}
 
function validityTypeLabel(value: unknown) {
  const normalized = String(value || '').toLowerCase()
  if (normalized === 'long_term' || normalized === 'long-term' || normalized === '长期' || normalized === '1') return '长期'
  if (normalized === 'fixed' || normalized === '非长期' || normalized === '0') return '非长期'
  return text(value)
}
 
async function load() {
  loading.value = true
  try {
    detail.value = await getMerchantOnboardingDetail(String(route.params.id))
  } finally {
    loading.value = false
  }
}
 
onMounted(load)
</script>
 
<template>
  <div v-loading="loading" class="onboarding-detail-page">
    <section v-if="detail" class="detail-panel">
      <h3>审核状态</h3>
      <!-- 审核状态原来在 progress 存在时会被「开通尚未完成」顶掉,运营看不到这条进件到底审没审。
           两者是不同的事:审核是这条申请的结论,开通是通道能力推进到哪一步。 -->
      <div class="status-value">{{ statusLabel(detail.status) }}</div>
      <el-alert
        v-if="detail.rejectReason"
        class="reject-reason"
        type="error"
        :closable="false"
        :title="`驳回原因:${detail.rejectReason}`"
      />
 
      <h3 class="onboarding-heading">进件信息</h3>
      <div class="detail-grid">
        <div class="detail-field"><span>进件编号</span><strong>{{ text(detail.applicationNo) }}</strong></div>
        <div class="detail-field"><span>申请时间</span><strong>{{ dateTimeText(detail.createdAt) }}</strong></div>
        <div class="detail-field"><span>商户编号</span><strong>{{ text(detail.merchantId) }}</strong></div>
        <div class="detail-field"><span>所属门店</span><strong>{{ text(detail.storeName) }}</strong></div>
        <div class="detail-field"><span>通道商户号</span><strong>{{ text(detail.providerHuifuId) }}</strong></div>
        <div class="detail-field"><span>通道申请单号</span><strong>{{ text(detail.providerApplyNo || detail.providerRequestNo) }}</strong></div>
        <div class="detail-field"><span>审核时间</span><strong>{{ dateTimeText(detail.reviewedAt) }}</strong></div>
        <div class="detail-field"><span>最后更新时间</span><strong>{{ dateTimeText(detail.updatedAt) }}</strong></div>
      </div>
 
      <template v-if="detail.progress">
        <h3 class="onboarding-heading">开通状态</h3>
        <div class="status-value">{{ detail.progress.status === 'approved' ? '开通已完成' : '开通尚未完成' }}</div>
      </template>
      <section v-if="detail.progress" class="progress-panel">
        <p>{{ detail.progress.reason }}</p>
        <p>最后成功同步:{{ dateTimeText(detail.progress.lastSyncedAt) }}</p>
        <el-alert v-if="detail.progress.syncError" :title="detail.progress.syncError" type="warning" :closable="false" />
        <div v-for="stage in detail.progress.stages" :key="stage.key" class="progress-stage">
          <strong>{{ stage.label }} · {{ stageLabel(stage.status) }}</strong>
          <p>{{ stage.reason }}</p>
          <img v-if="stage.qrCode" :src="stage.qrCode" alt="微信实名授权二维码" width="180" height="180" />
        </div>
        <p>刷新会查询通道进度,并继续办理已满足条件的缺失能力。待本人实名或授权时,请办理后再次刷新。</p>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit')" type="primary" :loading="syncing" @click="refreshProgress()">刷新进度</el-button>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && detail.progress.retryable" :disabled="syncing" @click="refreshProgress(true)">重试失败步骤</el-button>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit')" :disabled="syncing" @click="refreshChannelStatus()">查询通道状态</el-button>
        <el-button v-if="hasAdminPermission('admin.merchant-settlement.edit') && realnameActionable()" :disabled="syncing" @click="startRealname()">{{ realnameStage()?.status === 'failed' ? '重新发起微信实名' : '发起微信实名' }}</el-button>
      </section>
 
      <h3 class="onboarding-heading">商户进件</h3>
      <div class="detail-grid common-grid">
        <div class="detail-field"><span>商户类型</span><strong>{{ subjectTypeLabel(detail.subjectType) }}</strong></div>
      </div>
      <div v-if="!isIndividual(detail.subjectType)" class="detail-grid">
        <div class="detail-field"><span>商户名称</span><strong>{{ text(detail.onboardingMerchantName) }}</strong></div>
        <div class="detail-field"><span>商户简称</span><strong>{{ text(detail.merchantShortName) }}</strong></div>
        <div class="detail-field"><span>公司类型</span><strong>{{ text(detail.companyType) }}</strong></div>
        <div class="detail-field"><span>统一社会信用代码</span><strong>{{ text(detail.creditCode) }}</strong></div>
        <div class="detail-field"><span>营业执照</span><strong><a v-if="detail.businessLicenseUrl" :href="detail.businessLicenseUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>营业执照有效期类型</span><strong>{{ validityTypeLabel(detail.licenseValidityType) }}</strong></div>
        <div class="detail-field"><span>证照有效期起始日期</span><strong>{{ dateText(detail.licenseBeginDate) }}</strong></div>
        <div class="detail-field"><span>证照有效期结束日期</span><strong>{{ dateText(detail.licenseEndDate) }}</strong></div>
        <div class="detail-field"><span>成立时间</span><strong>{{ dateText(detail.establishedDate) }}</strong></div>
        <div class="detail-field"><span>注册地址(省、市、区)</span><strong>{{ text(detail.registeredRegion) }}</strong></div>
        <div class="detail-field detail-field--auto-height"><span>详细注册地址</span><strong>{{ text(detail.registeredAddress) }}</strong></div>
        <div class="detail-field"><span>注册资本</span><strong>{{ text(detail.registeredCapital) }}</strong></div>
        <div class="detail-field"><span>法人姓名</span><strong>{{ text(detail.subjectName) }}</strong></div>
        <div class="detail-field"><span>法人证件类型</span><strong>身份证</strong></div>
        <div class="detail-field"><span>法人身份证人像面</span><strong><a v-if="detail.legalIdFrontUrl" :href="detail.legalIdFrontUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>法人身份证国徽面</span><strong><a v-if="detail.legalIdBackUrl" :href="detail.legalIdBackUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>法人证件号码</span><strong>{{ text(detail.subjectIdNo || detail.subjectIdNoMasked) }}</strong></div>
        <div class="detail-field"><span>法人证件有效期类型</span><strong>{{ validityTypeLabel(detail.subjectCertValidityType) }}</strong></div>
        <div class="detail-field"><span>法人证件有效期开始日期</span><strong>{{ dateText(detail.subjectCertBeginDate) }}</strong></div>
        <div class="detail-field"><span>法人证件有效期截止日期</span><strong>{{ dateText(detail.subjectCertEndDate) }}</strong></div>
        <div class="detail-field"><span>管理员姓名</span><strong>{{ text(detail.contactName) }}</strong></div>
        <div class="detail-field"><span>管理员手机号</span><strong>{{ text(detail.contactMobile) }}</strong></div>
        <div class="detail-field detail-field--auto-height"><span>法人证件地址</span><strong>{{ text(detail.legalCertificateAddress) }}</strong></div>
        <div class="detail-field"><span>管理员邮箱</span><strong>{{ text(detail.administratorEmail) }}</strong></div>
      </div>
      <div v-else class="detail-grid">
        <div class="detail-field"><span>个人姓名</span><strong>{{ text(detail.subjectName) }}</strong></div>
        <div class="detail-field"><span>个人证件类型</span><strong>身份证</strong></div>
        <div class="detail-field"><span>个人证件号码</span><strong>{{ text(detail.subjectIdNo || detail.subjectIdNoMasked) }}</strong></div>
        <div class="detail-field"><span>个人证件有效期类型</span><strong>{{ validityTypeLabel(detail.subjectCertValidityType) }}</strong></div>
        <div class="detail-field"><span>个人证件有效期开始日期</span><strong>{{ dateText(detail.subjectCertBeginDate) }}</strong></div>
        <div class="detail-field"><span>个人证件有效期截止日期</span><strong>{{ dateText(detail.subjectCertEndDate) }}</strong></div>
        <div class="detail-field"><span>手机号</span><strong>{{ text(detail.contactMobile) }}</strong></div>
        <!-- 个人主体的 company_type 存的是进件时选的经营行业(企业主体那一列才是公司类型),
             它同时决定上报汇付的 MCC,所以运营要能在这里核对。 -->
        <div class="detail-field"><span>经营行业</span><strong>{{ text(detail.companyType) }}</strong></div>
        <div class="detail-field"><span>经营地区(省、市、区)</span><strong>{{ text(detail.registeredRegion) }}</strong></div>
        <div class="detail-field detail-field--auto-height"><span>个人证件地址</span><strong>{{ text(detail.legalCertificateAddress) }}</strong></div>
        <div class="detail-field"><span>邮箱</span><strong>{{ text(detail.administratorEmail) }}</strong></div>
        <div class="detail-field"><span>个人身份证人像面</span><strong><a v-if="detail.legalIdFrontUrl" :href="detail.legalIdFrontUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>个人身份证国徽面</span><strong><a v-if="detail.legalIdBackUrl" :href="detail.legalIdBackUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
      </div>
 
      <!-- 结算卡和门店照片对企业主体同样必要,原来只画在个人主体那一支:企业进件即使传了
           也看不到,而微信业务开通与微信实名认证都要这张店铺照片。 -->
      <h3 class="onboarding-heading">结算银行卡</h3>
      <div v-if="detail.initialBankCardId" class="detail-grid">
        <div class="detail-field"><span>卡号</span><strong>{{ text(detail.settlementCardMasked) }}</strong></div>
        <div class="detail-field"><span>开户银行</span><strong>{{ text(detail.settlementBankName) }}</strong></div>
        <div class="detail-field"><span>开户支行</span><strong>{{ text(detail.settlementBranchName) }}</strong></div>
        <div class="detail-field"><span>开户城市</span><strong>{{ text(detail.settlementCityName) }}</strong></div>
        <div class="detail-field"><span>绑定状态</span><strong>{{ statusLabel(detail.settlementCardStatus) }}</strong></div>
        <!-- 结算凭证图一列两义:对私(含个体工商户)是银行卡卡号面,对公是开户许可证。
             名字按主体切换,判定来自后端,前端不另抄一份规则。 -->
        <div class="detail-field"><span>{{ detail.privateSettlement ? '结算银行卡卡号面' : '开户许可证' }}</span><strong><a v-if="detail.settlementCardFrontUrl" :href="detail.settlementCardFrontUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div v-if="detail.settlementCardRejectReason" class="detail-field detail-field--auto-height"><span>绑定失败原因</span><strong>{{ detail.settlementCardRejectReason }}</strong></div>
      </div>
      <div v-else class="detail-grid">
        <div class="detail-field"><span>结算银行卡</span><strong>未绑定</strong></div>
      </div>
 
      <h3 class="onboarding-heading">门店照片</h3>
      <div class="detail-grid">
        <div class="detail-field"><span>店铺照片</span><strong><a v-if="detail.storeHeaderUrl" :href="detail.storeHeaderUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>店内环境照</span><strong><a v-if="detail.storeIndoorUrl" :href="detail.storeIndoorUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
      </div>
    </section>
  </div>
</template>
 
<style scoped>
.onboarding-detail-page { min-width: 1040px; }
.progress-panel { margin: 18px 0; padding: 16px; background: #f5f7fa; font-size: 14px; }
.progress-stage { padding: 12px 0; border-bottom: 1px solid #e2e6ec; }
.progress-stage p { margin: 6px 0; color: #606266; }
.detail-panel { min-height: 560px; padding: 18px 20px 54px; background: #fff; border: 1px solid #edf0f5; }
.detail-panel h3 { margin: 0; color: #1d2129; font-size: 15px; }
.status-value { margin-top: 16px; color: #1d2129; font-size: 13px; font-weight: 600; }
.reject-reason { margin-top: 12px; }
.onboarding-heading { margin-top: 24px !important; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px 46px; margin-top: 22px; max-width: 1032px; }
.common-grid { margin-bottom: -2px; }
.detail-field { display: grid; grid-template-columns: 144px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 32px; }
.detail-field > span { color: #303133; font-size: 13px; font-weight: 500; white-space: nowrap; }
.detail-field > strong { min-width: 0; min-height: 32px; box-sizing: border-box; padding: 7px 10px; color: #606266; font-size: 13px; font-weight: 400; line-height: 18px; word-break: break-all; background: #f5f7fa; }
.detail-field--auto-height { align-items: stretch; }
.detail-field--auto-height > strong { height: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.placeholder-field { visibility: hidden; }
</style>