manageInfo.vue 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125
  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 {
  284. uploadBusinessInfoImageToOss,
  285. filterOutUploadUserFileByUid,
  286. failBusinessInfoUploadCleanup,
  287. collectSuccessUploadUrls
  288. } from "@/utils/businessInfoImageUpload";
  289. import { localGet, localSet } from "@/utils/index";
  290. import cityJson from "@/assets/json/city.json";
  291. const BUSINESS_DATA_CACHE_KEY = "businessData";
  292. const formRef = ref<FormInstance>();
  293. const submitLoading = ref(false);
  294. const route = useRoute();
  295. const router = useRouter();
  296. type UploadKind = "storefront" | "interior" | "miniShot" | "appShot";
  297. const form = reactive({
  298. merchantShortName: "",
  299. servicePhone: "",
  300. scenarios: {
  301. offline: true,
  302. miniProgram: true,
  303. app: true,
  304. wechatAccount: false,
  305. website: false,
  306. wecom: false
  307. },
  308. offlineVenueName: "",
  309. offlineProvince: "",
  310. offlineCity: "",
  311. offlineAddress: "",
  312. offlineStorefrontFileList: [] as UploadUserFile[],
  313. offlineStorefrontUrls: [] as string[],
  314. offlineInteriorFileList: [] as UploadUserFile[],
  315. offlineInteriorUrls: [] as string[],
  316. offlineMpAppId: "",
  317. miniProgramAppId: "",
  318. miniProgramShotFileList: [] as UploadUserFile[],
  319. miniProgramShotUrls: [] as string[],
  320. appIdSource: "provider" as "provider" | "merchant",
  321. providerAppId: "",
  322. merchantAppId: "",
  323. appShotFileList: [] as UploadUserFile[],
  324. appShotUrls: [] as string[],
  325. /** 用于校验至少选一种经营场景 */
  326. scenarioBits: "1"
  327. });
  328. /** 与进件文档 business_info.sales_info.sales_scenes_type 枚举对应 */
  329. function buildSalesScenesType(): string[] {
  330. const s = form.scenarios;
  331. const types: string[] = [];
  332. if (s.offline) types.push("SALES_SCENES_STORE");
  333. if (s.miniProgram) types.push("SALES_SCENES_MINI_PROGRAM");
  334. if (s.app) types.push("SALES_SCENES_APP");
  335. if (s.wechatAccount) types.push("SALES_SCENES_MP");
  336. if (s.website) types.push("SALES_SCENES_WEB");
  337. if (s.wecom) types.push("SALES_SCENES_WEWORK");
  338. return types;
  339. }
  340. /** 上传组件更新 file-list 时可能丢 response,用 uid 备份 media_id */
  341. const wechatMediaIdByFileUid = new Map<number, string>();
  342. function getMediaIdFromUploadFile(f: UploadUserFile): string {
  343. const url = String((f as UploadUserFile & { url?: string }).url ?? "").trim();
  344. const tagged = String((f as UploadUserFile & { __wxMediaId?: string }).__wxMediaId ?? "").trim();
  345. if (tagged) return tagged;
  346. const uid = (f as UploadUserFile & { uid?: number }).uid;
  347. if (uid != null && wechatMediaIdByFileUid.has(uid)) {
  348. return wechatMediaIdByFileUid.get(uid) ?? "";
  349. }
  350. const r = (f as UploadUserFile & { response?: unknown }).response;
  351. if (r && typeof r === "object" && !Array.isArray(r)) {
  352. const o = r as Record<string, unknown>;
  353. const direct = String(o.media_id ?? o.mediaId ?? o.url ?? "").trim();
  354. if (direct) return direct;
  355. const nested = extractMediaUploadMeta(r).mediaId;
  356. if (nested) return nested;
  357. }
  358. return url;
  359. }
  360. /** 写入进件缓存的四处图片字段(OSS 上传后的可访问地址) */
  361. function collectMediaIdsForMerge(list: UploadUserFile[]): string[] {
  362. return list
  363. .filter(f => f.status === "success")
  364. .map(f => getMediaIdFromUploadFile(f))
  365. .filter(Boolean);
  366. }
  367. function mergeManageInfoIntoBusinessDataCache() {
  368. const prev = localGet(BUSINESS_DATA_CACHE_KEY);
  369. const base = prev && typeof prev === "object" && !Array.isArray(prev) ? { ...(prev as Record<string, unknown>) } : {};
  370. const prevBi =
  371. base.business_info && typeof base.business_info === "object" && !Array.isArray(base.business_info)
  372. ? { ...(base.business_info as Record<string, unknown>) }
  373. : {};
  374. const prevSales =
  375. prevBi.sales_info && typeof prevBi.sales_info === "object" && !Array.isArray(prevBi.sales_info)
  376. ? { ...(prevBi.sales_info as Record<string, unknown>) }
  377. : {};
  378. const sales_info: Record<string, unknown> = {
  379. ...prevSales,
  380. sales_scenes_type: buildSalesScenesType()
  381. };
  382. if (form.scenarios.offline) {
  383. sales_info.biz_store_info = {
  384. biz_store_name: String(form.offlineVenueName || "").trim(),
  385. biz_address_code: String(form.offlineCity || "").trim(),
  386. biz_store_address: String(form.offlineAddress || "").trim(),
  387. store_entrance_pic: collectMediaIdsForMerge(form.offlineStorefrontFileList),
  388. indoor_pic: collectMediaIdsForMerge(form.offlineInteriorFileList),
  389. biz_sub_appid: String(form.offlineMpAppId || "").trim()
  390. };
  391. } else {
  392. delete sales_info.biz_store_info;
  393. }
  394. if (form.scenarios.miniProgram) {
  395. sales_info.mini_program_info = {
  396. mini_program_sub_appid: String(form.miniProgramAppId || "").trim(),
  397. mini_program_pics: collectMediaIdsForMerge(form.miniProgramShotFileList)
  398. };
  399. } else {
  400. delete sales_info.mini_program_info;
  401. }
  402. if (form.scenarios.app) {
  403. sales_info.app_info = {
  404. app_appid: String(form.providerAppId || "").trim(),
  405. app_sub_appid: String(form.merchantAppId || "").trim(),
  406. app_pics: collectMediaIdsForMerge(form.appShotFileList)
  407. };
  408. } else {
  409. delete sales_info.app_info;
  410. }
  411. base.business_info = {
  412. ...prevBi,
  413. merchant_shortname: String(form.merchantShortName || "").trim(),
  414. service_phone: String(form.servicePhone || "").trim(),
  415. sales_info
  416. };
  417. localSet(BUSINESS_DATA_CACHE_KEY, base);
  418. }
  419. type CityJsonEntry = { name: string; adCode: string; cityCode: string; type?: string };
  420. type CityJsonRoot = { cityList: { letter: string; list: CityJsonEntry[] }[] };
  421. /** GB 行政区划代码前两位 → 省级名称(与 city.json 中 adCode 对应) */
  422. const PROVINCE_ROWS: [string, string][] = [
  423. ["11", "北京市"],
  424. ["12", "天津市"],
  425. ["13", "河北省"],
  426. ["14", "山西省"],
  427. ["15", "内蒙古自治区"],
  428. ["21", "辽宁省"],
  429. ["22", "吉林省"],
  430. ["23", "黑龙江省"],
  431. ["31", "上海市"],
  432. ["32", "江苏省"],
  433. ["33", "浙江省"],
  434. ["34", "安徽省"],
  435. ["35", "福建省"],
  436. ["36", "江西省"],
  437. ["37", "山东省"],
  438. ["41", "河南省"],
  439. ["42", "湖北省"],
  440. ["43", "湖南省"],
  441. ["44", "广东省"],
  442. ["45", "广西壮族自治区"],
  443. ["46", "海南省"],
  444. ["50", "重庆市"],
  445. ["51", "四川省"],
  446. ["52", "贵州省"],
  447. ["53", "云南省"],
  448. ["54", "西藏自治区"],
  449. ["61", "陕西省"],
  450. ["62", "甘肃省"],
  451. ["63", "青海省"],
  452. ["64", "宁夏回族自治区"],
  453. ["65", "新疆维吾尔自治区"],
  454. ["71", "台湾省"],
  455. ["81", "香港特别行政区"],
  456. ["82", "澳门特别行政区"]
  457. ];
  458. const PROVINCE_BY_PREFIX = Object.fromEntries(PROVINCE_ROWS) as Record<string, string>;
  459. const PROVINCE_PREFIX_ORDER = PROVINCE_ROWS.map(([k]) => k);
  460. function buildProvinceCityOptions(): {
  461. label: string;
  462. value: string;
  463. cities: { label: string; value: string }[];
  464. }[] {
  465. const root = cityJson as CityJsonRoot;
  466. const flat = root.cityList.flatMap(g => g.list);
  467. const bucket = new Map<string, Map<string, CityJsonEntry>>();
  468. for (const c of flat) {
  469. const digits = String(c.adCode ?? "").replace(/\D/g, "");
  470. const code = digits.length >= 6 ? digits.slice(-6) : digits.padStart(6, "0");
  471. if (code.length !== 6) continue;
  472. const prefix = code.slice(0, 2);
  473. if (!PROVINCE_BY_PREFIX[prefix]) continue;
  474. if (!bucket.has(prefix)) bucket.set(prefix, new Map());
  475. bucket.get(prefix)!.set(c.adCode, c);
  476. }
  477. const out = [...bucket.entries()].map(([prefix, cityMap]) => ({
  478. value: prefix,
  479. label: PROVINCE_BY_PREFIX[prefix],
  480. cities: [...cityMap.values()]
  481. .sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))
  482. .map(ci => ({ label: ci.name, value: ci.adCode }))
  483. }));
  484. out.sort((a, b) => {
  485. const ia = PROVINCE_PREFIX_ORDER.indexOf(a.value);
  486. const ib = PROVINCE_PREFIX_ORDER.indexOf(b.value);
  487. return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
  488. });
  489. return out;
  490. }
  491. const provinceOptions = buildProvinceCityOptions();
  492. const cityOptions = ref<{ label: string; value: string }[]>([]);
  493. watch(
  494. () => form.offlineProvince,
  495. code => {
  496. form.offlineCity = "";
  497. const p = provinceOptions.find(x => x.value === code);
  498. cityOptions.value = p?.cities ?? [];
  499. }
  500. );
  501. const providerAppOptions = [
  502. { label: "请选择", value: "" },
  503. { label: "wxf5f1efe3a9f5012e", value: "wxf5f1efe3a9f5012e" }
  504. ];
  505. const scenarioChecked = computed(() => {
  506. const s = form.scenarios;
  507. return s.offline || s.miniProgram || s.app || s.wechatAccount || s.website || s.wecom;
  508. });
  509. watch(
  510. () => form.scenarios,
  511. () => {
  512. form.scenarioBits = scenarioChecked.value ? "1" : "";
  513. },
  514. { deep: true }
  515. );
  516. function asStringArray(v: unknown): string[] {
  517. if (!Array.isArray(v)) return [];
  518. return v.map(x => String(x ?? "").trim()).filter(Boolean);
  519. }
  520. /** 从 localStorage businessData.business_info 回显经营信息表单 */
  521. function hydrateManageFormFromBusinessDataCache() {
  522. const raw = localGet(BUSINESS_DATA_CACHE_KEY) as Record<string, unknown> | null | undefined;
  523. if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
  524. const bi = raw.business_info as Record<string, unknown> | undefined;
  525. if (!bi || typeof bi !== "object" || Array.isArray(bi)) return;
  526. form.merchantShortName = String(bi.merchant_shortname ?? "").trim();
  527. form.servicePhone = String(bi.service_phone ?? "").trim();
  528. let pendingOfflineCity = "";
  529. const si = bi.sales_info as Record<string, unknown> | undefined;
  530. if (si && typeof si === "object" && !Array.isArray(si)) {
  531. const sceneTypes = si.sales_scenes_type;
  532. if (Array.isArray(sceneTypes)) {
  533. const set = new Set(sceneTypes.map(t => String(t)));
  534. form.scenarios.offline = set.has("SALES_SCENES_STORE");
  535. form.scenarios.miniProgram = set.has("SALES_SCENES_MINI_PROGRAM");
  536. form.scenarios.app = set.has("SALES_SCENES_APP");
  537. form.scenarios.wechatAccount = set.has("SALES_SCENES_MP");
  538. form.scenarios.website = set.has("SALES_SCENES_WEB");
  539. form.scenarios.wecom = set.has("SALES_SCENES_WEWORK");
  540. form.scenarioBits = scenarioChecked.value ? "1" : "";
  541. }
  542. const bzs = si.biz_store_info as Record<string, unknown> | undefined;
  543. if (bzs && typeof bzs === "object" && !Array.isArray(bzs)) {
  544. form.offlineVenueName = String(bzs.biz_store_name ?? "").trim();
  545. const digits = String(bzs.biz_address_code ?? "").replace(/\D/g, "");
  546. const code6 = digits.length >= 6 ? digits.slice(-6) : digits;
  547. if (code6.length >= 2) {
  548. const prefix = code6.slice(0, 2);
  549. form.offlineProvince = prefix;
  550. const p = provinceOptions.find(x => x.value === prefix);
  551. cityOptions.value = p?.cities ?? [];
  552. if (code6.length >= 6) pendingOfflineCity = code6;
  553. }
  554. form.offlineAddress = String(bzs.biz_store_address ?? "").trim();
  555. const se = asStringArray(bzs.store_entrance_pic);
  556. const indoor = asStringArray(bzs.indoor_pic);
  557. form.offlineStorefrontUrls = [...se];
  558. form.offlineInteriorUrls = [...indoor];
  559. form.offlineStorefrontFileList = se.map(
  560. (url, i) =>
  561. ({
  562. name: `storefront-${i + 1}.jpg`,
  563. url,
  564. status: "success",
  565. response: { url, media_id: url, mediaId: url },
  566. __wxMediaId: url
  567. }) as UploadUserFile
  568. );
  569. form.offlineInteriorFileList = indoor.map(
  570. (url, i) =>
  571. ({
  572. name: `interior-${i + 1}.jpg`,
  573. url,
  574. status: "success",
  575. response: { url, media_id: url, mediaId: url },
  576. __wxMediaId: url
  577. }) as UploadUserFile
  578. );
  579. form.offlineMpAppId = String(bzs.biz_sub_appid ?? "").trim();
  580. }
  581. const mpi = si.mini_program_info as Record<string, unknown> | undefined;
  582. if (mpi && typeof mpi === "object" && !Array.isArray(mpi)) {
  583. form.miniProgramAppId = String(mpi.mini_program_sub_appid ?? "").trim();
  584. const pics = asStringArray(mpi.mini_program_pics);
  585. form.miniProgramShotUrls = [...pics];
  586. form.miniProgramShotFileList = pics.map(
  587. (url, i) =>
  588. ({
  589. name: `mini-${i + 1}.jpg`,
  590. url,
  591. status: "success",
  592. response: { url, media_id: url, mediaId: url },
  593. __wxMediaId: url
  594. }) as UploadUserFile
  595. );
  596. }
  597. const appInf = si.app_info as Record<string, unknown> | undefined;
  598. if (appInf && typeof appInf === "object" && !Array.isArray(appInf)) {
  599. form.providerAppId = String(appInf.app_appid ?? "").trim();
  600. form.merchantAppId = String(appInf.app_sub_appid ?? "").trim();
  601. form.appIdSource = form.providerAppId ? "provider" : "merchant";
  602. const pics = asStringArray(appInf.app_pics);
  603. form.appShotUrls = [...pics];
  604. form.appShotFileList = pics.map(
  605. (url, i) =>
  606. ({
  607. name: `app-${i + 1}.jpg`,
  608. url,
  609. status: "success",
  610. response: { url, media_id: url, mediaId: url },
  611. __wxMediaId: url
  612. }) as UploadUserFile
  613. );
  614. }
  615. }
  616. nextTick(() => {
  617. if (pendingOfflineCity) form.offlineCity = pendingOfflineCity;
  618. formRef.value?.clearValidate();
  619. });
  620. }
  621. onMounted(() => {
  622. hydrateManageFormFromBusinessDataCache();
  623. });
  624. const rules: FormRules = {
  625. merchantShortName: [{ required: true, message: "请输入商户简称", trigger: "blur" }],
  626. servicePhone: [
  627. { required: true, message: "请输入客服电话", trigger: "blur" },
  628. { pattern: /^[\d\-+\s]{5,20}$/, message: "请输入有效电话号码", trigger: "blur" }
  629. ],
  630. scenarioBits: [{ required: true, message: "请至少选择一种经营场景", trigger: "change" }],
  631. offlineVenueName: [
  632. {
  633. validator: (_r, _v, cb) => {
  634. if (form.scenarios.offline && !String(form.offlineVenueName).trim()) {
  635. cb(new Error("请输入线下场所名称"));
  636. } else cb();
  637. },
  638. trigger: "blur"
  639. }
  640. ],
  641. offlineProvinceCity: [
  642. {
  643. validator: (_r, _v, cb) => {
  644. if (form.scenarios.offline && (!form.offlineProvince || !form.offlineCity)) {
  645. cb(new Error("请选择线下场所省市"));
  646. } else cb();
  647. },
  648. trigger: "change"
  649. }
  650. ],
  651. offlineAddress: [
  652. {
  653. validator: (_r, _v, cb) => {
  654. if (form.scenarios.offline && !String(form.offlineAddress).trim()) {
  655. cb(new Error("请输入线下场所地址"));
  656. } else cb();
  657. },
  658. trigger: "blur"
  659. }
  660. ],
  661. offlineStorefrontUrls: [
  662. {
  663. validator: (_r, _v, cb) => {
  664. if (form.scenarios.offline && form.offlineStorefrontUrls.length < 1) {
  665. cb(new Error("请上传门头照片"));
  666. } else cb();
  667. },
  668. trigger: "change"
  669. }
  670. ],
  671. offlineInteriorUrls: [
  672. {
  673. validator: (_r, _v, cb) => {
  674. if (form.scenarios.offline && form.offlineInteriorUrls.length < 1) {
  675. cb(new Error("请上传内部照片"));
  676. } else cb();
  677. },
  678. trigger: "change"
  679. }
  680. ],
  681. miniProgramAppId: [
  682. {
  683. validator: (_r, _v, cb) => {
  684. if (form.scenarios.miniProgram && !String(form.miniProgramAppId).trim()) {
  685. cb(new Error("请输入小程序 APPID"));
  686. } else cb();
  687. },
  688. trigger: "blur"
  689. }
  690. ],
  691. providerAppId: [
  692. {
  693. validator: (_r, _v, cb) => {
  694. if (form.scenarios.app && form.appIdSource === "provider" && !String(form.providerAppId).trim()) {
  695. cb(new Error("请选择服务商应用 AppID"));
  696. } else cb();
  697. },
  698. trigger: "change"
  699. }
  700. ],
  701. appShotUrls: [
  702. {
  703. validator: (_r, _v, cb) => {
  704. if (form.scenarios.app && form.appShotUrls.length < 1) {
  705. cb(new Error("请上传 APP 截图"));
  706. } else cb();
  707. },
  708. trigger: "change"
  709. }
  710. ]
  711. };
  712. const MAX_IMG_MB = 20;
  713. function beforeImageUpload(file: File) {
  714. const name = file.name?.toLowerCase() || "";
  715. const okExt = /\.(jpe?g|png|bmp)$/i.test(name);
  716. const okMime = ["image/jpeg", "image/png", "image/bmp"].includes(file.type);
  717. if (!okExt && !okMime) {
  718. ElMessage.error("图片只支持 JPG、BMP、PNG 格式");
  719. return false;
  720. }
  721. if (file.size / 1024 / 1024 > MAX_IMG_MB) {
  722. ElMessage.error(`文件大小不能超过 ${MAX_IMG_MB}M`);
  723. return false;
  724. }
  725. return true;
  726. }
  727. function urlListKey(kind: UploadKind): "offlineStorefrontUrls" | "offlineInteriorUrls" | "miniProgramShotUrls" | "appShotUrls" {
  728. const map = {
  729. storefront: "offlineStorefrontUrls",
  730. interior: "offlineInteriorUrls",
  731. miniShot: "miniProgramShotUrls",
  732. appShot: "appShotUrls"
  733. } as const;
  734. return map[kind];
  735. }
  736. /** 解析历史微信 media/upload 缓存(兼容旧数据) */
  737. function extractMediaUploadMeta(res: any): { fileUrl: string; mediaId: string; errMsg: string } {
  738. const errMsg = String(res?.msg ?? res?.message ?? res?.data?.msg ?? res?.data?.message ?? "").trim();
  739. const pickFromObject = (node: any): { fileUrl: string; mediaId: string } => {
  740. if (!node || typeof node !== "object" || Array.isArray(node)) return { fileUrl: "", mediaId: "" };
  741. const mediaId = String(node.media_id ?? node.mediaId ?? node.MediaId ?? "").trim();
  742. const fileUrl = String(node.url ?? node.fileUrl ?? node.mediaUrl ?? node.picUrl ?? "").trim();
  743. return { fileUrl, mediaId };
  744. };
  745. let root = res;
  746. const rawData = root?.data;
  747. if (rawData != null && typeof rawData === "string") {
  748. const s = rawData.trim();
  749. if (s.startsWith("{") || s.startsWith("[")) {
  750. try {
  751. root = { ...(root && typeof root === "object" ? root : {}), data: JSON.parse(s) };
  752. } catch {
  753. return { fileUrl: "", mediaId: s, errMsg };
  754. }
  755. } else if (s) {
  756. return { fileUrl: "", mediaId: s, errMsg };
  757. }
  758. }
  759. const candidates: any[] = [];
  760. const push = (x: any) => {
  761. if (x != null) candidates.push(x);
  762. };
  763. push(root);
  764. push(root?.data);
  765. if (root?.data != null && typeof root.data === "object" && !Array.isArray(root.data)) {
  766. push(root.data.data);
  767. for (const v of Object.values(root.data)) {
  768. if (v && typeof v === "object" && !Array.isArray(v)) push(v);
  769. }
  770. }
  771. let fileUrl = "";
  772. let mediaId = "";
  773. for (const c of candidates) {
  774. if (typeof c === "string" && c.trim()) {
  775. mediaId = c.trim();
  776. break;
  777. }
  778. if (!c || typeof c !== "object") continue;
  779. const p = pickFromObject(c);
  780. if (p.mediaId) {
  781. mediaId = p.mediaId;
  782. fileUrl = p.fileUrl || fileUrl;
  783. break;
  784. }
  785. for (const v of Object.values(c)) {
  786. if (v && typeof v === "object" && !Array.isArray(v)) {
  787. const p2 = pickFromObject(v);
  788. if (p2.mediaId) {
  789. mediaId = p2.mediaId;
  790. fileUrl = p2.fileUrl || fileUrl;
  791. break;
  792. }
  793. }
  794. }
  795. if (mediaId) break;
  796. }
  797. return { fileUrl, mediaId, errMsg };
  798. }
  799. function fileListForKind(kind: UploadKind): UploadUserFile[] {
  800. if (kind === "storefront") return form.offlineStorefrontFileList;
  801. if (kind === "interior") return form.offlineInteriorFileList;
  802. if (kind === "miniShot") return form.miniProgramShotFileList;
  803. return form.appShotFileList;
  804. }
  805. function setFileListForKind(kind: UploadKind, list: UploadUserFile[]) {
  806. if (kind === "storefront") form.offlineStorefrontFileList = list;
  807. else if (kind === "interior") form.offlineInteriorFileList = list;
  808. else if (kind === "miniShot") form.miniProgramShotFileList = list;
  809. else form.appShotFileList = list;
  810. }
  811. async function handleMultiUpload(options: UploadRequestOptions, kind: UploadKind) {
  812. const uploadFileItem = options.file as UploadUserFile;
  813. const uid = uploadFileItem.uid;
  814. const raw = uploadFileItem.raw || uploadFileItem;
  815. const file = raw instanceof File ? raw : null;
  816. if (!file) {
  817. setFileListForKind(kind, filterOutUploadUserFileByUid(fileListForKind(kind), uid));
  818. options.onError(new Error("无效文件") as any);
  819. return;
  820. }
  821. uploadFileItem.status = "uploading";
  822. try {
  823. const { fileUrl, mediaId } = await uploadBusinessInfoImageToOss(file, { showUploadOverlay: false });
  824. uploadFileItem.status = "success";
  825. uploadFileItem.url = fileUrl;
  826. uploadFileItem.response = { media_id: mediaId, url: fileUrl };
  827. const uidNum = (uploadFileItem as UploadUserFile & { uid?: number }).uid;
  828. if (uidNum != null) wechatMediaIdByFileUid.set(uidNum, fileUrl);
  829. (uploadFileItem as UploadUserFile & { __wxMediaId?: string }).__wxMediaId = fileUrl;
  830. options.onSuccess({ url: fileUrl } as any);
  831. } catch (err) {
  832. failBusinessInfoUploadCleanup({
  833. fileList: fileListForKind(kind),
  834. setFileList: list => setFileListForKind(kind, list),
  835. uid,
  836. error: err
  837. });
  838. options.onError(err as any);
  839. }
  840. syncUrlsFromFileList(kind);
  841. validateKind(kind);
  842. }
  843. function syncUrlsFromFileList(kind: UploadKind) {
  844. const key = urlListKey(kind);
  845. form[key] = collectSuccessUploadUrls(fileListForKind(kind));
  846. }
  847. function onMultiRemove(file: UploadUserFile, _fileList: UploadUserFile[], kind: UploadKind) {
  848. const uid = (file as UploadUserFile & { uid?: number }).uid;
  849. if (uid != null) wechatMediaIdByFileUid.delete(uid);
  850. delete (file as UploadUserFile & { __wxMediaId?: string }).__wxMediaId;
  851. syncUrlsFromFileList(kind);
  852. validateKind(kind);
  853. }
  854. function syncAllUploadUrlArrays() {
  855. syncUrlsFromFileList("storefront");
  856. syncUrlsFromFileList("interior");
  857. syncUrlsFromFileList("miniShot");
  858. syncUrlsFromFileList("appShot");
  859. }
  860. function assertActiveScenarioPicsHaveMediaId(): boolean {
  861. const groups: { label: string; list: UploadUserFile[] }[] = [];
  862. if (form.scenarios.offline) {
  863. groups.push({ label: "门头照片", list: form.offlineStorefrontFileList });
  864. groups.push({ label: "内部照片", list: form.offlineInteriorFileList });
  865. }
  866. if (form.scenarios.miniProgram) {
  867. groups.push({ label: "小程序截图", list: form.miniProgramShotFileList });
  868. }
  869. if (form.scenarios.app) {
  870. groups.push({ label: "APP 截图", list: form.appShotFileList });
  871. }
  872. for (const g of groups) {
  873. for (const f of g.list) {
  874. if (f.status !== "success") continue;
  875. if (!getMediaIdFromUploadFile(f)) {
  876. ElMessage.warning(`「${g.label}」请完成图片上传`);
  877. return false;
  878. }
  879. }
  880. }
  881. return true;
  882. }
  883. function validateKind(kind: UploadKind) {
  884. const map: Partial<Record<UploadKind, string>> = {
  885. storefront: "offlineStorefrontUrls",
  886. interior: "offlineInteriorUrls",
  887. appShot: "appShotUrls"
  888. };
  889. const f = map[kind];
  890. if (f) formRef.value?.validateField(f).catch(() => {});
  891. }
  892. function openScenarioGuide() {
  893. ElMessage.info("经营场景填写指引(可对接文档)");
  894. }
  895. function openStorefrontGuide() {
  896. ElMessage.info("门头照片拍摄指引");
  897. }
  898. function openAppidGuide() {
  899. ElMessage.info("公众号 APPID 查找说明");
  900. }
  901. function openMiniAppidGuide() {
  902. ElMessage.info("小程序 APPID 说明");
  903. }
  904. function openProviderAppGuide() {
  905. ElMessage.info("服务商应用说明");
  906. }
  907. function openMerchantAppGuide() {
  908. ElMessage.info("商家应用说明");
  909. }
  910. function openAppShotExample() {
  911. ElMessage.info("APP 截图示例");
  912. }
  913. function onBack() {
  914. router.push({ path: "/businessInfo/dataEntry", query: { ...route.query } });
  915. }
  916. function onSaveDraft() {
  917. ElMessage.success("草稿已保存(可对接接口)");
  918. }
  919. async function onSubmit() {
  920. if (!formRef.value) return;
  921. submitLoading.value = true;
  922. try {
  923. await formRef.value.validate();
  924. syncAllUploadUrlArrays();
  925. if (!assertActiveScenarioPicsHaveMediaId()) return;
  926. mergeManageInfoIntoBusinessDataCache();
  927. await router.push({
  928. path: "/businessInfo/industryQualifications",
  929. query: { ...route.query }
  930. });
  931. } catch {
  932. ElMessage.warning("请完善必填信息");
  933. } finally {
  934. submitLoading.value = false;
  935. }
  936. }
  937. </script>
  938. <style scoped lang="scss">
  939. $wechat-green: #07c160;
  940. .manage-info-page {
  941. box-sizing: border-box;
  942. min-height: 100%;
  943. padding: 24px 32px 40px;
  944. margin: 0 auto;
  945. background: #ffffff;
  946. }
  947. .page-title {
  948. margin: 0 0 24px;
  949. font-size: 20px;
  950. font-weight: 600;
  951. color: #303133;
  952. }
  953. .manage-form {
  954. :deep(.el-form-item__label) {
  955. font-weight: 500;
  956. color: #606266;
  957. }
  958. }
  959. .form-block {
  960. padding-bottom: 16px;
  961. margin-bottom: 28px;
  962. border-bottom: 1px solid #ebeef5;
  963. &:last-of-type {
  964. border-bottom: none;
  965. }
  966. }
  967. .sub-section {
  968. padding: 8px 16px 20px;
  969. margin-top: 8px;
  970. background: #fafafa;
  971. border: 1px solid #ebeef5;
  972. border-radius: 8px;
  973. }
  974. .section-head {
  975. display: flex;
  976. align-items: center;
  977. margin: 16px 0 20px;
  978. font-size: 16px;
  979. font-weight: 600;
  980. color: #303133;
  981. }
  982. .section-bar {
  983. display: inline-block;
  984. flex-shrink: 0;
  985. width: 4px;
  986. height: 16px;
  987. margin-right: 8px;
  988. background: $wechat-green;
  989. border-radius: 2px;
  990. }
  991. .section-intro {
  992. padding-left: 20px;
  993. font-size: 12px;
  994. color: #999999;
  995. }
  996. .field-stack {
  997. width: 100%;
  998. }
  999. .field-tip {
  1000. margin: 8px 0 0;
  1001. font-size: 12px;
  1002. line-height: 1.65;
  1003. color: #909399;
  1004. }
  1005. .scenario-checks {
  1006. display: flex;
  1007. flex-wrap: wrap;
  1008. gap: 12px 20px;
  1009. margin-bottom: 8px;
  1010. }
  1011. .label-with-icon {
  1012. display: inline-flex;
  1013. gap: 4px;
  1014. align-items: center;
  1015. }
  1016. .info-icon {
  1017. color: #909399;
  1018. cursor: help;
  1019. }
  1020. :deep(.el-upload--picture-card) {
  1021. --el-upload-picture-card-size: 120px;
  1022. }
  1023. :deep(.el-upload-list--picture-card .el-upload-list__item) {
  1024. width: 120px;
  1025. height: 120px;
  1026. }
  1027. .footer-actions {
  1028. display: flex;
  1029. align-items: center;
  1030. justify-content: center;
  1031. padding-top: 24px;
  1032. margin-top: 16px;
  1033. border-top: 1px solid #ebeef5;
  1034. }
  1035. .footer-center {
  1036. display: flex;
  1037. flex: 1;
  1038. justify-content: center;
  1039. }
  1040. .btn-next {
  1041. min-width: 100px;
  1042. background: $wechat-green;
  1043. border-color: $wechat-green;
  1044. &:hover {
  1045. background: #06ad56;
  1046. border-color: #06ad56;
  1047. }
  1048. }
  1049. </style>