diff --git a/docs/a5-integration.md b/docs/a5-integration.md index 10c6bf3..05380d0 100644 --- a/docs/a5-integration.md +++ b/docs/a5-integration.md @@ -15,7 +15,7 @@ - Development: `TARO_APP_API_A5=http://ag-test.batiao8.com` - Production: `TARO_APP_API_A5=http://ag.batiao8.com` -HTTP is configured as requested. Before a WeChat release, provision HTTPS with a valid certificate and update the environment value. Configure request/upload/download legal domains, including returned file CDN domains. HTTP is not a release-ready WeChat endpoint. +HTTP is configured as requested for regular requests. File uploads always use HTTPS on the same A5 host because WeChat uploadFile requires it. Before a WeChat release, verify the HTTPS gateway and certificate and update the environment value for all requests. Configure request/upload/download legal domains, including returned file CDN domains. HTTP is not a release-ready WeChat endpoint. ## API Contract @@ -31,6 +31,10 @@ HTTP is configured as requested. Before a WeChat release, provision HTTPS with a The H5 development proxy removes its `/api` prefix. Mini-program business requests therefore use `/h5/...` directly, without corp signing, token bootstrapping or presigned Qiniu upload. +## Image Upload + +WeChat uses `chooseMedia` with `mediaType: ['image']`; other targets retain `chooseImage`. Picking/uploading a photo does not require the page agreement checkbox. Platform privacy authorization still applies, and submitting the completed form still requires agreement. Cancelling the picker does not display an error. Native upload failures retain actionable error information; failed uploads do not offer the unrelated document reload action. + ## Verification - `pnpm test` diff --git a/src/lib/a5/api.test.ts b/src/lib/a5/api.test.ts index 90535d9..e164d8e 100644 --- a/src/lib/a5/api.test.ts +++ b/src/lib/a5/api.test.ts @@ -37,11 +37,34 @@ describe('A5 API isolation', () => { await expect(uploadA5Image('wxfile://image', '42', '7', 'order_biz2', { loc: '左面' })).resolves.toEqual({ id: '8', url: 'https://cdn/a.png' }) const options = mocks.uploadFile.mock.calls[0][0] const url = new URL(options.url) + expect(url.protocol).toBe('https:') + expect(url.hostname).toBe('ag-test.batiao8.com') expect(url.pathname).toBe('/h5/file') expect(JSON.parse(url.searchParams.get('extra')!)).toEqual({ loc: '左面' }) expect(options.name).toBe('file') expect(mocks.request).not.toHaveBeenCalled() }) + it('retains native upload error details', async () => { + mocks.uploadFile.mockImplementation((options) => { + options.fail({ errMsg: 'uploadFile:fail url not in domain list' }) + return { onProgressUpdate: vi.fn() } + }) + await expect(uploadA5Image('wxfile://image', '42', '7')).rejects.toThrow('上传域名未配置') + }) + it('reports non-JSON gateway upload failures with the HTTP status', async () => { + mocks.uploadFile.mockImplementation((options) => { + options.success({ statusCode: 502, data: 'Bad Gateway' }) + return { onProgressUpdate: vi.fn() } + }) + await expect(uploadA5Image('wxfile://image', '42', '7')).rejects.toThrow('图片上传服务异常 (502)') + }) + it('does not mark an unsuccessful business upload as complete', async () => { + mocks.uploadFile.mockImplementation((options) => { + options.success({ statusCode: 200, data: JSON.stringify({ code: 1, message: '链接不存在' }) }) + return { onProgressUpdate: vi.fn() } + }) + await expect(uploadA5Image('wxfile://image', '42', '7')).rejects.toThrow('链接不存在') + }) it.each(['404 Not Found', { code: 404, message: 'Not Found' }])('treats feedback HTTP 404 as UI-only success (%j)', async (data) => { mocks.request.mockResolvedValue({ statusCode: 404, data }) await expect(a5Request('/api/user/feedback', { method: 'POST', data: { order_id: '7' } })) diff --git a/src/lib/a5/api.ts b/src/lib/a5/api.ts index d8537f7..3a94f40 100644 --- a/src/lib/a5/api.ts +++ b/src/lib/a5/api.ts @@ -1,5 +1,6 @@ import Taro from '@tarojs/taro' import { adaptA5Files, adaptA5Order, items, record, text, unwrapA5Response } from './data' +import { imageErrorMessage } from './image' import type { OrderLike } from '../order' export function a5Url (path: string, params: Record = {}) { @@ -42,13 +43,17 @@ export function deleteA5File (id: string) { export async function uploadA5Image (filePath: string, linkId: string, orderId: string, scene = 'process', extra?: Record, onProgress?: (percent: number) => void) { const params: Record = fileParams(linkId, scene, orderId) if (extra) params.extra = JSON.stringify(extra) - const url = a5Url('/h5/file', params) + // WeChat uploads require HTTPS even when request() is allowed to use HTTP in DevTools. + const url = a5Url('/h5/file', params).replace(/^http:\/\//i, 'https://') onProgress?.(0) return new Promise<{ id: string, url: string }>((resolve, reject) => { const task = Taro.uploadFile({ url, filePath, name: 'file', timeout: 60000, success (response) { try { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error(`图片上传服务异常 (${response.statusCode}),请稍后重试`) + } const body = unwrapA5Response(response.statusCode, response.data) const id = text(body.data?.id) const imageUrl = text(body.data?.cdn_url || body.data?.url) @@ -57,7 +62,9 @@ export async function uploadA5Image (filePath: string, linkId: string, orderId: resolve({ id, url: imageUrl }) } catch (error) { reject(error) } }, - fail: reject, + fail (error) { + reject(new Error(imageErrorMessage(error))) + }, }) task.onProgressUpdate?.((event) => onProgress?.(event.progress)) }) diff --git a/src/lib/a5/image.test.ts b/src/lib/a5/image.test.ts new file mode 100644 index 0000000..fca4de5 --- /dev/null +++ b/src/lib/a5/image.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ getEnv: vi.fn(), chooseMedia: vi.fn(), chooseImage: vi.fn(), authorize: vi.fn() })) +vi.mock('@tarojs/taro', () => ({ default: { ...mocks, ENV_TYPE: { WEAPP: 'WEAPP', WEB: 'WEB' } } })) +vi.mock('../consent', () => ({ requestPlatformPrivacyAuthorization: mocks.authorize })) +import { chooseA5Image, imageErrorMessage } from './image' + +describe('A5 image selection', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.getEnv.mockReturnValue('WEAPP') + mocks.authorize.mockResolvedValue(true) + }) + it('uses the current WeChat image picker with only platform authorization', async () => { + mocks.chooseMedia.mockResolvedValue({ tempFiles: [{ tempFilePath: 'wxfile://photo.jpg' }] }) + await expect(chooseA5Image()).resolves.toBe('wxfile://photo.jpg') + expect(mocks.authorize).toHaveBeenCalledOnce() + expect(mocks.chooseMedia).toHaveBeenCalledWith({ count: 1, mediaType: ['image'], sizeType: ['compressed'], sourceType: ['album', 'camera'] }) + expect(mocks.chooseImage).not.toHaveBeenCalled() + }) + it('does not select files after system privacy authorization is declined', async () => { + mocks.authorize.mockResolvedValue(false) + await expect(chooseA5Image()).resolves.toBe('') + expect(mocks.chooseMedia).not.toHaveBeenCalled() + }) + it('treats cancelling the picker as a normal exit', async () => { + mocks.chooseMedia.mockRejectedValue({ errMsg: 'chooseMedia:fail cancel' }) + await expect(chooseA5Image()).resolves.toBe('') + }) + it('preserves native picker error details instead of reporting an upload failure', async () => { + mocks.chooseMedia.mockRejectedValue({ errMsg: 'chooseMedia:fail permission denied' }) + await expect(chooseA5Image()).rejects.toThrow('chooseMedia:fail permission denied') + }) + it('explains a missing WeChat privacy declaration', async () => { + mocks.chooseMedia.mockRejectedValue({ errMsg: 'chooseMedia:fail api scope is not declared in the privacy agreement' }) + await expect(chooseA5Image()).rejects.toThrow('小程序尚未配置照片隐私声明,请联系客服处理') + expect(mocks.chooseImage).not.toHaveBeenCalled() + }) + it('uses chooseImage for non-WeChat targets', async () => { + mocks.getEnv.mockReturnValue('WEB') + mocks.chooseImage.mockResolvedValue({ tempFilePaths: ['blob:photo'] }) + await expect(chooseA5Image()).resolves.toBe('blob:photo') + expect(mocks.chooseMedia).not.toHaveBeenCalled() + expect(mocks.authorize).not.toHaveBeenCalled() + }) + it('explains common upload configuration failures', () => { + expect(imageErrorMessage({ errMsg: 'uploadFile:fail url not in domain list' })).toBe('上传域名未配置,请联系客服处理') + expect(imageErrorMessage({ errMsg: 'uploadFile:fail ssl certificate error' })).toBe('图片上传安全连接失败,请联系客服处理') + expect(imageErrorMessage({ errMsg: 'uploadFile:fail timeout' })).toBe('图片上传超时,请重试') + }) +}) diff --git a/src/lib/a5/image.ts b/src/lib/a5/image.ts new file mode 100644 index 0000000..1a8b149 --- /dev/null +++ b/src/lib/a5/image.ts @@ -0,0 +1,34 @@ +import Taro from '@tarojs/taro' +import { requestPlatformPrivacyAuthorization } from '../consent' + +export function imageErrorMessage (error: unknown, fallback = '图片上传失败,请稍后重试') { + const reason = error as { message?: string, errMsg?: string } | null + const message = String(reason?.message || reason?.errMsg || '').trim() + if (/cancel/i.test(message)) return '' + if (/api scope is not declared in the privacy agreement/i.test(message)) return '小程序尚未配置照片隐私声明,请联系客服处理' + if (/url not in domain list|domain.*not.*configured/i.test(message)) return '上传域名未配置,请联系客服处理' + if (/ssl|certificate|https/i.test(message)) return '图片上传安全连接失败,请联系客服处理' + if (/timeout/i.test(message)) return '图片上传超时,请重试' + return message || fallback +} + +export async function chooseA5Image (): Promise { + try { + if (Taro.getEnv() === Taro.ENV_TYPE.WEAPP) { + if (!await requestPlatformPrivacyAuthorization()) return '' + const result = await Taro.chooseMedia({ + count: 1, + mediaType: ['image'], + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + }) + return result.tempFiles?.[0]?.tempFilePath || '' + } + const result = await Taro.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] }) + return result.tempFilePaths?.[0] || '' + } catch (error) { + const message = imageErrorMessage(error, '选择图片失败,请重试') + if (!message) return '' + throw new Error(message) + } +} diff --git a/src/pages/a5/resubmit/documents.tsx b/src/pages/a5/resubmit/documents.tsx index 342a997..fab33d9 100644 --- a/src/pages/a5/resubmit/documents.tsx +++ b/src/pages/a5/resubmit/documents.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import Taro from '@tarojs/taro' import { LOCAL_IMAGES } from '@/lib/assets' import { MAP_ASSET_BASE } from '@/lib/cdn' +import { FieldLabel } from '@/pages/resubmit/fields' import type { StorefrontSlot } from '@/lib/order' import identityFront from '@/assets/images/example-identity-front.png' import identityBack from '@/assets/images/example-identity-back.png' @@ -24,7 +25,7 @@ export default function Documents ({ slots, busy, progress, disabled, onUpload, return <> {DOCUMENTS.map((document, groupIndex) => - {document.title}{document.required ? * : null} + setExample(groupIndex)}>查看示例 ⓘ @@ -38,7 +39,7 @@ export default function Documents ({ slots, busy, progress, disabled, onUpload, {source ? Taro.previewImage({ current: source, urls: [source] })} /> : 已上传} : } {busy === index ? {Math.round(progress)}% : null} @@ -58,7 +59,7 @@ export default function Documents ({ slots, busy, progress, disabled, onUpload, 拍摄要求 {requirements.map((item) => {item})} - + : null} diff --git a/src/pages/a5/resubmit/index.config.ts b/src/pages/a5/resubmit/index.config.ts index 9029d96..e6b679f 100644 --- a/src/pages/a5/resubmit/index.config.ts +++ b/src/pages/a5/resubmit/index.config.ts @@ -1 +1,6 @@ -export default definePageConfig({ navigationBarTitleText: '补充资料' }) +export default definePageConfig({ + navigationBarTitleText: '重新提交', + navigationBarBackgroundColor: '#ffffff', + navigationBarTextStyle: 'black', + backgroundColor: '#F7F8FA', +}) diff --git a/src/pages/a5/resubmit/index.scss b/src/pages/a5/resubmit/index.scss index f911af0..2e3c11a 100644 --- a/src/pages/a5/resubmit/index.scss +++ b/src/pages/a5/resubmit/index.scss @@ -1,41 +1,33 @@ -.a5-form { - padding: 32px 28px calc(40px + env(safe-area-inset-bottom)); - background: #fff; - color: #222; - min-height: 100vh; - box-sizing: border-box; - font-size: 28px; - &__field { margin-bottom: 28px; } - &__label { display: block; margin-bottom: 16px; } - &__required { margin-left: 4px; color: #f53f3f; } - &__input, &__location { border: 1px solid #ddd; border-radius: 6px; height: 96px; padding: 0 24px; box-sizing: border-box; } - &__location { display: flex; align-items: center; justify-content: space-between; gap: 12px; } - &__location text:first-child { min-width: 0; overflow-wrap: anywhere; } - &__tip { display: block; font-size: 24px; color: #666; line-height: 1.6; } - &__error { padding: 20px 0; color: #c43838; overflow-wrap: anywhere; } - &__submit { background: #0261fc; color: #fff; font-size: 30px; border-radius: 6px; margin-top: 28px; } +.resubmit-page--a5 { + .resubmit-card { width: 100%; } + .resubmit__control-value, .resubmit__error { overflow-wrap: anywhere; } } .a5-doc { - margin: 32px 0; - &__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; } - &__example { font-size: 24px; color: #0261fc; } + margin-bottom: 28px; + &:last-child { margin-bottom: 0; } + &__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } + &__header .resubmit__label { margin-bottom: 0; } + &__example { flex-shrink: 0; font-size: 24px; color: #165dff; } + &__loading { display: block; margin-bottom: 16px; font-size: 24px; color: #86909c; } &__grid { display: flex; gap: 20px; } &__item { flex: 1; min-width: 0; max-width: calc((100% - 40px) / 3); text-align: center; } - &__media { width: 100%; aspect-ratio: 1; background: #f4f6f8; border: 1px solid #ddd; border-radius: 6px; position: relative; display: flex; align-items: center; justify-content: center; } + &__media { width: 100%; aspect-ratio: 1; box-sizing: border-box; background: #fff; border: 2px solid #e5e6eb; border-radius: 16px; overflow: hidden; position: relative; display: flex; align-items: center; justify-content: center; } &__image { width: 100%; height: 100%; } - &__upload { width: 100%; height: 100%; padding: 12px 0; display: flex; flex-direction: column; justify-content: center; align-items: center; background: transparent; font-size: 24px; line-height: 1.5; color: #666; } + &__upload { width: 100%; height: 100%; margin: 0; padding: 12px 0; display: flex; flex-direction: column; justify-content: center; align-items: center; background: transparent; font-size: 22px; line-height: 1.5; color: #c9cdd4; } + &__upload[disabled] { background: transparent; color: #c9cdd4; } &__upload::after, &__remove::after { border: 0; } - &__camera { width: 48px; height: 48px; margin-bottom: 12px; } - &__remove { position: absolute; top: 0; right: 0; width: 44px; height: 44px; padding: 0; line-height: 40px; border-radius: 0; background: #444; color: #fff; font-size: 32px; } - &__caption { display: block; font-size: 24px; margin-top: 12px; } - &__progress { position: absolute; bottom: 0; left: 0; right: 0; background: #0261fc; color: white; text-align: center; font-size: 22px; } + &__camera { width: 56px; height: 56px; margin-bottom: 8px; } + &__remove { position: absolute; top: 0; right: 0; width: 48px; height: 48px; padding: 0; line-height: 44px; border-radius: 0 16px 0 16px; background: rgba(29, 33, 41, .55); color: #fff; font-size: 32px; } + &__caption { display: block; font-size: 24px; color: #1d2129; margin-top: 12px; } + &__progress { position: absolute; bottom: 0; left: 0; right: 0; background: #165dff; color: white; text-align: center; font-size: 22px; } } .a5-example { position: fixed; inset: 0; z-index: 1200; background: rgba(0, 0, 0, .65); display: flex; align-items: center; justify-content: center; padding: 28px; box-sizing: border-box; - &__dialog { width: 100%; max-height: 85vh; overflow-y: auto; padding: 28px; background: white; border-radius: 8px; box-sizing: border-box; } + &__dialog { width: 100%; max-height: 85vh; overflow-y: auto; padding: 28px; background: white; border-radius: 16px; box-sizing: border-box; } &__title { display: block; text-align: center; font-size: 32px; font-weight: 600; margin: 8px 0 28px; } &__image { width: 100%; height: 180px; } - &__subtitle { display: block; font-weight: 600; margin: 28px 0 16px; } + &__subtitle { display: block; font-size: 28px; font-weight: 600; margin: 28px 0 16px; } &__requirements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; font-size: 24px; color: #666; } .a5-doc__item { max-width: none; } + .resubmit__submit { margin-top: 28px; } } diff --git a/src/pages/a5/resubmit/index.tsx b/src/pages/a5/resubmit/index.tsx index 7a5b862..5a7525a 100644 --- a/src/pages/a5/resubmit/index.tsx +++ b/src/pages/a5/resubmit/index.tsx @@ -1,4 +1,4 @@ -import { Button, Input, Text, View } from '@tarojs/components' +import { Button, Text, View } from '@tarojs/components' import Taro, { useDidShow, useLoad } from '@tarojs/taro' import { useRef, useState } from 'react' import { a5Request, deleteA5File, getA5Draft, getA5Files, uploadA5Image, type A5Draft } from '@/lib/a5/api' @@ -7,7 +7,10 @@ import { formatMapCoordinate, getInitialPhone2, getOrderAddressName, getOrderExt import { isValidPhone } from '@/lib/phone' import { consumeMapPickerResult } from '@/pages/map/picker' import PrivacyConsent from '@/components/privacy-consent' +import { chooseA5Image, imageErrorMessage } from '@/lib/a5/image' import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent' +import { MapResubmitFields, ResubmitNotes } from '@/pages/resubmit/fields' +import '@/pages/resubmit/index.scss' import Documents from './documents' import './index.scss' @@ -21,6 +24,7 @@ export default function A5Resubmit () { const [progress, setProgress] = useState(0) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') + const [loadError, setLoadError] = useState('') const [agreed, setAgreed] = useState(hasPrivacyConsent) const lock = useRef(false) const returningMap = useRef(false) @@ -28,7 +32,7 @@ export default function A5Resubmit () { async function loadFiles (current: A5Draft) { setLoading(true) setFilesReady(false) - setError('') + setLoadError('') try { const [storefront, license, identity] = await Promise.all(['order_biz2', 'order_biz1', 'order_biz3'].map((scene) => getA5Files(current.linkId, String(current.order.order_id), scene))) setSlots((previous) => [ @@ -38,7 +42,7 @@ export default function A5Resubmit () { ]) setFilesReady(true) } catch (reason) { - setError(reason instanceof Error ? reason.message : '图片加载失败') + setLoadError(imageErrorMessage(reason, '图片加载失败')) } finally { setLoading(false) } } useLoad(() => { @@ -69,9 +73,8 @@ export default function A5Resubmit () { if (!draft || disabled || !filesReady || lock.current) return lock.current = true try { - if (!agreed) { Taro.showToast({ title: '请先阅读并同意协议', icon: 'none' }); return } - const chosen = await Taro.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] }) - const path = chosen.tempFilePaths[0] + setError('') + const path = await chooseA5Image() if (!path) return setBusy(index) setProgress(0) @@ -79,7 +82,7 @@ export default function A5Resubmit () { const uploaded = await uploadA5Image(path, draft.linkId, String(draft.order.order_id), index < 3 ? 'order_biz2' : index === 3 ? 'order_biz1' : 'order_biz3', index < 3 ? { loc: STOREFRONT_LOCS[index] } : undefined, setProgress) setSlots((current) => current.map((slot, position) => position === index ? { ...uploaded, preview: path } : slot)) } catch (reason) { - if (!String((reason as any)?.errMsg || '').includes('cancel')) setError(reason instanceof Error ? reason.message : '图片上传失败') + setError(imageErrorMessage(reason)) } finally { setBusy(null); lock.current = false } } async function remove (index: number) { @@ -118,23 +121,21 @@ export default function A5Resubmit () { returningMap.current = true void Taro.navigateTo({ url: `/pages/map/index?name=${encodeURIComponent(values.entity_address_name || '')}${point ? `&lng=${point.lng}&lat=${point.lat}` : ''}` }) } - return - {draft?.order.reject_reason ? 驳回理由:{draft.order.reject_reason} : null} - {([ - ['entity_name', '门店名称', '请输入店铺/公司名称'], - ['entity_address_name', '门店地址', '请输入实际经营地址'], - ['entity_address', '门店位置', '请选择门店位置'], - ['entity_phone', '门店电话', '请输入门店电话'], - ['entity_phone2', '联系人电话', '请输入联系人电话'], - ] as const).map(([field, label, placeholder]) => - {label}* - {field === 'entity_address' ? {values[field] || placeholder} : update(field, field.includes('phone') ? event.detail.value.replace(/\D/g, '') : event.detail.value)} />} - )} - 温馨提示:请确保您的电话畅通,工作人员会在1-7个工作日联系您 - {loading ? 图片加载中... : null} - void upload(index)} onRemove={(index) => void remove(index)} /> - {error ? {error}{draft ? !disabled && void loadFiles(draft)}> 重新加载资料 : null} : null} - { setAgreed(value); savePrivacyConsent(value) }} /> - + return + {draft?.order.reject_reason ? 驳回理由:{draft.order.reject_reason} : null} + + + + {loading ? 图片加载中... : null} + void upload(index)} onRemove={(index) => void remove(index)} /> + + + {loadError ? {loadError}{draft ? !disabled && void loadFiles(draft)}> 重新加载资料 : null} : null} + {error ? {error} : null} + + + { setAgreed(value); savePrivacyConsent(value) }} /> + + } diff --git a/src/pages/agreement/index.tsx b/src/pages/agreement/index.tsx index 4e3b0fa..183709c 100644 --- a/src/pages/agreement/index.tsx +++ b/src/pages/agreement/index.tsx @@ -81,8 +81,8 @@ const PRIVACY_POLICY: Agreement = { { title: '一、我们收集的信息及目的', paragraphs: [ - '1. 联系电话:由您在查询框或表单中主动输入,或由业务跳转链接携带。我们使用联系电话匹配和展示关联订单、核验申请、联系办理人、反馈处理进度及提供客户服务。未经您勾选同意,我们不会以该号码发起订单查询或提交表单。', - '2. 门店及业务资料:由您主动填写或上传,包括门店名称、地址、地图坐标、门店电话、办理人电话、门头照片和营业执照图片。我们仅用于审核申请资料、确认门店真实性、完成地图标注、生成相关业务展示页面及与您联系。', + '1. 联系电话:由您在查询框或表单中主动输入,或由业务跳转链接携带。我们使用联系电话匹配和展示关联订单、核验申请、联系联系人、反馈处理进度及提供客户服务。未经您勾选同意,我们不会以该号码发起订单查询或提交表单。', + '2. 门店及业务资料:由您主动填写或上传,包括门店名称、地址、地图坐标、门店电话、联系人电话、门头照片和营业执照图片。我们仅用于审核申请资料、确认门店真实性、完成地图标注、生成相关业务展示页面及与您联系。', '3. 企业申报资料:由您主动填写、查询并选择,包括企业名称、统一社会信用代码、法定代表人姓名、法定代表人身份证号和手机号。身份证号属于敏感个人信息,仅在核验申报主体身份和办理相关业务确有必要时使用。处理敏感个人信息可能对个人权益产生较大影响,请在确认必要后再提交。', '4. 位置信息:当您使用定位、搜索地点或地图选点功能时,经您授权后通过微信定位能力获取当前位置或由您主动选择地点,并可能将坐标发送至地图服务进行地址解析。该信息仅用于帮助您确定门店位置和填写门店地址。您可以拒绝定位并手动在地图上选点,但部分便捷定位功能将无法使用。', '5. 投诉与反馈信息:由您主动提交,包括投诉类型、问题描述、图片以及选填的手机号、微信号或邮箱。我们用于受理、核实、联系和处理投诉。', diff --git a/src/pages/resubmit/fields.test.ts b/src/pages/resubmit/fields.test.ts new file mode 100644 index 0000000..cda7f49 --- /dev/null +++ b/src/pages/resubmit/fields.test.ts @@ -0,0 +1,51 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callbacks = vi.hoisted(() => ({ inputs: [] as any[], images: [] as any[], views: [] as any[] })) +vi.mock('@tarojs/components', () => ({ + View: (props: any) => { callbacks.views.push(props); return createElement('div', { className: props.className }, props.children) }, + Text: (props: any) => createElement('span', { className: props.className }, props.children), + Image: (props: any) => { callbacks.images.push(props); return createElement('img', { className: props.className, src: props.src }) }, + Input: (props: any) => { callbacks.inputs.push(props); return createElement('input', { className: props.className, defaultValue: props.value, placeholder: props.placeholder }) }, +})) +import { MapResubmitFields, ResubmitNotes } from './fields' + +describe('shared resubmit fields', () => { + beforeEach(() => { callbacks.inputs.length = 0; callbacks.images.length = 0; callbacks.views.length = 0 }) + it('keeps the original field labels, icons, hints and required markers', () => { + const html = renderToStaticMarkup(createElement(MapResubmitFields, { values: {}, onChange: vi.fn(), onChooseMap: vi.fn() })) + for (const label of ['门店名称', '门店地址', '门店位置', '门店电话', '联系人电话']) expect(html).toContain(label) + expect(html.match(/class="resubmit__required"/g)).toHaveLength(3) + expect(html.match(/class="resubmit__label-icon"/g)).toHaveLength(5) + expect(html).toContain('请确保门店名称与门头照片店名一致') + expect(html).toContain('工作人员会在1-7个工作日联系您') + expect(html).toContain('请选择门店地址') + }) + it('routes both address controls to the map and formats coordinates for display', () => { + const onChooseMap = vi.fn() + const html = renderToStaticMarkup(createElement(MapResubmitFields, { values: { entity_address_name: '测试地址', entity_address: '120,30' }, onChange: vi.fn(), onChooseMap })) + const controls = callbacks.views.filter((props) => props.className === 'resubmit__control resubmit__control--nav') + expect(controls).toHaveLength(2) + controls.forEach((props) => props.onClick()) + expect(onChooseMap).toHaveBeenCalledTimes(2) + expect(html).toContain('测试地址') + expect(html).toContain('N30.000000°') + }) + it('preserves input changes, phone sanitization and clear controls', () => { + const onChange = vi.fn() + renderToStaticMarkup(createElement(MapResubmitFields, { values: { entity_name: '原门店' }, onChange, onChooseMap: vi.fn() })) + callbacks.inputs[0].onInput({ detail: { value: '新门店' } }) + expect(onChange).toHaveBeenLastCalledWith('entity_name', '新门店') + callbacks.inputs[1].onInput({ detail: { value: '138-0013-8000' } }) + expect(onChange).toHaveBeenLastCalledWith('entity_phone', '13800138000') + callbacks.images.find((props) => props.className === 'resubmit__clear').onClick() + expect(onChange).toHaveBeenLastCalledWith('entity_name', '') + }) + it('keeps application notes shared between both pages', () => { + const html = renderToStaticMarkup(createElement(ResubmitNotes)) + expect(html).toContain('申请说明:') + expect(html).toContain('上线时间:') + expect(html).toContain('1-7个工作日完成上线,一次添加长久有效') + }) +}) diff --git a/src/pages/resubmit/fields.tsx b/src/pages/resubmit/fields.tsx new file mode 100644 index 0000000..90a5def --- /dev/null +++ b/src/pages/resubmit/fields.tsx @@ -0,0 +1,98 @@ +import { Image, Input, Text, View } from '@tarojs/components' +import { LOCAL_IMAGES } from '@/lib/assets' +import { ENTITY_LABELS, HOME_ICON_BASE, INPUT_ARROW_ICON } from '@/lib/cdn' +import { formatMapCoordinateDisplay, parseCoordinate, type ResubmitValues } from '@/lib/order' + +export function FieldLabel ({ icon, text, required }: { icon: string, text: string, required?: boolean }) { + return ( + + + {text} + {required ? * : null} + + ) +} + +function ClearInput ({ value, placeholder, type, maxlength, onChange }: { + value?: string + placeholder: string + type?: 'text' | 'number' + maxlength?: number + onChange: (value: string) => void +}) { + return ( + + onChange(event.detail.value)} + /> + {value ? onChange('')} /> : null} + + ) +} + +export function MapResubmitFields ({ values, onChange, onChooseMap }: { + values: ResubmitValues + onChange: (field: keyof ResubmitValues, value: string) => void + onChooseMap: () => void +}) { + const mapPoint = parseCoordinate(values.entity_address) + const mapDisplay = mapPoint ? formatMapCoordinateDisplay(mapPoint) : (values.entity_address || '') + + return <> + + + onChange('entity_name', value)} /> + 请确保门店名称与门头照片店名一致 + + + + + + + {values.entity_address_name || '请选择门店地址'} + + + + + + + + + + {mapDisplay || '请选择门店位置'} + + + + + + + + onChange('entity_phone', value.replace(/\D/g, ''))} /> + + + + + onChange('entity_phone2', value.replace(/\D/g, ''))} /> + 请确保您的电话畅通,工作人员会在1-7个工作日联系您 + + +} + +export function ResubmitNotes () { + return <> + + 申请说明: + 提交信息后,平台将进行审核。审核通过后,您的商户信息将在地图上展示,并同步生成专属详情页面。海量用户可通过地图搜索并快速找到您的位置,进一步提升品牌曝光与到店流量。 + + + 上线时间: + 1-7个工作日完成上线,一次添加长久有效 + + +} diff --git a/src/pages/resubmit/index.tsx b/src/pages/resubmit/index.tsx index 486d224..a60151e 100644 --- a/src/pages/resubmit/index.tsx +++ b/src/pages/resubmit/index.tsx @@ -1,11 +1,11 @@ -import { Button, Image, Input, Text, View } from '@tarojs/components' +import { Button, Image, Text, View } from '@tarojs/components' import Taro, { useDidShow, useLoad } from '@tarojs/taro' import { useState } from 'react' import PrivacyConsent from '@/components/privacy-consent' import { updateOrderExtra } from '@/api/pay' import { uploadImage } from '@/api/user' import { LOCAL_IMAGES } from '@/lib/assets' -import { ENTITY_LABELS, HOME_ICON_BASE, INPUT_ARROW_ICON, MAP_ASSET_BASE } from '@/lib/cdn' +import { ENTITY_LABELS, MAP_ASSET_BASE } from '@/lib/cdn' import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent' import { isCorpGoodsType } from '@/lib/detail' import { getOrderDraft } from '@/lib/orderDraft' @@ -13,7 +13,6 @@ import { isValidPhone } from '@/lib/phone' import { buildResubmitPayload, formatMapCoordinate, - formatMapCoordinateDisplay, getInitialPhone2, getOrderAddressName, getOrderExtraImages, @@ -26,6 +25,7 @@ import { type UploadedImage, } from '@/lib/order' import { consumeMapPickerResult } from '@/pages/map/picker' +import { FieldLabel, MapResubmitFields, ResubmitNotes } from './fields' import CorpResubmit from './corp' import './index.scss' @@ -36,54 +36,6 @@ const STOREFRONT_EXAMPLES = [ LOCAL_IMAGES.exampleStorefrontLeft, ] -const DISCLAIMER_BODY = - '提交信息后,平台将进行审核。审核通过后,您的商户信息将在地图上展示,并同步生成专属详情页面。海量用户可通过地图搜索并快速找到您的位置,进一步提升品牌曝光与到店流量。' - -function FieldLabel ({ icon, text, required }: { icon: string, text: string, required?: boolean }) { - return ( - - - {text} - {required ? * : null} - - ) -} - -function ClearInput ({ - value, - placeholder, - type, - maxlength, - onChange, -}: { - value?: string - placeholder: string - type?: 'text' | 'number' - maxlength?: number - onChange: (value: string) => void -}) { - return ( - - onChange(event.detail.value)} - /> - {value ? ( - onChange('')} - /> - ) : null} - - ) -} - function ImageSlot ({ value, uploading, @@ -249,7 +201,7 @@ export default function ResubmitPage () { return } if (!isValidPhone(values.entity_phone2 || '')) { - Taro.showToast({ title: '请填写正确的办理人电话', icon: 'none' }) + Taro.showToast({ title: '请填写正确的联系人电话', icon: 'none' }) return } @@ -271,9 +223,6 @@ export default function ResubmitPage () { } } - const mapPoint = parseCoordinate(values.entity_address) - const mapDisplay = mapPoint ? formatMapCoordinateDisplay(mapPoint) : (values.entity_address || '') - if (order && isCorpGoodsType(order.goods_type)) { return } @@ -286,58 +235,7 @@ export default function ResubmitPage () { - - - updateField('entity_name', value)} - /> - 请确保门店名称与门头照片店名一致 - - - - - - - {values.entity_address_name || '请选择门店地址'} - - - - - - - - - - {mapDisplay || '请选择门店位置'} - - - - - - - - updateField('entity_phone', value.replace(/\D/g, ''))} - /> - - - - - updateField('entity_phone2', value.replace(/\D/g, ''))} - /> - 请确保您的电话畅通,工作人员会在1-7个工作日联系您 - + {STOREFRONT_LABELS.map((label, index) => ( @@ -385,14 +283,7 @@ export default function ResubmitPage () { - - 申请说明: - {DISCLAIMER_BODY} - - - 上线时间: - 1-7个工作日完成上线,一次添加长久有效 - + {formError ? {formError} : null}