This commit is contained in:
zhangjianjun 2026-09-07 17:07:34 +08:00
parent ef6e138ea9
commit 3ac84c8fcf
13 changed files with 333 additions and 176 deletions

View File

@ -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`

View File

@ -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: '<html>Bad Gateway</html>' })
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(['<html>404 Not Found</html>', { 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' } }))

View File

@ -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<string, string> = {}) {
@ -42,13 +43,17 @@ export function deleteA5File (id: string) {
export async function uploadA5Image (filePath: string, linkId: string, orderId: string, scene = 'process', extra?: Record<string, string>, onProgress?: (percent: number) => void) {
const params: Record<string, string> = 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))
})

50
src/lib/a5/image.test.ts Normal file
View File

@ -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('图片上传超时,请重试')
})
})

34
src/lib/a5/image.ts Normal file
View File

@ -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<string> {
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)
}
}

View File

@ -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) => <View className='a5-doc' key={document.title}>
<View className='a5-doc__header'>
<Text>{document.title}{document.required ? <Text className='a5-form__required'>*</Text> : null}</Text>
<FieldLabel icon={`${MAP_ASSET_BASE}/${groupIndex === 0 ? 'pay-success-storefront.png' : 'pay-success-license.png'}`} text={document.title} required={document.required} />
<Text className='a5-doc__example' onClick={() => setExample(groupIndex)}> </Text>
</View>
<View className='a5-doc__grid'>
@ -38,7 +39,7 @@ export default function Documents ({ slots, busy, progress, disabled, onUpload,
{source ? <Image className='a5-doc__image' src={source} mode='aspectFit' onClick={() => Taro.previewImage({ current: source, urls: [source] })} /> : <Text></Text>}
<Button className='a5-doc__remove' disabled={disabled} onClick={() => onRemove(index)}>×</Button>
</> : <Button className='a5-doc__upload' disabled={disabled} onClick={() => onUpload(index)}>
<Image className='a5-doc__camera' src={LOCAL_IMAGES.camera} /><Text></Text>
<Image className='a5-doc__camera' src={LOCAL_IMAGES.camera} /><Text></Text>
</Button>}
{busy === index ? <View className='a5-doc__progress'>{Math.round(progress)}%</View> : null}
</View>
@ -58,7 +59,7 @@ export default function Documents ({ slots, busy, progress, disabled, onUpload,
</View>
<Text className='a5-example__subtitle'></Text>
<View className='a5-example__requirements'>{requirements.map((item) => <Text key={item}>{item}</Text>)}</View>
<Button className='a5-form__submit' onClick={() => setExample(null)}></Button>
<Button className='resubmit__submit' onClick={() => setExample(null)}></Button>
</View>
</View> : null}
</>

View File

@ -1 +1,6 @@
export default definePageConfig({ navigationBarTitleText: '补充资料' })
export default definePageConfig({
navigationBarTitleText: '重新提交',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#F7F8FA',
})

View File

@ -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; }
}

View File

@ -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 <View className='a5-form'>
{draft?.order.reject_reason ? <View className='a5-form__error'>{draft.order.reject_reason}</View> : null}
{([
['entity_name', '门店名称', '请输入店铺/公司名称'],
['entity_address_name', '门店地址', '请输入实际经营地址'],
['entity_address', '门店位置', '请选择门店位置'],
['entity_phone', '门店电话', '请输入门店电话'],
['entity_phone2', '联系人电话', '请输入联系人电话'],
] as const).map(([field, label, placeholder]) => <View className='a5-form__field' key={field}>
<Text className='a5-form__label'>{label}<Text className='a5-form__required'>*</Text></Text>
{field === 'entity_address' ? <View className='a5-form__location' onClick={chooseMap}><Text>{values[field] || placeholder}</Text><Text></Text></View> : <Input className='a5-form__input' value={values[field] || ''} placeholder={placeholder} type={field.includes('phone') ? 'number' : 'text'} maxlength={field.includes('phone') ? 12 : 150} onInput={(event) => update(field, field.includes('phone') ? event.detail.value.replace(/\D/g, '') : event.detail.value)} />}
</View>)}
<Text className='a5-form__tip'>1-7</Text>
{loading ? <Text>...</Text> : null}
<Documents slots={slots} busy={busy} progress={progress} disabled={disabled || !filesReady} onUpload={(index) => void upload(index)} onRemove={(index) => void remove(index)} />
{error ? <View className='a5-form__error'>{error}{draft ? <Text onClick={() => !disabled && void loadFiles(draft)}> </Text> : null}</View> : null}
<PrivacyConsent agreed={agreed} onChange={(value) => { setAgreed(value); savePrivacyConsent(value) }} />
<Button className='a5-form__submit' loading={submitting} disabled={disabled || !filesReady || !draft} onClick={() => void submit()}></Button>
return <View className='resubmit-page resubmit-page--a5'>
{draft?.order.reject_reason ? <View className='resubmit-page__reject'>{draft.order.reject_reason}</View> : null}
<View className='resubmit-page__body resubmit-page__body--consent'>
<View className='resubmit-card'>
<MapResubmitFields values={values} onChange={update} onChooseMap={chooseMap} />
{loading ? <Text className='a5-doc__loading'>...</Text> : null}
<Documents slots={slots} busy={busy} progress={progress} disabled={disabled || !filesReady} onUpload={(index) => void upload(index)} onRemove={(index) => void remove(index)} />
</View>
<ResubmitNotes />
{loadError ? <View className='resubmit__error'>{loadError}{draft ? <Text onClick={() => !disabled && void loadFiles(draft)}> </Text> : null}</View> : null}
{error ? <View className='resubmit__error'>{error}</View> : null}
</View>
<View className='resubmit-page__footer resubmit-page__footer--consent'>
<PrivacyConsent className='resubmit__privacy' agreed={agreed} onChange={(value) => { setAgreed(value); savePrivacyConsent(value) }} />
<Button className='resubmit__submit' loading={submitting} disabled={disabled || !filesReady || !draft} onClick={() => void submit()}></Button>
</View>
</View>
}

View File

@ -81,8 +81,8 @@ const PRIVACY_POLICY: Agreement = {
{
title: '一、我们收集的信息及目的',
paragraphs: [
'1. 联系电话:由您在查询框或表单中主动输入,或由业务跳转链接携带。我们使用联系电话匹配和展示关联订单、核验申请、联系办理人、反馈处理进度及提供客户服务。未经您勾选同意,我们不会以该号码发起订单查询或提交表单。',
'2. 门店及业务资料:由您主动填写或上传,包括门店名称、地址、地图坐标、门店电话、办理人电话、门头照片和营业执照图片。我们仅用于审核申请资料、确认门店真实性、完成地图标注、生成相关业务展示页面及与您联系。',
'1. 联系电话:由您在查询框或表单中主动输入,或由业务跳转链接携带。我们使用联系电话匹配和展示关联订单、核验申请、联系联系人、反馈处理进度及提供客户服务。未经您勾选同意,我们不会以该号码发起订单查询或提交表单。',
'2. 门店及业务资料:由您主动填写或上传,包括门店名称、地址、地图坐标、门店电话、联系人电话、门头照片和营业执照图片。我们仅用于审核申请资料、确认门店真实性、完成地图标注、生成相关业务展示页面及与您联系。',
'3. 企业申报资料:由您主动填写、查询并选择,包括企业名称、统一社会信用代码、法定代表人姓名、法定代表人身份证号和手机号。身份证号属于敏感个人信息,仅在核验申报主体身份和办理相关业务确有必要时使用。处理敏感个人信息可能对个人权益产生较大影响,请在确认必要后再提交。',
'4. 位置信息:当您使用定位、搜索地点或地图选点功能时,经您授权后通过微信定位能力获取当前位置或由您主动选择地点,并可能将坐标发送至地图服务进行地址解析。该信息仅用于帮助您确定门店位置和填写门店地址。您可以拒绝定位并手动在地图上选点,但部分便捷定位功能将无法使用。',
'5. 投诉与反馈信息:由您主动提交,包括投诉类型、问题描述、图片以及选填的手机号、微信号或邮箱。我们用于受理、核实、联系和处理投诉。',

View File

@ -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个工作日完成上线一次添加长久有效')
})
})

View File

@ -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 (
<View className='resubmit__label'>
<Image className='resubmit__label-icon' src={icon} />
<Text>{text}</Text>
{required ? <Text className='resubmit__required'>*</Text> : null}
</View>
)
}
function ClearInput ({ value, placeholder, type, maxlength, onChange }: {
value?: string
placeholder: string
type?: 'text' | 'number'
maxlength?: number
onChange: (value: string) => void
}) {
return (
<View className='resubmit__control'>
<Input
className='resubmit__input'
type={type}
maxlength={maxlength}
value={value}
placeholder={placeholder}
placeholderClass='resubmit__placeholder'
onInput={(event) => onChange(event.detail.value)}
/>
{value ? <Image className='resubmit__clear' src={LOCAL_IMAGES.clear} onClick={() => onChange('')} /> : null}
</View>
)
}
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 <>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_marker@2x.png`} text={ENTITY_LABELS.entityName} required />
<ClearInput value={values.entity_name} placeholder='请输入门店名称' onChange={(value) => onChange('entity_name', value)} />
<Text className='resubmit__hint'></Text>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location@2x.png`} text={ENTITY_LABELS.entityAddress} />
<View className='resubmit__control resubmit__control--nav' onClick={onChooseMap}>
<Text className={values.entity_address_name ? 'resubmit__control-value' : 'resubmit__placeholder'}>
{values.entity_address_name || '请选择门店地址'}
</Text>
<Image className='resubmit__arrow' src={INPUT_ARROW_ICON} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location_lnglat@2x.png`} text={ENTITY_LABELS.entityMapLocation} />
<View className='resubmit__control resubmit__control--nav' onClick={onChooseMap}>
<Text className={mapDisplay ? 'resubmit__control-value' : 'resubmit__placeholder'}>
{mapDisplay || '请选择门店位置'}
</Text>
<Image className='resubmit__arrow' src={INPUT_ARROW_ICON} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='门店电话' required />
<ClearInput type='number' maxlength={12} value={values.entity_phone} placeholder='请输入' onChange={(value) => onChange('entity_phone', value.replace(/\D/g, ''))} />
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='联系人电话' required />
<ClearInput type='number' maxlength={12} value={values.entity_phone2} placeholder='请输入' onChange={(value) => onChange('entity_phone2', value.replace(/\D/g, ''))} />
<Text className='resubmit__hint'>1-7</Text>
</View>
</>
}
export function ResubmitNotes () {
return <>
<Text className='resubmit__disclaimer'>
<Text className='resubmit__disclaimer-label'></Text>
<Text className='resubmit__disclaimer-text'></Text>
</Text>
<Text className='resubmit__online'>
<Text className='resubmit__disclaimer-label'>线</Text>
<Text className='resubmit__disclaimer-text'>1-7线</Text>
</Text>
</>
}

