This commit is contained in:
parent
ef6e138ea9
commit
3ac84c8fcf
|
|
@ -15,7 +15,7 @@
|
||||||
- Development: `TARO_APP_API_A5=http://ag-test.batiao8.com`
|
- Development: `TARO_APP_API_A5=http://ag-test.batiao8.com`
|
||||||
- Production: `TARO_APP_API_A5=http://ag.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
|
## 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.
|
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
|
## Verification
|
||||||
|
|
||||||
- `pnpm test`
|
- `pnpm test`
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
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 options = mocks.uploadFile.mock.calls[0][0]
|
||||||
const url = new URL(options.url)
|
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(url.pathname).toBe('/h5/file')
|
||||||
expect(JSON.parse(url.searchParams.get('extra')!)).toEqual({ loc: '左面' })
|
expect(JSON.parse(url.searchParams.get('extra')!)).toEqual({ loc: '左面' })
|
||||||
expect(options.name).toBe('file')
|
expect(options.name).toBe('file')
|
||||||
expect(mocks.request).not.toHaveBeenCalled()
|
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) => {
|
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 })
|
mocks.request.mockResolvedValue({ statusCode: 404, data })
|
||||||
await expect(a5Request('/api/user/feedback', { method: 'POST', data: { order_id: '7' } }))
|
await expect(a5Request('/api/user/feedback', { method: 'POST', data: { order_id: '7' } }))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { adaptA5Files, adaptA5Order, items, record, text, unwrapA5Response } from './data'
|
import { adaptA5Files, adaptA5Order, items, record, text, unwrapA5Response } from './data'
|
||||||
|
import { imageErrorMessage } from './image'
|
||||||
import type { OrderLike } from '../order'
|
import type { OrderLike } from '../order'
|
||||||
|
|
||||||
export function a5Url (path: string, params: Record<string, string> = {}) {
|
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) {
|
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)
|
const params: Record<string, string> = fileParams(linkId, scene, orderId)
|
||||||
if (extra) params.extra = JSON.stringify(extra)
|
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)
|
onProgress?.(0)
|
||||||
return new Promise<{ id: string, url: string }>((resolve, reject) => {
|
return new Promise<{ id: string, url: string }>((resolve, reject) => {
|
||||||
const task = Taro.uploadFile({
|
const task = Taro.uploadFile({
|
||||||
url, filePath, name: 'file', timeout: 60000,
|
url, filePath, name: 'file', timeout: 60000,
|
||||||
success (response) {
|
success (response) {
|
||||||
try {
|
try {
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw new Error(`图片上传服务异常 (${response.statusCode}),请稍后重试`)
|
||||||
|
}
|
||||||
const body = unwrapA5Response(response.statusCode, response.data)
|
const body = unwrapA5Response(response.statusCode, response.data)
|
||||||
const id = text(body.data?.id)
|
const id = text(body.data?.id)
|
||||||
const imageUrl = text(body.data?.cdn_url || body.data?.url)
|
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 })
|
resolve({ id, url: imageUrl })
|
||||||
} catch (error) { reject(error) }
|
} catch (error) { reject(error) }
|
||||||
},
|
},
|
||||||
fail: reject,
|
fail (error) {
|
||||||
|
reject(new Error(imageErrorMessage(error)))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
task.onProgressUpdate?.((event) => onProgress?.(event.progress))
|
task.onProgressUpdate?.((event) => onProgress?.(event.progress))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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('图片上传超时,请重试')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { LOCAL_IMAGES } from '@/lib/assets'
|
import { LOCAL_IMAGES } from '@/lib/assets'
|
||||||
import { MAP_ASSET_BASE } from '@/lib/cdn'
|
import { MAP_ASSET_BASE } from '@/lib/cdn'
|
||||||
|
import { FieldLabel } from '@/pages/resubmit/fields'
|
||||||
import type { StorefrontSlot } from '@/lib/order'
|
import type { StorefrontSlot } from '@/lib/order'
|
||||||
import identityFront from '@/assets/images/example-identity-front.png'
|
import identityFront from '@/assets/images/example-identity-front.png'
|
||||||
import identityBack from '@/assets/images/example-identity-back.png'
|
import identityBack from '@/assets/images/example-identity-back.png'
|
||||||
|
|
@ -24,7 +25,7 @@ export default function Documents ({ slots, busy, progress, disabled, onUpload,
|
||||||
return <>
|
return <>
|
||||||
{DOCUMENTS.map((document, groupIndex) => <View className='a5-doc' key={document.title}>
|
{DOCUMENTS.map((document, groupIndex) => <View className='a5-doc' key={document.title}>
|
||||||
<View className='a5-doc__header'>
|
<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>
|
<Text className='a5-doc__example' onClick={() => setExample(groupIndex)}>查看示例 ⓘ</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='a5-doc__grid'>
|
<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>}
|
{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__remove' disabled={disabled} onClick={() => onRemove(index)}>×</Button>
|
||||||
</> : <Button className='a5-doc__upload' disabled={disabled} onClick={() => onUpload(index)}>
|
</> : <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>}
|
</Button>}
|
||||||
{busy === index ? <View className='a5-doc__progress'>{Math.round(progress)}%</View> : null}
|
{busy === index ? <View className='a5-doc__progress'>{Math.round(progress)}%</View> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -58,7 +59,7 @@ export default function Documents ({ slots, busy, progress, disabled, onUpload,
|
||||||
</View>
|
</View>
|
||||||
<Text className='a5-example__subtitle'>拍摄要求</Text>
|
<Text className='a5-example__subtitle'>拍摄要求</Text>
|
||||||
<View className='a5-example__requirements'>{requirements.map((item) => <Text key={item}>{item}</Text>)}</View>
|
<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>
|
||||||
</View> : null}
|
</View> : null}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1 +1,6 @@
|
||||||
export default definePageConfig({ navigationBarTitleText: '补充资料' })
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '重新提交',
|
||||||
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
backgroundColor: '#F7F8FA',
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,33 @@
|
||||||
.a5-form {
|
.resubmit-page--a5 {
|
||||||
padding: 32px 28px calc(40px + env(safe-area-inset-bottom));
|
.resubmit-card { width: 100%; }
|
||||||
background: #fff;
|
.resubmit__control-value, .resubmit__error { overflow-wrap: anywhere; }
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
.a5-doc {
|
.a5-doc {
|
||||||
margin: 32px 0;
|
margin-bottom: 28px;
|
||||||
&__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; }
|
&:last-child { margin-bottom: 0; }
|
||||||
&__example { font-size: 24px; color: #0261fc; }
|
&__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; }
|
&__grid { display: flex; gap: 20px; }
|
||||||
&__item { flex: 1; min-width: 0; max-width: calc((100% - 40px) / 3); text-align: center; }
|
&__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%; }
|
&__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; }
|
&__upload::after, &__remove::after { border: 0; }
|
||||||
&__camera { width: 48px; height: 48px; margin-bottom: 12px; }
|
&__camera { width: 56px; height: 56px; margin-bottom: 8px; }
|
||||||
&__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; }
|
&__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; margin-top: 12px; }
|
&__caption { display: block; font-size: 24px; color: #1d2129; margin-top: 12px; }
|
||||||
&__progress { position: absolute; bottom: 0; left: 0; right: 0; background: #0261fc; color: white; text-align: center; font-size: 22px; }
|
&__progress { position: absolute; bottom: 0; left: 0; right: 0; background: #165dff; color: white; text-align: center; font-size: 22px; }
|
||||||
}
|
}
|
||||||
.a5-example {
|
.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;
|
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; }
|
&__title { display: block; text-align: center; font-size: 32px; font-weight: 600; margin: 8px 0 28px; }
|
||||||
&__image { width: 100%; height: 180px; }
|
&__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; }
|
&__requirements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; font-size: 24px; color: #666; }
|
||||||
.a5-doc__item { max-width: none; }
|
.a5-doc__item { max-width: none; }
|
||||||
|
.resubmit__submit { margin-top: 28px; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 Taro, { useDidShow, useLoad } from '@tarojs/taro'
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { a5Request, deleteA5File, getA5Draft, getA5Files, uploadA5Image, type A5Draft } from '@/lib/a5/api'
|
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 { isValidPhone } from '@/lib/phone'
|
||||||
import { consumeMapPickerResult } from '@/pages/map/picker'
|
import { consumeMapPickerResult } from '@/pages/map/picker'
|
||||||
import PrivacyConsent from '@/components/privacy-consent'
|
import PrivacyConsent from '@/components/privacy-consent'
|
||||||
|
import { chooseA5Image, imageErrorMessage } from '@/lib/a5/image'
|
||||||
import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent'
|
import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent'
|
||||||
|
import { MapResubmitFields, ResubmitNotes } from '@/pages/resubmit/fields'
|
||||||
|
import '@/pages/resubmit/index.scss'
|
||||||
import Documents from './documents'
|
import Documents from './documents'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
@ -21,6 +24,7 @@ export default function A5Resubmit () {
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [loadError, setLoadError] = useState('')
|
||||||
const [agreed, setAgreed] = useState(hasPrivacyConsent)
|
const [agreed, setAgreed] = useState(hasPrivacyConsent)
|
||||||
const lock = useRef(false)
|
const lock = useRef(false)
|
||||||
const returningMap = useRef(false)
|
const returningMap = useRef(false)
|
||||||
|
|
@ -28,7 +32,7 @@ export default function A5Resubmit () {
|
||||||
async function loadFiles (current: A5Draft) {
|
async function loadFiles (current: A5Draft) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setFilesReady(false)
|
setFilesReady(false)
|
||||||
setError('')
|
setLoadError('')
|
||||||
try {
|
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)))
|
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) => [
|
setSlots((previous) => [
|
||||||
|
|
@ -38,7 +42,7 @@ export default function A5Resubmit () {
|
||||||
])
|
])
|
||||||
setFilesReady(true)
|
setFilesReady(true)
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(reason instanceof Error ? reason.message : '图片加载失败')
|
setLoadError(imageErrorMessage(reason, '图片加载失败'))
|
||||||
} finally { setLoading(false) }
|
} finally { setLoading(false) }
|
||||||
}
|
}
|
||||||
useLoad(() => {
|
useLoad(() => {
|
||||||
|
|
@ -69,9 +73,8 @@ export default function A5Resubmit () {
|
||||||
if (!draft || disabled || !filesReady || lock.current) return
|
if (!draft || disabled || !filesReady || lock.current) return
|
||||||
lock.current = true
|
lock.current = true
|
||||||
try {
|
try {
|
||||||
if (!agreed) { Taro.showToast({ title: '请先阅读并同意协议', icon: 'none' }); return }
|
setError('')
|
||||||
const chosen = await Taro.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'] })
|
const path = await chooseA5Image()
|
||||||
const path = chosen.tempFilePaths[0]
|
|
||||||
if (!path) return
|
if (!path) return
|
||||||
setBusy(index)
|
setBusy(index)
|
||||||
setProgress(0)
|
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)
|
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))
|
setSlots((current) => current.map((slot, position) => position === index ? { ...uploaded, preview: path } : slot))
|
||||||
} catch (reason) {
|
} 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 }
|
} finally { setBusy(null); lock.current = false }
|
||||||
}
|
}
|
||||||
async function remove (index: number) {
|
async function remove (index: number) {
|
||||||
|
|
@ -118,23 +121,21 @@ export default function A5Resubmit () {
|
||||||
returningMap.current = true
|
returningMap.current = true
|
||||||
void Taro.navigateTo({ url: `/pages/map/index?name=${encodeURIComponent(values.entity_address_name || '')}${point ? `&lng=${point.lng}&lat=${point.lat}` : ''}` })
|
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'>
|
return <View className='resubmit-page resubmit-page--a5'>
|
||||||
{draft?.order.reject_reason ? <View className='a5-form__error'>驳回理由:{draft.order.reject_reason}</View> : null}
|
{draft?.order.reject_reason ? <View className='resubmit-page__reject'>驳回理由:{draft.order.reject_reason}</View> : null}
|
||||||
{([
|
<View className='resubmit-page__body resubmit-page__body--consent'>
|
||||||
['entity_name', '门店名称', '请输入店铺/公司名称'],
|
<View className='resubmit-card'>
|
||||||
['entity_address_name', '门店地址', '请输入实际经营地址'],
|
<MapResubmitFields values={values} onChange={update} onChooseMap={chooseMap} />
|
||||||
['entity_address', '门店位置', '请选择门店位置'],
|
{loading ? <Text className='a5-doc__loading'>图片加载中...</Text> : null}
|
||||||
['entity_phone', '门店电话', '请输入门店电话'],
|
<Documents slots={slots} busy={busy} progress={progress} disabled={disabled || !filesReady} onUpload={(index) => void upload(index)} onRemove={(index) => void remove(index)} />
|
||||||
['entity_phone2', '联系人电话', '请输入联系人电话'],
|
</View>
|
||||||
] as const).map(([field, label, placeholder]) => <View className='a5-form__field' key={field}>
|
<ResubmitNotes />
|
||||||
<Text className='a5-form__label'>{label}<Text className='a5-form__required'>*</Text></Text>
|
{loadError ? <View className='resubmit__error'>{loadError}{draft ? <Text onClick={() => !disabled && void loadFiles(draft)}> 重新加载资料</Text> : null}</View> : null}
|
||||||
{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)} />}
|
{error ? <View className='resubmit__error'>{error}</View> : null}
|
||||||
</View>)}
|
</View>
|
||||||
<Text className='a5-form__tip'>温馨提示:请确保您的电话畅通,工作人员会在1-7个工作日联系您</Text>
|
<View className='resubmit-page__footer resubmit-page__footer--consent'>
|
||||||
{loading ? <Text>图片加载中...</Text> : null}
|
<PrivacyConsent className='resubmit__privacy' agreed={agreed} onChange={(value) => { setAgreed(value); savePrivacyConsent(value) }} />
|
||||||
<Documents slots={slots} busy={busy} progress={progress} disabled={disabled || !filesReady} onUpload={(index) => void upload(index)} onRemove={(index) => void remove(index)} />
|
<Button className='resubmit__submit' loading={submitting} disabled={disabled || !filesReady || !draft} onClick={() => void submit()}>立即提交</Button>
|
||||||
{error ? <View className='a5-form__error'>{error}{draft ? <Text onClick={() => !disabled && void loadFiles(draft)}> 重新加载资料</Text> : null}</View> : null}
|
</View>
|
||||||
<PrivacyConsent agreed={agreed} onChange={(value) => { setAgreed(value); savePrivacyConsent(value) }} />
|
|
||||||
<Button className='a5-form__submit' loading={submitting} disabled={disabled || !filesReady || !draft} onClick={() => void submit()}>立即提交</Button>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,8 @@ const PRIVACY_POLICY: Agreement = {
|
||||||
{
|
{
|
||||||
title: '一、我们收集的信息及目的',
|
title: '一、我们收集的信息及目的',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'1. 联系电话:由您在查询框或表单中主动输入,或由业务跳转链接携带。我们使用联系电话匹配和展示关联订单、核验申请、联系办理人、反馈处理进度及提供客户服务。未经您勾选同意,我们不会以该号码发起订单查询或提交表单。',
|
'1. 联系电话:由您在查询框或表单中主动输入,或由业务跳转链接携带。我们使用联系电话匹配和展示关联订单、核验申请、联系联系人、反馈处理进度及提供客户服务。未经您勾选同意,我们不会以该号码发起订单查询或提交表单。',
|
||||||
'2. 门店及业务资料:由您主动填写或上传,包括门店名称、地址、地图坐标、门店电话、办理人电话、门头照片和营业执照图片。我们仅用于审核申请资料、确认门店真实性、完成地图标注、生成相关业务展示页面及与您联系。',
|
'2. 门店及业务资料:由您主动填写或上传,包括门店名称、地址、地图坐标、门店电话、联系人电话、门头照片和营业执照图片。我们仅用于审核申请资料、确认门店真实性、完成地图标注、生成相关业务展示页面及与您联系。',
|
||||||
'3. 企业申报资料:由您主动填写、查询并选择,包括企业名称、统一社会信用代码、法定代表人姓名、法定代表人身份证号和手机号。身份证号属于敏感个人信息,仅在核验申报主体身份和办理相关业务确有必要时使用。处理敏感个人信息可能对个人权益产生较大影响,请在确认必要后再提交。',
|
'3. 企业申报资料:由您主动填写、查询并选择,包括企业名称、统一社会信用代码、法定代表人姓名、法定代表人身份证号和手机号。身份证号属于敏感个人信息,仅在核验申报主体身份和办理相关业务确有必要时使用。处理敏感个人信息可能对个人权益产生较大影响,请在确认必要后再提交。',
|
||||||
'4. 位置信息:当您使用定位、搜索地点或地图选点功能时,经您授权后通过微信定位能力获取当前位置或由您主动选择地点,并可能将坐标发送至地图服务进行地址解析。该信息仅用于帮助您确定门店位置和填写门店地址。您可以拒绝定位并手动在地图上选点,但部分便捷定位功能将无法使用。',
|
'4. 位置信息:当您使用定位、搜索地点或地图选点功能时,经您授权后通过微信定位能力获取当前位置或由您主动选择地点,并可能将坐标发送至地图服务进行地址解析。该信息仅用于帮助您确定门店位置和填写门店地址。您可以拒绝定位并手动在地图上选点,但部分便捷定位功能将无法使用。',
|
||||||
'5. 投诉与反馈信息:由您主动提交,包括投诉类型、问题描述、图片以及选填的手机号、微信号或邮箱。我们用于受理、核实、联系和处理投诉。',
|
'5. 投诉与反馈信息:由您主动提交,包括投诉类型、问题描述、图片以及选填的手机号、微信号或邮箱。我们用于受理、核实、联系和处理投诉。',
|
||||||
|
|
|
||||||
|
|
@ -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个工作日完成上线,一次添加长久有效')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
@ -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 Taro, { useDidShow, useLoad } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import PrivacyConsent from '@/components/privacy-consent'
|
import PrivacyConsent from '@/components/privacy-consent'
|
||||||
import { updateOrderExtra } from '@/api/pay'
|
import { updateOrderExtra } from '@/api/pay'
|
||||||
import { uploadImage } from '@/api/user'
|
import { uploadImage } from '@/api/user'
|
||||||
import { LOCAL_IMAGES } from '@/lib/assets'
|
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 { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent'
|
||||||
import { isCorpGoodsType } from '@/lib/detail'
|
import { isCorpGoodsType } from '@/lib/detail'
|
||||||
import { getOrderDraft } from '@/lib/orderDraft'
|
import { getOrderDraft } from '@/lib/orderDraft'
|
||||||
|
|
@ -13,7 +13,6 @@ import { isValidPhone } from '@/lib/phone'
|
||||||
import {
|
import {
|
||||||
buildResubmitPayload,
|
buildResubmitPayload,
|
||||||
formatMapCoordinate,
|
formatMapCoordinate,
|
||||||
formatMapCoordinateDisplay,
|
|
||||||
getInitialPhone2,
|
getInitialPhone2,
|
||||||
getOrderAddressName,
|
getOrderAddressName,
|
||||||
getOrderExtraImages,
|
getOrderExtraImages,
|
||||||
|
|
@ -26,6 +25,7 @@ import {
|
||||||
type UploadedImage,
|
type UploadedImage,
|
||||||
} from '@/lib/order'
|
} from '@/lib/order'
|
||||||
import { consumeMapPickerResult } from '@/pages/map/picker'
|
import { consumeMapPickerResult } from '@/pages/map/picker'
|
||||||
|
import { FieldLabel, MapResubmitFields, ResubmitNotes } from './fields'
|
||||||
import CorpResubmit from './corp'
|
import CorpResubmit from './corp'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
|
|
@ -36,54 +36,6 @@ const STOREFRONT_EXAMPLES = [
|
||||||
LOCAL_IMAGES.exampleStorefrontLeft,
|
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 ({
|
function ImageSlot ({
|
||||||
value,
|
value,
|
||||||
uploading,
|
uploading,
|
||||||
|
|
@ -249,7 +201,7 @@ export default function ResubmitPage () {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isValidPhone(values.entity_phone2 || '')) {
|
if (!isValidPhone(values.entity_phone2 || '')) {
|
||||||
Taro.showToast({ title: '请填写正确的办理人电话', icon: 'none' })
|
Taro.showToast({ title: '请填写正确的联系人电话', icon: 'none' })
|
||||||
return
|
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)) {
|
if (order && isCorpGoodsType(order.goods_type)) {
|
||||||
return <CorpResubmit order={order} />
|
return <CorpResubmit order={order} />
|
||||||
}
|
}
|
||||||
|
|
@ -286,58 +235,7 @@ export default function ResubmitPage () {
|
||||||
|
|
||||||
<View className='resubmit-page__body resubmit-page__body--consent'>
|
<View className='resubmit-page__body resubmit-page__body--consent'>
|
||||||
<View className='resubmit-card'>
|
<View className='resubmit-card'>
|
||||||
<View className='resubmit__field'>
|
<MapResubmitFields values={values} onChange={updateField} onChooseMap={handleChooseMap} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{STOREFRONT_LABELS.map((label, index) => (
|
{STOREFRONT_LABELS.map((label, index) => (
|
||||||
<View className='resubmit__field' key={label}>
|
<View className='resubmit__field' key={label}>
|
||||||
|
|
@ -385,14 +283,7 @@ export default function ResubmitPage () {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className='resubmit__disclaimer'>
|
<ResubmitNotes />
|
||||||
<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>
|
|
||||||
{formError ? <Text className='resubmit__error'>{formError}</Text> : null}
|
{formError ? <Text className='resubmit__error'>{formError}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue