lakalaOnboarding.vue 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. <template>
  2. <view class="page" v-if="loaded">
  3. <view v-if="mode !== 'settlement-change'" class="steps">
  4. <view v-for="(name, index) in stepNames" :key="name" class="step" :class="{ active: currentStep >= index + 1 }" @click="goStep(index + 1)">
  5. <view class="circle">{{ index + 1 }}</view>
  6. <text>{{ name }}</text>
  7. </view>
  8. </view>
  9. <view v-if="currentStep === 1 && mode !== 'settlement-change'">
  10. <SectionTitle title="商家基本情况" />
  11. <view class="box choice-row">
  12. <text class="label required" style="width: 280upx;">商户类别</text>
  13. <radio-group @change="setValue('license.merchantType', $event.detail.value)">
  14. <label><radio value="TP_MERCHANT" :checked="form.license.merchantType === 'TP_MERCHANT'" />企业</label>
  15. <label><radio value="TP_PERSONAL" :checked="form.license.merchantType === 'TP_PERSONAL'" />小微</label>
  16. </radio-group>
  17. </view>
  18. <AttachmentCard v-if="form.license.merchantType === 'TP_MERCHANT'" title="营业执照" imgType="BUSINESS_LICENCE" :items="form.license.attachments" @upload="uploadAttachment('license', 'BUSINESS_LICENCE', true)" />
  19. <view class="box">
  20. <FormInput v-if="form.license.merchantType === 'TP_MERCHANT'" label="营业执照名称" v-model="form.license.licenseName" required />
  21. <FormInput v-if="form.license.merchantType === 'TP_MERCHANT'" label="证件代码" v-model="form.license.licenseNo" required />
  22. <DateInput v-if="form.license.merchantType === 'TP_MERCHANT'" label="执照起始日期" v-model="form.license.licenseStart" />
  23. <DateInput v-if="form.license.merchantType === 'TP_MERCHANT'" label="执照有效期" v-model="form.license.licenseEnd" />
  24. <FormInput v-if="form.license.merchantType === 'TP_MERCHANT'" label="注册地址" v-model="form.license.registeredAddress" />
  25. </view>
  26. <AttachmentCard title="身份证人像面" imgType="ID_CARD_FRONT" :items="form.license.attachments" @upload="uploadAttachment('license', 'ID_CARD_FRONT', true)" />
  27. <AttachmentCard title="身份证国徽面" imgType="ID_CARD_BEHIND" :items="form.license.attachments" @upload="uploadAttachment('license', 'ID_CARD_BEHIND', true)" />
  28. <view class="box">
  29. <!-- <FormInput label="经营范围" v-model="form.license.businessContent" required /> -->
  30. <view v-if="form.license.merchantType === 'TP_PERSONAL'">
  31. <FormInput label="姓名" v-model="form.license.legalName" required />
  32. </view>
  33. <view v-if="form.license.merchantType === 'TP_MERCHANT'">
  34. <FormInput label="法人姓名" v-model="form.license.legalName" required />
  35. </view>
  36. <FormInput label="身份证号" v-model="form.license.legalIdNo" required />
  37. <DateInput label="身份证起始日期" v-model="form.license.legalIdStart" />
  38. <DateInput label="身份证有效期" v-model="form.license.legalIdEnd" />
  39. </view>
  40. </view>
  41. <view v-if="currentStep === 2 && mode !== 'settlement-change'">
  42. <SectionTitle title="商户信息" />
  43. <view class="box">
  44. <FormInput label="商户经营名称" v-model="form.merchant.businessName" required />
  45. <view class="form-row">
  46. <text class="form-label required">商户所在地区</text>
  47. <button class="mini-btn" @click="chooseRegion('merchant', 'region')">{{ merchantRegionText }}</button>
  48. </view>
  49. <FormInput label="详细地址" v-model="form.merchant.address" required />
  50. </view>
  51. <view class="tip">商户类型固定为花店(5992);联系人、手机号和邮箱按系统默认值提交。</view>
  52. </view>
  53. <view v-if="currentStep === 3">
  54. <SectionTitle title="结算信息" />
  55. <view class="box choice-row">
  56. <text class="label required" style="width: 290upx;">账户类型</text>
  57. <radio-group @change="setValue('settlement.accountType', $event.detail.value)">
  58. <label><radio value="58" :checked="form.settlement.accountType === '58'" />对私</label>
  59. <label><radio value="57" :checked="form.settlement.accountType === '57'" />对公</label>
  60. </radio-group>
  61. </view>
  62. <view v-if="form.settlement.accountType === '58'" class="box switch-row">
  63. <text>是否法人进件</text>
  64. <switch :checked="form.settlement.isLegalPerson" @change="setValue('settlement.isLegalPerson', $event.detail.value)" color="#288bf5" />
  65. </view>
  66. <AttachmentCard v-if="form.settlement.accountType === '58'" title="银行卡照片" imgType="BANK_CARD" :items="form.settlement.attachments" @upload="uploadAttachment('settlement', 'BANK_CARD', true)" />
  67. <template v-if="form.settlement.accountType === '58' && !form.settlement.isLegalPerson">
  68. <AttachmentCard title="结算人身份证人像面" imgType="ID_CARD_FRONT" :items="form.settlement.attachments" @upload="uploadAttachment('settlement', 'ID_CARD_FRONT', true)" />
  69. <AttachmentCard title="结算人身份证国徽面" imgType="ID_CARD_BEHIND" :items="form.settlement.attachments" @upload="uploadAttachment('settlement', 'ID_CARD_BEHIND', true)" />
  70. <AttachmentCard title="法人授权函" imgType="LETTER_OF_AUTHORIZATION" :items="form.settlement.attachments" @upload="uploadAttachment('settlement', 'LETTER_OF_AUTHORIZATION', false)" />
  71. </template>
  72. <AttachmentCard v-if="form.settlement.accountType === '57'" title="开户许可证" imgType="OPENING_PERMIT" :items="form.settlement.attachments" @upload="uploadAttachment('settlement', 'OPENING_PERMIT', false)" />
  73. <view class="box">
  74. <FormInput label="开户名" v-model="form.settlement.accountName" required />
  75. <FormInput label="银行卡号" v-model="form.settlement.accountNo" required />
  76. <FormInput label="开户银行" v-model="form.settlement.bankName" required />
  77. <view class="form-row">
  78. <text class="form-label required">开户地区</text>
  79. <button class="mini-btn" @click="chooseRegion('settlement', 'bank-region')">{{ settlementRegionText }}</button>
  80. </view>
  81. <view class="form-row">
  82. <text class="form-label required">开户支行</text>
  83. <button class="mini-btn" @click="chooseBank">{{ settlementBranchText }}</button>
  84. </view>
  85. <FormInput v-if="!form.settlement.isLegalPerson" label="结算人身份证号" v-model="form.settlement.accountIdNo" />
  86. <DateInput v-if="!form.settlement.isLegalPerson" label="结算人证件起始日" v-model="form.settlement.accountIdStart" />
  87. <DateInput v-if="!form.settlement.isLegalPerson" label="结算人证件有效期" v-model="form.settlement.accountIdEnd" />
  88. </view>
  89. </view>
  90. <view v-if="currentStep === 4 && mode !== 'settlement-change'">
  91. <SectionTitle title="门店信息" />
  92. <AttachmentCard title="门头照" imgType="SHOP_OUTSIDE_IMG" :items="form.shop.attachments" @upload="uploadAttachment('shop', 'SHOP_OUTSIDE_IMG', false)" />
  93. <AttachmentCard title="内设照" imgType="SHOP_INSIDE_IMG" :items="form.shop.attachments" @upload="uploadAttachment('shop', 'SHOP_INSIDE_IMG', false)" />
  94. <AttachmentCard title="收银台照" imgType="CHECKSTAND_IMG" :items="form.shop.attachments" @upload="uploadAttachment('shop', 'CHECKSTAND_IMG', false)" />
  95. <view class="box">
  96. <FormInput label="门店名称" v-model="form.shop.name" required />
  97. <FormInput label="详细地址" v-model="form.shop.address" required />
  98. <FormInput label="门店联系人" v-model="form.shop.contactName" required />
  99. <FormInput label="联系手机号" v-model="form.shop.contactMobile" required />
  100. <!-- <FormInput label="经度" v-model="form.shop.longitude" required />
  101. <FormInput label="纬度" v-model="form.shop.latitude" required /> -->
  102. </view>
  103. </view>
  104. <view v-if="currentStep === 5 && mode !== 'settlement-change'">
  105. <SectionTitle title="业务信息" />
  106. <view class="box summary">
  107. <view><text>结算类型</text><text>自动结算</text></view>
  108. <view><text>结算周期</text><text>D1</text></view>
  109. <view><text>商户类型</text><text>花店(5992)</text></view>
  110. <view><text>批发费率</text><text>{{ form.business.rate }}%</text></view>
  111. </view>
  112. <view class="box">
  113. <FormInput label="电子合同签约手机号" v-model="form.business.signMobile" required />
  114. </view>
  115. <view class="contract">
  116. <button class="outline" @click="createContract">生成并签署电子合同</button>
  117. <button class="outline" @click="checkContract">刷新签约状态</button>
  118. <text :class="{ signed: contractStatus === 'COMPLETED' }">签约状态:{{ contractStatusText }}</text>
  119. </view>
  120. </view>
  121. <view class="bottom-space"></view>
  122. <view class="bottom">
  123. <button v-if="currentStep > 1 && mode !== 'settlement-change'" class="back" @click="previous">上一步</button>
  124. <button v-if="currentStep < 5 && mode !== 'settlement-change'" class="next" @click="next">下一步</button>
  125. <button v-else class="next" @click="submit">{{ mode === 'settlement-change' ? '提交申请' : '提交审核' }}</button>
  126. </view>
  127. <!-- 省市区/支行等超长列表选择,小程序 showActionSheet 最多 6 项不够用 -->
  128. <OptionPicker
  129. :visible="optionPicker.visible"
  130. :title="optionPicker.title"
  131. :options="optionPicker.options"
  132. @select="onOptionPickerSelect"
  133. @cancel="onOptionPickerCancel"
  134. />
  135. </view>
  136. </template>
  137. <script>
  138. import {
  139. applicationDetail, saveDraft, uploadFile, ocrResult, aliyunOcr, applyContract,
  140. contractStatus, submitApplication, getOption
  141. } from "@/api/lakala-account";
  142. import SectionTitle from "./lakala-components/SectionTitle.vue";
  143. import FormInput from "./lakala-components/FormInput.vue";
  144. import DateInput from "./lakala-components/DateInput.vue";
  145. import AttachmentCard from "./lakala-components/AttachmentCard.vue";
  146. import OptionPicker from "./lakala-components/OptionPicker.vue";
  147. export default {
  148. components: { SectionTitle, FormInput, DateInput, AttachmentCard, OptionPicker },
  149. data() {
  150. return {
  151. id: 0, mode: 'create', loaded: false, currentStep: 1, form: null,
  152. stepNames: ['执照信息', '商户信息', '结算信息', '门店信息', '业务信息'],
  153. contractStatus: '', saveTimer: null, saving: false,
  154. // 通用选项弹层状态,resolve 用于 chooseOption / chooseBank 的 Promise
  155. optionPicker: { visible: false, title: '', options: [], resolve: null }
  156. };
  157. },
  158. computed: {
  159. contractStatusText() {
  160. return { COMPLETED: '已完成', UNDONE: '待签署' }[this.contractStatus] || '未生成';
  161. },
  162. merchantRegionText() {
  163. const m = this.form && this.form.merchant;
  164. return m && m.countyCode ? `${m.provinceName || ''}${m.cityName || ''}${m.countyName || ''}` : '请选择地区';
  165. },
  166. settlementRegionText() {
  167. const s = this.form && this.form.settlement;
  168. return s && s.cityCode ? `${s.provinceName || ''}${s.cityName || ''}` : '请选择开户地区';
  169. },
  170. /** 已选支行展示名称,未选时提示查询 */
  171. settlementBranchText() {
  172. const s = this.form && this.form.settlement;
  173. if (!s || !s.bankNo) return '查询并选择支行';
  174. return s.bankName || s.bankNo;
  175. }
  176. },
  177. watch: {
  178. form: {
  179. deep: true,
  180. handler() {
  181. if (!this.loaded || this.saving) return;
  182. clearTimeout(this.saveTimer);
  183. this.saveTimer = setTimeout(() => this.persist(), 800);
  184. }
  185. }
  186. },
  187. onLoad(option) {
  188. this.id = Number(option.id || 0);
  189. this.mode = option.mode || 'create';
  190. this.load();
  191. },
  192. onShow() {
  193. if (this.loaded && this.contractStatus === 'UNDONE') this.checkContract(false);
  194. },
  195. onUnload() {
  196. clearTimeout(this.saveTimer);
  197. this.persist();
  198. },
  199. methods: {
  200. init() {
  201. },
  202. async load() {
  203. const res = await applicationDetail({ id: this.id });
  204. this.form = res.data.formData;
  205. this.applyDefaultMerchantType();
  206. this.normalizeFormAttachments();
  207. this.applyDefaultExpiryDates();
  208. this.currentStep = this.mode === 'settlement-change' ? 3 : Number(res.data.currentStep || 1);
  209. this.contractStatus = res.data.contractStatus || '';
  210. this.loaded = true;
  211. },
  212. /** 新建进件默认按企业商户处理;已有草稿或详情值不覆盖 */
  213. applyDefaultMerchantType() {
  214. if (!this.form.license.merchantType) {
  215. this.$set(this.form.license, 'merchantType', 'TP_MERCHANT');
  216. }
  217. },
  218. /** 返回当天日期 yyyy-MM-dd,供 date picker 默认值使用 */
  219. getTodayDateStr() {
  220. const d = new Date();
  221. const pad = n => (n < 10 ? '0' : '') + n;
  222. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
  223. },
  224. /** 执照/身份证有效期未填时默认当天,减少用户重复选择 */
  225. applyDefaultExpiryDates() {
  226. const today = this.getTodayDateStr();
  227. if (!this.form.license.legalIdEnd || this.form.license.legalIdEnd == '9999-12-31') {
  228. this.$set(this.form.license, 'legalIdEnd', today);
  229. }
  230. if (!this.form.license.licenseEnd || this.form.license.licenseEnd == '9999-12-31') {
  231. this.$set(this.form.license, 'licenseEnd', today);
  232. }
  233. },
  234. async persist() {
  235. if (!this.form || this.saving) return;
  236. this.saving = true;
  237. try {
  238. await saveDraft({ id: this.id, currentStep: this.currentStep, formData: JSON.stringify(this.form) });
  239. } catch (e) {
  240. // Submitted applications are intentionally no longer editable.
  241. } finally {
  242. this.saving = false;
  243. }
  244. },
  245. setValue(path, value) {
  246. const parts = path.split('.');
  247. this.$set(this.form[parts[0]], parts[1], value);
  248. },
  249. async chooseRegion(section, type) {
  250. const province = await this.chooseOption(type, '1', '选择省份');
  251. if (!province) return;
  252. const city = await this.chooseOption(type, province.code, '选择城市');
  253. if (!city) return;
  254. this.$set(this.form[section], 'provinceCode', province.code);
  255. this.$set(this.form[section], 'provinceName', province.name);
  256. this.$set(this.form[section], 'cityCode', city.code);
  257. this.$set(this.form[section], 'cityName', city.name);
  258. if (section === 'merchant') {
  259. const county = await this.chooseOption(type, city.code, '选择区县');
  260. if (county) {
  261. this.$set(this.form.merchant, 'countyCode', county.code);
  262. this.$set(this.form.merchant, 'countyName', county.name);
  263. }
  264. }
  265. },
  266. async chooseOption(type, parentCode, title) {
  267. const res = await getOption({ type, parentCode });
  268. const list = this.normalizeOptions(res.data);
  269. if (!list.length) {
  270. uni.showToast({ title: '没有可选数据', icon: 'none' });
  271. return null;
  272. }
  273. return this.openOptionPicker(title, list);
  274. },
  275. /** 打开可滚动选项弹层,返回选中项或 null */
  276. openOptionPicker(title, options) {
  277. return new Promise(resolve => {
  278. this.optionPicker = { visible: true, title, options, resolve };
  279. });
  280. },
  281. onOptionPickerSelect(item) {
  282. const { resolve } = this.optionPicker;
  283. this.optionPicker.visible = false;
  284. this.optionPicker.resolve = null;
  285. if (resolve) resolve(item);
  286. },
  287. onOptionPickerCancel() {
  288. const { resolve } = this.optionPicker;
  289. this.optionPicker.visible = false;
  290. this.optionPicker.resolve = null;
  291. if (resolve) resolve(null);
  292. },
  293. /** OCR/接口日期统一为 yyyy-MM-dd,供 date picker 使用 */
  294. normalizeDateStr(value) {
  295. if (!value) return value;
  296. const str = String(value).trim();
  297. if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
  298. const cn = str.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日$/);
  299. if (cn) {
  300. const pad = n => (n < 10 ? '0' : '') + Number(n);
  301. return `${cn[1]}-${pad(cn[2])}-${pad(cn[3])}`;
  302. }
  303. const dot = str.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})$/);
  304. if (dot) {
  305. const pad = n => (n < 10 ? '0' : '') + Number(n);
  306. return `${dot[1]}-${pad(dot[2])}-${pad(dot[3])}`;
  307. }
  308. return str;
  309. },
  310. /** 统一省市区、支行等选项结构;兼容 camelCase 与 snake_case 字段 */
  311. normalizeOptions(data) {
  312. const source = Array.isArray(data) ? data : ((data && data.data) || []);
  313. return source.map(v => ({
  314. code: String(
  315. v.code
  316. || v.branch_bank_no
  317. || v.area_code || v.bank_no || v.id || ''
  318. ),
  319. name: v.name || v.area_name
  320. || v.branch_bank_name
  321. || v.bank_name || '',
  322. raw: v
  323. })).filter(v => v.code && v.name);
  324. },
  325. async chooseBank() {
  326. if (!this.form.settlement.cityCode) { //|| !this.form.settlement.bankName
  327. uni.showToast({ title: '请先选择开户地区', icon: 'none' }); //并填写银行名称
  328. return;
  329. }
  330. const res = await getOption({ type: 'bank', parentCode: this.form.settlement.cityCode, keyword: this.form.settlement.bankName });
  331. const list = this.normalizeOptions(res.data);
  332. if (!list.length) {
  333. uni.showToast({ title: '没有查询到支行', icon: 'none' });
  334. return;
  335. }
  336. const selected = await this.openOptionPicker('选择开户支行', list);
  337. if (!selected) return;
  338. const row = selected.raw || selected;
  339. this.$set(this.form.settlement, 'bankName', row.branch_bank_name);
  340. // bankNo 取支行联行号,不能用 bank_no(仅为银行类别码,如 102)
  341. this.$set(this.form.settlement, 'bankNo', String(row.branch_bank_no || ''));
  342. this.$set(this.form.settlement, 'clearingBankNo', String(row.clear_no || ''));
  343. },
  344. goStep(step) {
  345. if (step < this.currentStep) {
  346. this.currentStep = step;
  347. this.persist();
  348. }
  349. },
  350. previous() { this.currentStep--; this.persist(); },
  351. next() {
  352. if (!this.validateStep(this.currentStep)) return;
  353. if (this.currentStep === 3) {
  354. this.form.shop.name = this.form.merchant.businessName;
  355. this.form.shop.address = this.form.merchant.address;
  356. this.form.shop.provinceCode = this.form.merchant.provinceCode;
  357. this.form.shop.cityCode = this.form.merchant.cityCode;
  358. this.form.shop.countyCode = this.form.merchant.countyCode;
  359. this.form.shop.provinceName = this.form.merchant.provinceName;
  360. this.form.shop.cityName = this.form.merchant.cityName;
  361. this.form.shop.countyName = this.form.merchant.countyName;
  362. }
  363. this.currentStep++;
  364. this.persist();
  365. },
  366. validateStep(step) {
  367. const required = {
  368. 1: ['license.legalName', 'license.legalIdNo', 'license.legalIdStart', 'license.legalIdEnd'],
  369. 2: ['merchant.businessName', 'merchant.provinceCode', 'merchant.cityCode', 'merchant.countyCode', 'merchant.address'],
  370. 3: ['settlement.accountName', 'settlement.accountNo', 'settlement.bankName', 'settlement.bankNo', 'settlement.clearingBankNo', 'settlement.provinceCode', 'settlement.cityCode'],
  371. 4: ['shop.name', 'shop.address', 'shop.contactName', 'shop.contactMobile', 'shop.longitude', 'shop.latitude'],
  372. 5: ['business.signMobile']
  373. }[step] || [];
  374. // 经营范围仅企业商户必填(小微无营业执照,无此字段)
  375. if (step === 1 && this.form.license.merchantType === 'TP_MERCHANT') {
  376. required.push('license.businessContent');
  377. }
  378. const missing = required.some(path => {
  379. const [section, field] = path.split('.');
  380. return !this.form[section][field];
  381. });
  382. if (missing) uni.showToast({ title: '请填写完整必填信息', icon: 'none' });
  383. return !missing;
  384. },
  385. async uploadAttachment(section, imgType, isOcr) {
  386. const path = await this.chooseImage();
  387. if (!path) return;
  388. uni.showLoading({ title: '上传识别中', mask: true });
  389. try {
  390. const content = await this.toBase64(path);
  391. const useLakalaOcr = isOcr && imgType === 'BUSINESS_LICENCE';
  392. const res = await uploadFile({ id: this.id, content, imgType, isOcr: useLakalaOcr ? 1 : 0 });
  393. const list = this.form[section].attachments || [];
  394. const attachment = this.normalizeAttachment(res.data);
  395. const oldIndex = list.findIndex(v => v.imgType === imgType);
  396. if (oldIndex >= 0) list.splice(oldIndex, 1, attachment); else list.push(attachment);
  397. this.$set(this.form[section], 'attachments', list);
  398. if (isOcr && (attachment.batchNo || attachment.ossPath || attachment.lakalaUrl || attachment.url)) {
  399. await this.pollOcr(section, attachment, 0);
  400. }
  401. await this.persist();
  402. } finally {
  403. uni.hideLoading();
  404. }
  405. },
  406. normalizeAttachment(attachment) {
  407. return Object.assign({}, attachment, {
  408. lakalaUrl: attachment.lakalaUrl || attachment.url || '',
  409. batchNo: attachment.batchNo || '',
  410. imgType: attachment.imgType || '',
  411. ocrStatus: attachment.ocrStatus || attachment.status || '',
  412. ocrResult: attachment.ocrResult || attachment.ocr || null
  413. });
  414. },
  415. normalizeFormAttachments() {
  416. ['license', 'settlement', 'shop'].forEach(section => {
  417. const list = (this.form[section] && this.form[section].attachments) || [];
  418. this.$set(this.form[section], 'attachments', list.map(item => this.normalizeAttachment(item)));
  419. });
  420. },
  421. chooseImage() {
  422. return new Promise(resolve => {
  423. uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: res => resolve(res.tempFilePaths[0]), fail: () => resolve('') });
  424. });
  425. },
  426. toBase64(path) {
  427. // #ifdef MP-WEIXIN
  428. return new Promise((resolve, reject) => uni.getFileSystemManager().readFile({ filePath: path, encoding: 'base64', success: res => resolve('data:image/png;base64,' + res.data), fail: reject }));
  429. // #endif
  430. // #ifdef H5
  431. return fetch(path).then(r => r.blob()).then(blob => new Promise(resolve => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(blob); }));
  432. // #endif
  433. // #ifdef APP-PLUS
  434. return new Promise((resolve, reject) => { plus.io.resolveLocalFileSystemURL(path, entry => { entry.file(file => { const reader = new plus.io.FileReader(); reader.onloadend = e => resolve(e.target.result); reader.onerror = reject; reader.readAsDataURL(file); }); }, reject); });
  435. // #endif
  436. },
  437. async pollOcr(section, attachment, count) {
  438. if (count > 8) return;
  439. const type = attachment.imgType;
  440. if (type === 'BUSINESS_LICENCE') {
  441. const res = await ocrResult({ id: this.id, batchNo: attachment.batchNo, imgType: attachment.imgType });
  442. this.updateAttachment(section, attachment.imgType, {
  443. ocrStatus: res.data.status || '',
  444. ocrResult: res.data.result || null
  445. });
  446. if (res.data.status === '01') {
  447. await new Promise(resolve => setTimeout(resolve, 1200));
  448. return this.pollOcr(section, attachment, count + 1);
  449. }
  450. if (res.data.status === '00' && res.data.result) this.applyOcr(section, attachment.imgType, res.data.result);
  451. } else {
  452. const imageUrl = attachment.ossPath || attachment.lakalaUrl || attachment.url || '';
  453. if (!imageUrl) return;
  454. const res = await aliyunOcr({ id: this.id, imgType: attachment.imgType, imageUrl });
  455. this.updateAttachment(section, attachment.imgType, {
  456. ocrStatus: res.data.status || '',
  457. ocrResult: res.data.result || null
  458. });
  459. if (res.data.status === '00' && res.data.result) this.applyOcr(section, attachment.imgType, res.data.result);
  460. }
  461. },
  462. updateAttachment(section, imgType, patch) {
  463. const list = this.form[section].attachments || [];
  464. const index = list.findIndex(v => v.imgType === imgType);
  465. if (index < 0) return;
  466. this.$set(list, index, Object.assign({}, list[index], patch));
  467. this.$set(this.form[section], 'attachments', list);
  468. },
  469. applyOcr(section, type, result) {
  470. if (type === 'BUSINESS_LICENCE') {
  471. Object.assign(this.form.license, {
  472. licenseName: result.biz_license_address[1] || '',
  473. licenseNo: result.biz_license_credit_code || result.biz_license_registration_code || this.form.license.licenseNo,
  474. registeredAddress: result.biz_license_address || this.form.license.registeredAddress,
  475. businessContent: result.biz_license_scope || this.form.license.businessContent,
  476. legalName: result.biz_license_owner_name || this.form.license.legalName,
  477. licenseStart: this.normalizeDateStr(result.biz_license_start_time) || this.form.license.licenseStart
  478. });
  479. if (!this.form.merchant.address) this.$set(this.form.merchant, 'address', result.biz_license_address || '');
  480. } else if (type === 'ID_CARD_FRONT') {
  481. const target = section === 'license' ? this.form.license : this.form.settlement;
  482. this.$set(target, section === 'license' ? 'legalName' : 'accountName', result.name || target.accountName || '');
  483. this.$set(target, section === 'license' ? 'legalIdNo' : 'accountIdNo', result.id_number || '');
  484. } else if (type === 'ID_CARD_BEHIND') {
  485. const validity = String(result.validity || '').split('-');
  486. const target = section === 'license' ? this.form.license : this.form.settlement;
  487. if (validity.length >= 2) {
  488. this.$set(target, section === 'license' ? 'legalIdStart' : 'accountIdStart', this.normalizeDateStr(validity[0]));
  489. this.$set(target, section === 'license' ? 'legalIdEnd' : 'accountIdEnd', this.normalizeDateStr(validity.slice(1).join('-')));
  490. }
  491. } else if (type === 'BANK_CARD') {
  492. this.$set(this.form.settlement, 'accountNo', result.card_number || result.bank_card_number || this.form.settlement.accountNo);
  493. this.$set(this.form.settlement, 'bankName', result.bank_name || this.form.settlement.bankName);
  494. }
  495. },
  496. async createContract() {
  497. if (!this.validateStep(5)) return;
  498. await this.persist();
  499. const res = await applyContract({ id: this.id });
  500. this.contractStatus = res.data.status;
  501. uni.navigateTo({ url: `/common/webview?url=${encodeURIComponent(res.data.resultUrl)}` });
  502. },
  503. async checkContract(show = true) {
  504. if (!this.contractStatus) {
  505. if (show) uni.showToast({ title: '请先生成电子合同', icon: 'none' });
  506. return;
  507. }
  508. const res = await contractStatus({ id: this.id });
  509. this.contractStatus = res.data.status;
  510. if (show) uni.showToast({ title: this.contractStatusText, icon: 'none' });
  511. },
  512. async submit() {
  513. if (!this.validateStep(this.currentStep)) return;
  514. await this.persist();
  515. const res = await submitApplication({ id: this.id, formData: JSON.stringify(this.form) });
  516. uni.showToast({ title: this.mode === 'settlement-change' ? '申请已提交' : '已提交后台审核', icon: 'none' });
  517. setTimeout(() => uni.navigateBack(), 1000);
  518. }
  519. }
  520. };
  521. </script>
  522. <style lang="scss" scoped>
  523. .page { min-height: 100vh; background: #f5f7fa; padding-bottom: 30upx; font-size: 28upx; }
  524. .steps { display: flex; background: #fff; padding: 30upx 12upx; border-radius: 0 0 28upx 28upx; }
  525. .step { flex: 1; text-align: center; color: #999; font-size: 27.5upx; position: relative; }
  526. .step:not(:last-child):before { content: ''; position: absolute; height: 4upx; background: #d8e8fa; top: 25upx; left: 60%; right: -40%; }
  527. .step.active:not(:last-child):before { background: #288bf5; }
  528. .circle { width: 52upx; height: 52upx; line-height: 52upx; border-radius: 50%; background: #d8e8fa; color: #fff; margin: 0 auto 12upx; position: relative; z-index: 1; }
  529. .active { color: #222; }.active .circle { background: #288bf5; }
  530. .box { margin: 0 24upx 24upx; padding: 24upx; background: #fff; border-radius: 18upx; }
  531. .choice-row, .switch-row { display: flex; justify-content: space-between; align-items: center; }
  532. .choice-row label { margin-left: 26upx; font-size: 28upx; }
  533. .label, .form-label { color: #34445c; }
  534. .required:before { content: '*'; color: #f33; margin-right: 6upx; }
  535. .form-row { min-height: 88upx; display: flex; align-items: center; border-bottom: 1upx solid #eee; }
  536. .form-label { width: 270upx; flex-shrink: 0; }
  537. .mini-btn { flex: 1; margin: 0; padding: 0 12upx; background: #fff; color: #288bf5; font-size: 32.5upx; line-height: 64upx; text-align: right; }
  538. .tip { margin: 20upx 30upx; color: #999; font-size: 35upx; line-height: 52.5upx; }
  539. .summary view { display: flex; justify-content: space-between; font-size: 35upx; line-height: 87.5upx; border-bottom: 1upx solid #eee; }
  540. .contract { margin: 24upx; display: flex; flex-direction: column; gap: 18upx; text-align: center; color: #999; font-size: 35upx; }
  541. .signed { color: #079b52; }
  542. .bottom-space { height: 150upx; }
  543. .bottom { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 18upx; padding: 20upx 28upx calc(20upx + env(safe-area-inset-bottom)); background: #fff; }
  544. .bottom button { flex: 1; border-radius: 50upx; font-size: 35upx; }
  545. .next { color: #fff; background: #288bf5; }.back, .outline { color: #288bf5; background: #fff; border: 1upx solid #288bf5; }
  546. </style>