manageInfo.vue 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
  1. <template>
  2. <div class="manage-info-page">
  3. <h1 class="page-title">经营信息</h1>
  4. <el-form
  5. ref="formRef"
  6. class="manage-form"
  7. :model="form"
  8. :rules="rules"
  9. label-width="200px"
  10. require-asterisk-position="right"
  11. label-position="right"
  12. >
  13. <!-- 基础经营信息 -->
  14. <section class="form-block">
  15. <h2 class="section-head">
  16. <span class="section-bar" aria-hidden="true" />
  17. 经营信息
  18. </h2>
  19. <el-form-item prop="merchantShortName">
  20. <template #label>
  21. <span class="label-with-icon">
  22. 商户简称
  23. <el-tooltip placement="top" content="简称将展示给消费者,需与营业执照或品牌一致,勿使用无关或违规词汇。">
  24. <el-icon class="info-icon" :size="16"><InfoFilled /></el-icon>
  25. </el-tooltip>
  26. </span>
  27. </template>
  28. <div class="field-stack">
  29. <el-input
  30. v-model="form.merchantShortName"
  31. placeholder="请输入商户简称"
  32. clearable
  33. maxlength="64"
  34. show-word-limit
  35. style="max-width: 480px"
  36. />
  37. <p class="field-tip">
  38. 1. 在支付完成页向买家展示,需与微信经营类目相关。<br />
  39. 2.简称要求:<br />
  40. 不支持单纯以人名来命名,若为个体户经营,可用“个体户+经营者名称”或“经营者名称+业务”命名,如个体户“张三”或“张三餐饮店”;<br />
  41. 不支持无实际意义的文案,如“XX”特约商户,“800”,“XX客服电话XXX”;
  42. </p>
  43. </div>
  44. </el-form-item>
  45. <el-form-item prop="servicePhone">
  46. <template #label>
  47. <span class="label-with-icon">
  48. 客服电话
  49. <el-tooltip placement="top" content="用于用户咨询与平台联系,请填写真实可接通号码。">
  50. <el-icon class="info-icon" :size="16"><InfoFilled /></el-icon>
  51. </el-tooltip>
  52. </span>
  53. </template>
  54. <div class="field-stack">
  55. <el-input
  56. v-model="form.servicePhone"
  57. placeholder="请输入客服电话"
  58. clearable
  59. maxlength="20"
  60. style="max-width: 480px"
  61. />
  62. <p class="field-tip">
  63. 1.请填写真实、可接通的客服电话,以便用户咨询。<br />
  64. 2.请确保电话畅通,以便入驻平台回拨确认。<br />
  65. </p>
  66. </div>
  67. </el-form-item>
  68. <el-form-item label="经营场景" prop="scenarioBits" required>
  69. <div class="field-stack">
  70. <div class="scenario-checks">
  71. <el-checkbox v-model="form.scenarios.offline"> 线下场景 </el-checkbox>
  72. <el-checkbox v-model="form.scenarios.miniProgram"> 小程序 </el-checkbox>
  73. <el-checkbox v-model="form.scenarios.app"> APP </el-checkbox>
  74. <el-checkbox v-model="form.scenarios.wechatAccount"> 服务号或订阅号 </el-checkbox>
  75. <el-checkbox v-model="form.scenarios.website"> 互联网网站 </el-checkbox>
  76. <el-checkbox v-model="form.scenarios.wecom"> 企业微信 </el-checkbox>
  77. </div>
  78. <p class="field-tip">
  79. 请勾选实际售卖商品/提供服务场景(至少一项),以便为你开通需要的支付权限<br />
  80. 建议只勾选目前必须的尝尽,以便尽快通过入驻审核,其他支付权限你可在入驻后再根据实际需要发起申请
  81. </p>
  82. </div>
  83. </el-form-item>
  84. </section>
  85. <!-- 线下场景 -->
  86. <section v-if="form.scenarios.offline" class="form-block sub-section">
  87. <h2 class="section-head">
  88. <span class="section-bar" aria-hidden="true" />
  89. 线下场所
  90. <span class="section-intro"
  91. >你选择了“线下场所”场景,入驻成功后,服务商可帮商户发起付款码支付,JSAPI支付,同时系统将会对地址等信息进行核实</span
  92. >
  93. </h2>
  94. <el-form-item label="线下场所名称" prop="offlineVenueName">
  95. <el-input v-model="form.offlineVenueName" placeholder="请输入线下场所名称" clearable style="max-width: 480px" />
  96. </el-form-item>
  97. <el-form-item label="线下场所省市" prop="offlineProvinceCity">
  98. <el-select v-model="form.offlineProvince" placeholder="请选择省" filterable style="width: 200px">
  99. <el-option v-for="p in provinceOptions" :key="p.value" :label="p.label" :value="p.value" />
  100. </el-select>
  101. <el-select
  102. v-model="form.offlineCity"
  103. placeholder="请选择市"
  104. filterable
  105. style="width: 200px; margin-left: 12px"
  106. :disabled="!form.offlineProvince"
  107. >
  108. <el-option v-for="c in cityOptions" :key="c.value" :label="c.label" :value="c.value" />
  109. </el-select>
  110. </el-form-item>
  111. <el-form-item label="线下场所地址" prop="offlineAddress">
  112. <div class="field-stack">
  113. <el-input
  114. v-model="form.offlineAddress"
  115. type="textarea"
  116. :rows="2"
  117. placeholder="请输入详细地址"
  118. maxlength="200"
  119. show-word-limit
  120. style="max-width: 480px"
  121. />
  122. <p class="field-tip">如有多个经营场所,请填写主要营业地址。</p>
  123. </div>
  124. </el-form-item>
  125. <el-form-item prop="offlineStorefrontUrls">
  126. <template #label>
  127. <span class="label-with-icon">
  128. 线下场所门头照片
  129. <el-tooltip placement="top" content="需清晰展示门头招牌与店名。">
  130. <el-icon class="info-icon" :size="16"><InfoFilled /></el-icon>
  131. </el-tooltip>
  132. </span>
  133. </template>
  134. <div class="field-stack">
  135. <el-upload
  136. v-model:file-list="form.offlineStorefrontFileList"
  137. list-type="picture-card"
  138. :limit="5"
  139. accept=".jpg,.jpeg,.png,.bmp,image/jpeg,image/png,image/bmp"
  140. :before-upload="beforeImageUpload"
  141. :http-request="opt => handleMultiUpload(opt, 'storefront')"
  142. :on-remove="(f, fl) => onMultiRemove(f, fl, 'storefront')"
  143. >
  144. <el-icon><Plus /></el-icon>
  145. </el-upload>
  146. <p class="field-tip">
  147. 1.场景图片正面拍摄且清晰,完整,图片不得有遮挡;<br />
  148. 2.门店招牌清晰,招牌名称,文字可辨识,门框完整,且店面显示在营,若为停车场等无固定门头照片的经营场所,可上传岗亭/出入闸口。
  149. </p>
  150. </div>
  151. </el-form-item>
  152. <el-form-item prop="offlineInteriorUrls">
  153. <template #label>
  154. <span class="label-with-icon">
  155. 线下场所内部照片
  156. <el-tooltip placement="top" content="展示店内经营环境。">
  157. <el-icon class="info-icon" :size="16"><InfoFilled /></el-icon>
  158. </el-tooltip>
  159. </span>
  160. </template>
  161. <div class="field-stack">
  162. <el-upload
  163. v-model:file-list="form.offlineInteriorFileList"
  164. list-type="picture-card"
  165. :limit="5"
  166. accept=".jpg,.jpeg,.png,.bmp,image/jpeg,image/png,image/bmp"
  167. :before-upload="beforeImageUpload"
  168. :http-request="opt => handleMultiUpload(opt, 'interior')"
  169. :on-remove="(f, fl) => onMultiRemove(f, fl, 'interior')"
  170. >
  171. <el-icon><Plus /></el-icon>
  172. </el-upload>
  173. <p class="field-tip">
  174. 1.场景图片正面拍摄且清晰,完整,图片不得有遮挡;<br />
  175. 2.门店招牌清晰,招牌名称,文字可辨识,门框完整,且店面显示在营,若为停车场等无固定门头照片的经营场所,可上传岗亭/出入闸口。
  176. </p>
  177. </div>
  178. </el-form-item>
  179. <el-form-item label="线下场所对应的服务号或公众号 APPID">
  180. <div class="field-stack">
  181. <el-input v-model="form.offlineMpAppId" placeholder="选填" clearable style="max-width: 480px" />
  182. <p class="field-tip">
  183. 1.可填写已认领的服务号或公众号(需时已认证的服务号,政府或媒体类型的公众号),小程序,应用的APPID.<br />
  184. 2.完成进件后,系统发起特约商户号与该APPID的绑定(即配置为sub_appid,可在发起支付时传入)<br />
  185. (1)若APPID主体与商家主体一致,则直接完成绑定;<br />
  186. (2)若APPID主体与商家主体不一致,则需商户提供主体变更证明材料,审核通过后完成绑定。
  187. </p>
  188. </div>
  189. </el-form-item>
  190. </section>
  191. <!-- 小程序 -->
  192. <section v-if="form.scenarios.miniProgram" class="form-block sub-section">
  193. <h2 class="section-head">
  194. <span class="section-bar" aria-hidden="true" />
  195. 小程序<span class="section-intro">你选择了“小程序”场景,入驻成功后,服务商可帮商户发起JSAPI支付</span>
  196. </h2>
  197. <el-form-item label="小程序 APPID" prop="miniProgramAppId">
  198. <div class="field-stack">
  199. <el-input v-model="form.miniProgramAppId" placeholder="请输入小程序 APPID" clearable style="max-width: 480px" />
  200. <p class="field-tip">
  201. 1.请填写已认证小程序的 APPID。<br />
  202. 2.完成进件后,系统发起特约商户号与该APPID的绑定(即配置为sub_appid,可在发起支付时传入)<br />
  203. (1)若APPID主体与商家主体一致,则直接完成绑定;<br />
  204. (2)若APPID主体与商家主体不一致,则需商户提供主体变更证明材料,审核通过后完成绑定。
  205. </p>
  206. </div>
  207. </el-form-item>
  208. <el-form-item label="小程序截图">
  209. <div class="field-stack">
  210. <el-upload
  211. v-model:file-list="form.miniProgramShotFileList"
  212. list-type="picture-card"
  213. :limit="5"
  214. accept=".jpg,.jpeg,.png,.bmp,image/jpeg,image/png,image/bmp"
  215. :before-upload="beforeImageUpload"
  216. :http-request="opt => handleMultiUpload(opt, 'miniShot')"
  217. :on-remove="(f, fl) => onMultiRemove(f, fl, 'miniShot')"
  218. >
  219. <el-icon><Plus /></el-icon>
  220. </el-upload>
  221. <p class="field-tip">请提供展示商品/服务的页面截图/设计稿(最多5张),若小程序胃建设完善或未上线请务必提供。</p>
  222. </div>
  223. </el-form-item>
  224. </section>
  225. <!-- APP -->
  226. <section v-if="form.scenarios.app" class="form-block sub-section">
  227. <h2 class="section-head">
  228. <span class="section-bar" aria-hidden="true" />
  229. APP<span class="section-intro">你选择了“APP”场景,入驻成功后,服务商可帮商户发起APP支付</span>
  230. </h2>
  231. <el-form-item label="AppID 来源" prop="appIdSource" required>
  232. <el-radio-group v-model="form.appIdSource">
  233. <el-radio value="provider"> 服务商应用 AppID </el-radio>
  234. <el-radio value="merchant"> 商家应用 AppID </el-radio>
  235. </el-radio-group>
  236. </el-form-item>
  237. <el-form-item v-if="form.appIdSource === 'provider'" label="服务商应用 AppID" prop="providerAppId">
  238. <div class="field-stack">
  239. <el-select v-model="form.providerAppId" placeholder="请选择" filterable style="max-width: 480px">
  240. <el-option v-for="o in providerAppOptions" :key="o.value" :label="o.label" :value="o.value" />
  241. </el-select>
  242. </div>
  243. </el-form-item>
  244. <el-form-item prop="appShotUrls">
  245. <template #label>
  246. <span class="label-with-icon">
  247. APP 截图
  248. <el-tooltip placement="top" content="首页、商品列表、详情、支付等相关页面。">
  249. <el-icon class="info-icon" :size="16"><InfoFilled /></el-icon>
  250. </el-tooltip>
  251. </span>
  252. </template>
  253. <div class="field-stack">
  254. <el-upload
  255. v-model:file-list="form.appShotFileList"
  256. list-type="picture-card"
  257. :limit="5"
  258. accept=".jpg,.jpeg,.png,.bmp,image/jpeg,image/png,image/bmp"
  259. :before-upload="beforeImageUpload"
  260. :http-request="opt => handleMultiUpload(opt, 'appShot')"
  261. :on-remove="(f, fl) => onMultiRemove(f, fl, 'appShot')"
  262. >
  263. <el-icon><Plus /></el-icon>
  264. </el-upload>
  265. <p class="field-tip">请提供APP首页截图,尾页截图,应用内截图,支付页截图各1张</p>
  266. </div>
  267. </el-form-item>
  268. </section>
  269. </el-form>
  270. <div class="footer-actions">
  271. <el-button @click="onBack"> 返回 </el-button>
  272. <el-button type="primary" class="btn-next" :loading="submitLoading" @click="onSubmit"> 确定 </el-button>
  273. </div>
  274. </div>
  275. </template>
  276. <script setup lang="ts">
  277. import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
  278. import { useRoute, useRouter } from "vue-router";
  279. import { ElMessage } from "element-plus";
  280. import type { FormInstance, FormRules } from "element-plus";
  281. import type { UploadRequestOptions, UploadUserFile } from "element-plus";
  282. import { Plus, InfoFilled } from "@element-plus/icons-vue";
  283. import { getUpload } from "@/api/modules/businessInfo";
  284. import { localGet, localSet } from "@/utils/index";
  285. import cityJson from "@/assets/json/city.json";
  286. const BUSINESS_DATA_CACHE_KEY = "businessData";
  287. const formRef = ref<FormInstance>();
  288. const submitLoading = ref(false);
  289. const route = useRoute();
  290. const router = useRouter();
  291. type UploadKind = "storefront" | "interior" | "miniShot" | "appShot";
  292. const form = reactive({
  293. merchantShortName: "",
  294. servicePhone: "",
  295. scenarios: {
  296. offline: true,
  297. miniProgram: true,
  298. app: true,
  299. wechatAccount: false,
  300. website: false,
  301. wecom: false
  302. },
  303. offlineVenueName: "",
  304. offlineProvince: "",
  305. offlineCity: "",
  306. offlineAddress: "",
  307. offlineStorefrontFileList: [] as UploadUserFile[],
  308. offlineStorefrontUrls: [] as string[],
  309. offlineInteriorFileList: [] as UploadUserFile[],
  310. offlineInteriorUrls: [] as string[],
  311. offlineMpAppId: "",
  312. miniProgramAppId: "",
  313. miniProgramShotFileList: [] as UploadUserFile[],
  314. miniProgramShotUrls: [] as string[],
  315. appIdSource: "provider" as "provider" | "merchant",
  316. providerAppId: "",
  317. merchantAppId: "",
  318. appShotFileList: [] as UploadUserFile[],
  319. appShotUrls: [] as string[],
  320. /** 用于校验至少选一种经营场景 */
  321. scenarioBits: "1"
  322. });
  323. /** 与进件文档 business_info.sales_info.sales_scenes_type 枚举对应 */
  324. function buildSalesScenesType(): string[] {
  325. const s = form.scenarios;
  326. const types: string[] = [];
  327. if (s.offline) types.push("SALES_SCENES_STORE");
  328. if (s.miniProgram) types.push("SALES_SCENES_MINI_PROGRAM");
  329. if (s.app) types.push("SALES_SCENES_APP");
  330. if (s.wechatAccount) types.push("SALES_SCENES_MP");
  331. if (s.website) types.push("SALES_SCENES_WEB");
  332. if (s.wecom) types.push("SALES_SCENES_WEWORK");
  333. return types;
  334. }
  335. /** 上传组件更新 file-list 时可能丢 response,用 uid 备份 media_id */
  336. const wechatMediaIdByFileUid = new Map<number, string>();
  337. function getMediaIdFromUploadFile(f: UploadUserFile): string {
  338. const tagged = String((f as UploadUserFile & { __wxMediaId?: string }).__wxMediaId ?? "").trim();
  339. if (tagged) return tagged;
  340. const uid = (f as UploadUserFile & { uid?: number }).uid;
  341. if (uid != null && wechatMediaIdByFileUid.has(uid)) {
  342. return wechatMediaIdByFileUid.get(uid) ?? "";
  343. }
  344. const r = (f as UploadUserFile & { response?: unknown }).response;
  345. if (r && typeof r === "object" && !Array.isArray(r)) {
  346. const o = r as Record<string, unknown>;
  347. const direct = String(o.media_id ?? o.mediaId ?? "").trim();
  348. if (direct) return direct;
  349. const nested = extractMediaUploadMeta(r).mediaId;
  350. if (nested) return nested;
  351. }
  352. return "";
  353. }
  354. /** 写入进件缓存的四处图片字段均为微信 media/upload 返回的 media_id */
  355. function collectMediaIdsForMerge(list: UploadUserFile[]): string[] {
  356. return list
  357. .filter(f => f.status === "success")
  358. .map(f => getMediaIdFromUploadFile(f))
  359. .filter(Boolean);
  360. }
  361. function mergeManageInfoIntoBusinessDataCache() {
  362. const prev = localGet(BUSINESS_DATA_CACHE_KEY);
  363. const base = prev && typeof prev === "object" && !Array.isArray(prev) ? { ...(prev as Record<string, unknown>) } : {};
  364. const prevBi =
  365. base.business_info && typeof base.business_info === "object" && !Array.isArray(base.business_info)
  366. ? { ...(base.business_info as Record<string, unknown>) }
  367. : {};
  368. const prevSales =
  369. prevBi.sales_info && typeof prevBi.sales_info === "object" && !Array.isArray(prevBi.sales_info)
  370. ? { ...(prevBi.sales_info as Record<string, unknown>) }
  371. : {};
  372. const sales_info: Record<string, unknown> = {
  373. ...prevSales,
  374. sales_scenes_type: buildSalesScenesType()
  375. };
  376. if (form.scenarios.offline) {
  377. sales_info.biz_store_info = {
  378. biz_store_name: String(form.offlineVenueName || "").trim(),
  379. biz_address_code: String(form.offlineCity || "").trim(),
  380. biz_store_address: String(form.offlineAddress || "").trim(),
  381. store_entrance_pic: collectMediaIdsForMerge(form.offlineStorefrontFileList),
  382. indoor_pic: collectMediaIdsForMerge(form.offlineInteriorFileList),
  383. biz_sub_appid: String(form.offlineMpAppId || "").trim()
  384. };
  385. } else {
  386. delete sales_info.biz_store_info;
  387. }
  388. if (form.scenarios.miniProgram) {
  389. sales_info.mini_program_info = {
  390. mini_program_sub_appid: String(form.miniProgramAppId || "").trim(),
  391. mini_program_pics: collectMediaIdsForMerge(form.miniProgramShotFileList)
  392. };
  393. } else {
  394. delete sales_info.mini_program_info;
  395. }
  396. if (form.scenarios.app) {
  397. sales_info.app_info = {
  398. app_appid: String(form.providerAppId || "").trim(),
  399. app_sub_appid: String(form.merchantAppId || "").trim(),
  400. app_pics: collectMediaIdsForMerge(form.appShotFileList)
  401. };
  402. } else {
  403. delete sales_info.app_info;
  404. }
  405. base.business_info = {
  406. ...prevBi,
  407. merchant_shortname: String(form.merchantShortName || "").trim(),
  408. service_phone: String(form.servicePhone || "").trim(),
  409. sales_info
  410. };
  411. localSet(BUSINESS_DATA_CACHE_KEY, base);
  412. }
  413. type CityJsonEntry = { name: string; adCode: string; cityCode: string; type?: string };
  414. type CityJsonRoot = { cityList: { letter: string; list: CityJsonEntry[] }[] };
  415. /** GB 行政区划代码前两位 → 省级名称(与 city.json 中 adCode 对应) */
  416. const PROVINCE_ROWS: [string, string][] = [
  417. ["11", "北京市"],
  418. ["12", "天津市"],
  419. ["13", "河北省"],
  420. ["14", "山西省"],
  421. ["15", "内蒙古自治区"],
  422. ["21", "辽宁省"],
  423. ["22", "吉林省"],
  424. ["23", "黑龙江省"],
  425. ["31", "上海市"],
  426. ["32", "江苏省"],
  427. ["33", "浙江省"],
  428. ["34", "安徽省"],
  429. ["35", "福建省"],
  430. ["36", "江西省"],
  431. ["37", "山东省"],
  432. ["41", "河南省"],
  433. ["42", "湖北省"],
  434. ["43", "湖南省"],
  435. ["44", "广东省"],
  436. ["45", "广西壮族自治区"],
  437. ["46", "海南省"],
  438. ["50", "重庆市"],
  439. ["51", "四川省"],
  440. ["52", "贵州省"],
  441. ["53", "云南省"],
  442. ["54", "西藏自治区"],
  443. ["61", "陕西省"],
  444. ["62", "甘肃省"],
  445. ["63", "青海省"],
  446. ["64", "宁夏回族自治区"],
  447. ["65", "新疆维吾尔自治区"],
  448. ["71", "台湾省"],
  449. ["81", "香港特别行政区"],
  450. ["82", "澳门特别行政区"]
  451. ];
  452. const PROVINCE_BY_PREFIX = Object.fromEntries(PROVINCE_ROWS) as Record<string, string>;
  453. const PROVINCE_PREFIX_ORDER = PROVINCE_ROWS.map(([k]) => k);
  454. function buildProvinceCityOptions(): {
  455. label: string;
  456. value: string;
  457. cities: { label: string; value: string }[];
  458. }[] {
  459. const root = cityJson as CityJsonRoot;
  460. const flat = root.cityList.flatMap(g => g.list);
  461. const bucket = new Map<string, Map<string, CityJsonEntry>>();
  462. for (const c of flat) {
  463. const digits = String(c.adCode ?? "").replace(/\D/g, "");
  464. const code = digits.length >= 6 ? digits.slice(-6) : digits.padStart(6, "0");
  465. if (code.length !== 6) continue;
  466. const prefix = code.slice(0, 2);
  467. if (!PROVINCE_BY_PREFIX[prefix]) continue;
  468. if (!bucket.has(prefix)) bucket.set(prefix, new Map());
  469. bucket.get(prefix)!.set(c.adCode, c);
  470. }
  471. const out = [...bucket.entries()].map(([prefix, cityMap]) => ({
  472. value: prefix,
  473. label: PROVINCE_BY_PREFIX[prefix],
  474. cities: [...cityMap.values()]
  475. .sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))
  476. .map(ci => ({ label: ci.name, value: ci.adCode }))
  477. }));
  478. out.sort((a, b) => {
  479. const ia = PROVINCE_PREFIX_ORDER.indexOf(a.value);
  480. const ib = PROVINCE_PREFIX_ORDER.indexOf(b.value);
  481. return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
  482. });
  483. return out;
  484. }
  485. const provinceOptions = buildProvinceCityOptions();
  486. const cityOptions = ref<{ label: string; value: string }[]>([]);
  487. watch(
  488. () => form.offlineProvince,
  489. code => {
  490. form.offlineCity = "";
  491. const p = provinceOptions.find(x => x.value === code);
  492. cityOptions.value = p?.cities ?? [];
  493. }
  494. );
  495. const providerAppOptions = [
  496. { label: "请选择", value: "" },
  497. { label: "wxf5f1efe3a9f5012e", value: "wxf5f1efe3a9f5012e" }
  498. ];
  499. const scenarioChecked = computed(() => {
  500. const s = form.scenarios;
  501. return s.offline || s.miniProgram || s.app || s.wechatAccount || s.website || s.wecom;
  502. });
  503. watch(
  504. () => form.scenarios,
  505. () => {
  506. form.scenarioBits = scenarioChecked.value ? "1" : "";
  507. },
  508. { deep: true }
  509. );
  510. function asStringArray(v: unknown): string[] {
  511. if (!Array.isArray(v)) return [];
  512. return v.map(x => String(x ?? "").trim()).filter(Boolean);
  513. }
  514. /** 从 localStorage businessData.business_info 回显经营信息表单 */
  515. function hydrateManageFormFromBusinessDataCache() {
  516. const raw = localGet(BUSINESS_DATA_CACHE_KEY) as Record<string, unknown> | null | undefined;
  517. if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
  518. const bi = raw.business_info as Record<string, unknown> | undefined;
  519. if (!bi || typeof bi !== "object" || Array.isArray(bi)) return;
  520. form.merchantShortName = String(bi.merchant_shortname ?? "").trim();
  521. form.servicePhone = String(bi.service_phone ?? "").trim();
  522. let pendingOfflineCity = "";
  523. const si = bi.sales_info as Record<string, unknown> | undefined;
  524. if (si && typeof si === "object" && !Array.isArray(si)) {
  525. const sceneTypes = si.sales_scenes_type;
  526. if (Array.isArray(sceneTypes)) {
  527. const set = new Set(sceneTypes.map(t => String(t)));
  528. form.scenarios.offline = set.has("SALES_SCENES_STORE");
  529. form.scenarios.miniProgram = set.has("SALES_SCENES_MINI_PROGRAM");
  530. form.scenarios.app = set.has("SALES_SCENES_APP");
  531. form.scenarios.wechatAccount = set.has("SALES_SCENES_MP");
  532. form.scenarios.website = set.has("SALES_SCENES_WEB");
  533. form.scenarios.wecom = set.has("SALES_SCENES_WEWORK");
  534. form.scenarioBits = scenarioChecked.value ? "1" : "";
  535. }
  536. const bzs = si.biz_store_info as Record<string, unknown> | undefined;
  537. if (bzs && typeof bzs === "object" && !Array.isArray(bzs)) {
  538. form.offlineVenueName = String(bzs.biz_store_name ?? "").trim();
  539. const digits = String(bzs.biz_address_code ?? "").replace(/\D/g, "");
  540. const code6 = digits.length >= 6 ? digits.slice(-6) : digits;
  541. if (code6.length >= 2) {
  542. const prefix = code6.slice(0, 2);
  543. form.offlineProvince = prefix;
  544. const p = provinceOptions.find(x => x.value === prefix);
  545. cityOptions.value = p?.cities ?? [];
  546. if (code6.length >= 6) pendingOfflineCity = code6;
  547. }
  548. form.offlineAddress = String(bzs.biz_store_address ?? "").trim();
  549. const se = asStringArray(bzs.store_entrance_pic);
  550. const indoor = asStringArray(bzs.indoor_pic);
  551. form.offlineStorefrontUrls = [...se];
  552. form.offlineInteriorUrls = [...indoor];
  553. form.offlineStorefrontFileList = se.map(
  554. (url, i) =>
  555. ({
  556. name: `storefront-${i + 1}.jpg`,
  557. url,
  558. status: "success",
  559. response: { url, media_id: url, mediaId: url },
  560. __wxMediaId: url
  561. }) as UploadUserFile
  562. );
  563. form.offlineInteriorFileList = indoor.map(
  564. (url, i) =>
  565. ({
  566. name: `interior-${i + 1}.jpg`,
  567. url,
  568. status: "success",
  569. response: { url, media_id: url, mediaId: url },
  570. __wxMediaId: url
  571. }) as UploadUserFile
  572. );
  573. form.offlineMpAppId = String(bzs.biz_sub_appid ?? "").trim();
  574. }
  575. const mpi = si.mini_program_info as Record<string, unknown> | undefined;
  576. if (mpi && typeof mpi === "object" && !Array.isArray(mpi)) {
  577. form.miniProgramAppId = String(mpi.mini_program_sub_appid ?? "").trim();
  578. const pics = asStringArray(mpi.mini_program_pics);
  579. form.miniProgramShotUrls = [...pics];
  580. form.miniProgramShotFileList = pics.map(
  581. (url, i) =>
  582. ({
  583. name: `mini-${i + 1}.jpg`,
  584. url,
  585. status: "success",
  586. response: { url, media_id: url, mediaId: url },
  587. __wxMediaId: url
  588. }) as UploadUserFile
  589. );
  590. }
  591. const appInf = si.app_info as Record<string, unknown> | undefined;
  592. if (appInf && typeof appInf === "object" && !Array.isArray(appInf)) {
  593. form.providerAppId = String(appInf.app_appid ?? "").trim();
  594. form.merchantAppId = String(appInf.app_sub_appid ?? "").trim();
  595. form.appIdSource = form.providerAppId ? "provider" : "merchant";
  596. const pics = asStringArray(appInf.app_pics);
  597. form.appShotUrls = [...pics];
  598. form.appShotFileList = pics.map(
  599. (url, i) =>
  600. ({
  601. name: `app-${i + 1}.jpg`,
  602. url,
  603. status: "success",
  604. response: { url, media_id: url, mediaId: url },
  605. __wxMediaId: url
  606. }) as UploadUserFile
  607. );
  608. }
  609. }
  610. nextTick(() => {
  611. if (pendingOfflineCity) form.offlineCity = pendingOfflineCity;
  612. formRef.value?.clearValidate();
  613. });
  614. }
  615. onMounted(() => {
  616. hydrateManageFormFromBusinessDataCache();
  617. });
  618. const rules: FormRules = {
  619. merchantShortName: [{ required: true, message: "请输入商户简称", trigger: "blur" }],
  620. servicePhone: [
  621. { required: true, message: "请输入客服电话", trigger: "blur" },
  622. { pattern: /^[\d\-+\s]{5,20}$/, message: "请输入有效电话号码", trigger: "blur" }
  623. ],
  624. scenarioBits: [{ required: true, message: "请至少选择一种经营场景", trigger: "change" }],
  625. offlineVenueName: [
  626. {
  627. validator: (_r, _v, cb) => {
  628. if (form.scenarios.offline && !String(form.offlineVenueName).trim()) {
  629. cb(new Error("请输入线下场所名称"));
  630. } else cb();
  631. },
  632. trigger: "blur"
  633. }
  634. ],
  635. offlineProvinceCity: [
  636. {
  637. validator: (_r, _v, cb) => {
  638. if (form.scenarios.offline && (!form.offlineProvince || !form.offlineCity)) {
  639. cb(new Error("请选择线下场所省市"));
  640. } else cb();
  641. },
  642. trigger: "change"
  643. }
  644. ],
  645. offlineAddress: [
  646. {
  647. validator: (_r, _v, cb) => {
  648. if (form.scenarios.offline && !String(form.offlineAddress).trim()) {
  649. cb(new Error("请输入线下场所地址"));
  650. } else cb();
  651. },
  652. trigger: "blur"
  653. }
  654. ],
  655. offlineStorefrontUrls: [
  656. {
  657. validator: (_r, _v, cb) => {
  658. if (form.scenarios.offline && form.offlineStorefrontUrls.length < 1) {
  659. cb(new Error("请上传门头照片"));
  660. } else cb();
  661. },
  662. trigger: "change"
  663. }
  664. ],
  665. offlineInteriorUrls: [
  666. {
  667. validator: (_r, _v, cb) => {
  668. if (form.scenarios.offline && form.offlineInteriorUrls.length < 1) {
  669. cb(new Error("请上传内部照片"));
  670. } else cb();
  671. },
  672. trigger: "change"
  673. }
  674. ],
  675. miniProgramAppId: [
  676. {
  677. validator: (_r, _v, cb) => {
  678. if (form.scenarios.miniProgram && !String(form.miniProgramAppId).trim()) {
  679. cb(new Error("请输入小程序 APPID"));
  680. } else cb();
  681. },
  682. trigger: "blur"
  683. }
  684. ],
  685. providerAppId: [
  686. {
  687. validator: (_r, _v, cb) => {
  688. if (form.scenarios.app && form.appIdSource === "provider" && !String(form.providerAppId).trim()) {
  689. cb(new Error("请选择服务商应用 AppID"));
  690. } else cb();
  691. },
  692. trigger: "change"
  693. }
  694. ],
  695. appShotUrls: [
  696. {
  697. validator: (_r, _v, cb) => {
  698. if (form.scenarios.app && form.appShotUrls.length < 1) {
  699. cb(new Error("请上传 APP 截图"));
  700. } else cb();
  701. },
  702. trigger: "change"
  703. }
  704. ]
  705. };
  706. const MAX_IMG_MB = 20;
  707. function beforeImageUpload(file: File) {
  708. const name = file.name?.toLowerCase() || "";
  709. const okExt = /\.(jpe?g|png|bmp)$/i.test(name);
  710. const okMime = ["image/jpeg", "image/png", "image/bmp"].includes(file.type);
  711. if (!okExt && !okMime) {
  712. ElMessage.error("图片只支持 JPG、BMP、PNG 格式");
  713. return false;
  714. }
  715. if (file.size / 1024 / 1024 > MAX_IMG_MB) {
  716. ElMessage.error(`文件大小不能超过 ${MAX_IMG_MB}M`);
  717. return false;
  718. }
  719. return true;
  720. }
  721. function urlListKey(kind: UploadKind): "offlineStorefrontUrls" | "offlineInteriorUrls" | "miniProgramShotUrls" | "appShotUrls" {
  722. const map = {
  723. storefront: "offlineStorefrontUrls",
  724. interior: "offlineInteriorUrls",
  725. miniShot: "miniProgramShotUrls",
  726. appShot: "appShotUrls"
  727. } as const;
  728. return map[kind];
  729. }
  730. /** 解析 getUpload 响应:兼容 data 为字符串、双层 data、嵌套对象等 */
  731. function extractMediaUploadMeta(res: any): { fileUrl: string; mediaId: string; errMsg: string } {
  732. const errMsg = String(res?.msg ?? res?.message ?? res?.data?.msg ?? res?.data?.message ?? "").trim();
  733. const pickFromObject = (node: any): { fileUrl: string; mediaId: string } => {
  734. if (!node || typeof node !== "object" || Array.isArray(node)) return { fileUrl: "", mediaId: "" };
  735. const mediaId = String(node.media_id ?? node.mediaId ?? node.MediaId ?? "").trim();
  736. const fileUrl = String(node.url ?? node.fileUrl ?? node.mediaUrl ?? node.picUrl ?? "").trim();
  737. return { fileUrl, mediaId };
  738. };
  739. let root = res;
  740. const rawData = root?.data;
  741. if (rawData != null && typeof rawData === "string") {
  742. const s = rawData.trim();
  743. if (s.startsWith("{") || s.startsWith("[")) {
  744. try {
  745. root = { ...(root && typeof root === "object" ? root : {}), data: JSON.parse(s) };
  746. } catch {
  747. return { fileUrl: "", mediaId: s, errMsg };
  748. }
  749. } else if (s) {
  750. return { fileUrl: "", mediaId: s, errMsg };
  751. }
  752. }
  753. const candidates: any[] = [];
  754. const push = (x: any) => {
  755. if (x != null) candidates.push(x);
  756. };
  757. push(root);
  758. push(root?.data);
  759. if (root?.data != null && typeof root.data === "object" && !Array.isArray(root.data)) {
  760. push(root.data.data);
  761. for (const v of Object.values(root.data)) {
  762. if (v && typeof v === "object" && !Array.isArray(v)) push(v);
  763. }
  764. }
  765. let fileUrl = "";
  766. let mediaId = "";
  767. for (const c of candidates) {
  768. if (typeof c === "string" && c.trim()) {
  769. mediaId = c.trim();
  770. break;
  771. }
  772. if (!c || typeof c !== "object") continue;
  773. const p = pickFromObject(c);
  774. if (p.mediaId) {
  775. mediaId = p.mediaId;
  776. fileUrl = p.fileUrl || fileUrl;
  777. break;
  778. }
  779. for (const v of Object.values(c)) {
  780. if (v && typeof v === "object" && !Array.isArray(v)) {
  781. const p2 = pickFromObject(v);
  782. if (p2.mediaId) {
  783. mediaId = p2.mediaId;
  784. fileUrl = p2.fileUrl || fileUrl;
  785. break;
  786. }
  787. }
  788. }
  789. if (mediaId) break;
  790. }
  791. return { fileUrl, mediaId, errMsg };
  792. }
  793. async function handleMultiUpload(options: UploadRequestOptions, kind: UploadKind) {
  794. const uploadFileItem = options.file as UploadUserFile;
  795. const raw = uploadFileItem.raw || uploadFileItem;
  796. const file = raw instanceof File ? raw : null;
  797. if (!file) return;
  798. uploadFileItem.status = "uploading";
  799. try {
  800. const fd = new FormData();
  801. fd.append("file", file);
  802. const res: any = await getUpload(fd);
  803. const { fileUrl, mediaId, errMsg } = extractMediaUploadMeta(res);
  804. if (mediaId) {
  805. uploadFileItem.status = "success";
  806. if (fileUrl) uploadFileItem.url = fileUrl;
  807. uploadFileItem.response = { media_id: mediaId, url: fileUrl };
  808. const uid = (uploadFileItem as UploadUserFile & { uid?: number }).uid;
  809. if (uid != null) wechatMediaIdByFileUid.set(uid, mediaId);
  810. (uploadFileItem as UploadUserFile & { __wxMediaId?: string }).__wxMediaId = mediaId;
  811. } else {
  812. uploadFileItem.status = "fail";
  813. ElMessage.error(errMsg || "上传失败,未返回 media_id");
  814. }
  815. } catch {
  816. uploadFileItem.status = "fail";
  817. }
  818. syncUrlsFromFileList(kind);
  819. validateKind(kind);
  820. }
  821. function syncUrlsFromFileList(kind: UploadKind) {
  822. const key = urlListKey(kind);
  823. let list: UploadUserFile[] = [];
  824. if (kind === "storefront") list = form.offlineStorefrontFileList;
  825. else if (kind === "interior") list = form.offlineInteriorFileList;
  826. else if (kind === "miniShot") list = form.miniProgramShotFileList;
  827. else list = form.appShotFileList;
  828. form[key] = list
  829. .map(f => {
  830. const fu = f as UploadUserFile & { url?: string; response?: { url?: string } };
  831. const mid = getMediaIdFromUploadFile(f);
  832. const url = String(fu.url ?? fu.response?.url ?? "").trim();
  833. return mid || url;
  834. })
  835. .filter(Boolean) as string[];
  836. }
  837. function onMultiRemove(file: UploadUserFile, _fileList: UploadUserFile[], kind: UploadKind) {
  838. const uid = (file as UploadUserFile & { uid?: number }).uid;
  839. if (uid != null) wechatMediaIdByFileUid.delete(uid);
  840. delete (file as UploadUserFile & { __wxMediaId?: string }).__wxMediaId;
  841. syncUrlsFromFileList(kind);
  842. validateKind(kind);
  843. }
  844. function syncAllUploadUrlArrays() {
  845. syncUrlsFromFileList("storefront");
  846. syncUrlsFromFileList("interior");
  847. syncUrlsFromFileList("miniShot");
  848. syncUrlsFromFileList("appShot");
  849. }
  850. function assertActiveScenarioPicsHaveMediaId(): boolean {
  851. const groups: { label: string; list: UploadUserFile[] }[] = [];
  852. if (form.scenarios.offline) {
  853. groups.push({ label: "门头照片", list: form.offlineStorefrontFileList });
  854. groups.push({ label: "内部照片", list: form.offlineInteriorFileList });
  855. }
  856. if (form.scenarios.miniProgram) {
  857. groups.push({ label: "小程序截图", list: form.miniProgramShotFileList });
  858. }
  859. if (form.scenarios.app) {
  860. groups.push({ label: "APP 截图", list: form.appShotFileList });
  861. }
  862. for (const g of groups) {
  863. for (const f of g.list) {
  864. if (f.status !== "success") continue;
  865. if (!getMediaIdFromUploadFile(f)) {
  866. ElMessage.warning(`「${g.label}」须使用微信素材上传接口返回的 media_id,请删除后重新上传`);
  867. return false;
  868. }
  869. }
  870. }
  871. return true;
  872. }
  873. function validateKind(kind: UploadKind) {
  874. const map: Partial<Record<UploadKind, string>> = {
  875. storefront: "offlineStorefrontUrls",
  876. interior: "offlineInteriorUrls",
  877. appShot: "appShotUrls"
  878. };
  879. const f = map[kind];
  880. if (f) formRef.value?.validateField(f).catch(() => {});
  881. }
  882. function openScenarioGuide() {
  883. ElMessage.info("经营场景填写指引(可对接文档)");
  884. }
  885. function openStorefrontGuide() {
  886. ElMessage.info("门头照片拍摄指引");
  887. }
  888. function openAppidGuide() {
  889. ElMessage.info("公众号 APPID 查找说明");
  890. }
  891. function openMiniAppidGuide() {
  892. ElMessage.info("小程序 APPID 说明");
  893. }
  894. function openProviderAppGuide() {
  895. ElMessage.info("服务商应用说明");
  896. }
  897. function openMerchantAppGuide() {
  898. ElMessage.info("商家应用说明");
  899. }
  900. function openAppShotExample() {
  901. ElMessage.info("APP 截图示例");
  902. }
  903. function onBack() {
  904. router.push({ path: "/businessInfo/dataEntry", query: { ...route.query } });
  905. }
  906. function onSaveDraft() {
  907. ElMessage.success("草稿已保存(可对接接口)");
  908. }
  909. async function onSubmit() {
  910. if (!formRef.value) return;
  911. submitLoading.value = true;
  912. try {
  913. await formRef.value.validate();
  914. syncAllUploadUrlArrays();
  915. if (!assertActiveScenarioPicsHaveMediaId()) return;
  916. mergeManageInfoIntoBusinessDataCache();
  917. await router.push({
  918. path: "/businessInfo/industryQualifications",
  919. query: { ...route.query }
  920. });
  921. } catch {
  922. ElMessage.warning("请完善必填信息");
  923. } finally {
  924. submitLoading.value = false;
  925. }
  926. }
  927. </script>
  928. <style scoped lang="scss">
  929. $wechat-green: #07c160;
  930. .manage-info-page {
  931. box-sizing: border-box;
  932. min-height: 100%;
  933. padding: 24px 32px 40px;
  934. margin: 0 auto;
  935. background: #ffffff;
  936. }
  937. .page-title {
  938. margin: 0 0 24px;
  939. font-size: 20px;
  940. font-weight: 600;
  941. color: #303133;
  942. }
  943. .manage-form {
  944. :deep(.el-form-item__label) {
  945. font-weight: 500;
  946. color: #606266;
  947. }
  948. }
  949. .form-block {
  950. padding-bottom: 16px;
  951. margin-bottom: 28px;
  952. border-bottom: 1px solid #ebeef5;
  953. &:last-of-type {
  954. border-bottom: none;
  955. }
  956. }
  957. .sub-section {
  958. padding: 8px 16px 20px;
  959. margin-top: 8px;
  960. background: #fafafa;
  961. border: 1px solid #ebeef5;
  962. border-radius: 8px;
  963. }
  964. .section-head {
  965. display: flex;
  966. align-items: center;
  967. margin: 16px 0 20px;
  968. font-size: 16px;
  969. font-weight: 600;
  970. color: #303133;
  971. }
  972. .section-bar {
  973. display: inline-block;
  974. flex-shrink: 0;
  975. width: 4px;
  976. height: 16px;
  977. margin-right: 8px;
  978. background: $wechat-green;
  979. border-radius: 2px;
  980. }
  981. .section-intro {
  982. padding-left: 20px;
  983. font-size: 12px;
  984. color: #999999;
  985. }
  986. .field-stack {
  987. width: 100%;
  988. }
  989. .field-tip {
  990. margin: 8px 0 0;
  991. font-size: 12px;
  992. line-height: 1.65;
  993. color: #909399;
  994. }
  995. .scenario-checks {
  996. display: flex;
  997. flex-wrap: wrap;
  998. gap: 12px 20px;
  999. margin-bottom: 8px;
  1000. }
  1001. .label-with-icon {
  1002. display: inline-flex;
  1003. gap: 4px;
  1004. align-items: center;
  1005. }
  1006. .info-icon {
  1007. color: #909399;
  1008. cursor: help;
  1009. }
  1010. :deep(.el-upload--picture-card) {
  1011. --el-upload-picture-card-size: 120px;
  1012. }
  1013. :deep(.el-upload-list--picture-card .el-upload-list__item) {
  1014. width: 120px;
  1015. height: 120px;
  1016. }
  1017. .footer-actions {
  1018. display: flex;
  1019. align-items: center;
  1020. justify-content: center;
  1021. padding-top: 24px;
  1022. margin-top: 16px;
  1023. border-top: 1px solid #ebeef5;
  1024. }
  1025. .footer-center {
  1026. display: flex;
  1027. flex: 1;
  1028. justify-content: center;
  1029. }
  1030. .btn-next {
  1031. min-width: 100px;
  1032. background: $wechat-green;
  1033. border-color: $wechat-green;
  1034. &:hover {
  1035. background: #06ad56;
  1036. border-color: #06ad56;
  1037. }
  1038. }
  1039. </style>