View File

@ -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 (
<View className='resubmit__label'>
<Image className='resubmit__label-icon' src={icon} />
<Text>{text}</Text>
{required ? <Text className='resubmit__required'>*</Text> : null}
</View>
)
}
function ClearInput ({
value,
placeholder,
type,
maxlength,
onChange,
}: {
value?: string
placeholder: string
type?: 'text' | 'number'
maxlength?: number
onChange: (value: string) => void
}) {
return (
<View className='resubmit__control'>
<Input
className='resubmit__input'
type={type}
maxlength={maxlength}
value={value}
placeholder={placeholder}
placeholderClass='resubmit__placeholder'
onInput={(event) => onChange(event.detail.value)}
/>
{value ? (
<Image
className='resubmit__clear'
src={LOCAL_IMAGES.clear}
onClick={() => onChange('')}
/>
) : null}
</View>
)
}
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 <CorpResubmit order={order} />
}
@ -286,58 +235,7 @@ export default function ResubmitPage () {
<View className='resubmit-page__body resubmit-page__body--consent'>
<View className='resubmit-card'>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_marker@2x.png`} text={ENTITY_LABELS.entityName} required />
<ClearInput
value={values.entity_name}
placeholder='请输入门店名称'
onChange={(value) => updateField('entity_name', value)}
/>
<Text className='resubmit__hint'></Text>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location@2x.png`} text={ENTITY_LABELS.entityAddress} />
<View className='resubmit__control resubmit__control--nav' onClick={handleChooseMap}>
<Text className={values.entity_address_name ? 'resubmit__control-value' : 'resubmit__placeholder'}>
{values.entity_address_name || '请选择门店地址'}
</Text>
<Image className='resubmit__arrow' src={INPUT_ARROW_ICON} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location_lnglat@2x.png`} text={ENTITY_LABELS.entityMapLocation} />
<View className='resubmit__control resubmit__control--nav' onClick={handleChooseMap}>
<Text className={mapDisplay ? 'resubmit__control-value' : 'resubmit__placeholder'}>
{mapDisplay || '请选择门店位置'}
</Text>
<Image className='resubmit__arrow' src={INPUT_ARROW_ICON} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='门店电话' required />
<ClearInput
type='number'
maxlength={12}
value={values.entity_phone}
placeholder='请输入'
onChange={(value) => updateField('entity_phone', value.replace(/\D/g, ''))}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='办理人电话' required />
<ClearInput
type='number'
maxlength={12}
value={values.entity_phone2}
placeholder='请输入'
onChange={(value) => updateField('entity_phone2', value.replace(/\D/g, ''))}
/>
<Text className='resubmit__hint'>1-7</Text>
</View>
<MapResubmitFields values={values} onChange={updateField} onChooseMap={handleChooseMap} />
{STOREFRONT_LABELS.map((label, index) => (
<View className='resubmit__field' key={label}>
@ -385,14 +283,7 @@ export default function ResubmitPage () {
</View>
</View>
<Text className='resubmit__disclaimer'>
<Text className='resubmit__disclaimer-label'></Text>
<Text className='resubmit__disclaimer-text'>{DISCLAIMER_BODY}</Text>
</Text>
<Text className='resubmit__online'>
<Text className='resubmit__disclaimer-label'>线</Text>
<Text className='resubmit__disclaimer-text'>1-7线</Text>
</Text>
<ResubmitNotes />
{formError ? <Text className='resubmit__error'>{formError}</Text> : null}
</View>