This commit is contained in:
zhangjianjun 2026-09-07 16:14:31 +08:00
parent 2890248afa
commit ef6e138ea9
22 changed files with 740 additions and 15 deletions

View File

@ -1,3 +1,4 @@
TARO_APP_API_ORIGIN=https://corp-test.batiao8.com
TARO_APP_API_A5=https://ag-test.batiao8.com
TARO_APP_API_HOST=nb.batiao8.com
TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198

View File

@ -1,3 +1,4 @@
TARO_APP_API_ORIGIN=https://corp.batiao8.com
TARO_APP_API_A5=https://ag.batiao8.com
TARO_APP_API_HOST=nb.zuom8.cn
TARO_APP_TIANDITU_KEY=ad8b251afd667ea8b8bc6f33df2ce198

43
docs/a5-integration.md Normal file
View File

@ -0,0 +1,43 @@
# A5 Map Label Integration
## Launch Contract
`pages/a5/index?phone=13800138000&link_id=42`
- `phone`: auto-query phone; manual query remains available without it.
- `link_id`: A5 link ID for configuration, business documents and completion files.
- Legacy `/h/<id>` scenes and URL Link encoded separators are accepted.
- No corp token, host, package or default short link is used by A5.
- agent-h5 resolves the link from `agentLink.linkId`, `linkId`, then `/h/<id>`.
## Environments
- 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.
## API Contract
- `GET /h5/link?id=<link_id>`: link configuration and customer service.
- `GET /h5/order?phone=...&page=1&size=20&status=2`: paid orders, filtered to map_label. This follows the H5 first-20 query behavior.
- `PUT /h5/order`: `{ id, biz_info: JSON.stringify(...) }`.
- `GET/POST /h5/file`: `link_id`, `LinkId`, `scene`, `scene_id`; uploads use multipart field `file`.
- `DELETE /h5/file?id=...`: delete a document.
- Business scenes: license `order_biz1`, storefront `order_biz2`, identity `order_biz3`.
- Storefront uploads carry `extra.loc` (front/left/right labels in Chinese).
- Completion scenes: `order_process_gd`, `order_process_bd`, `order_process_tx`.
- Complaint: `POST /api/user/feedback`, matching agent-h5's declared API contract. This route was not found in the local agent-api router. Per the requested temporary UI behavior, only HTTP 404 for this POST is treated as success so the page shows its normal success message and returns. No feedback is persisted in that case. Other HTTP, business and network failures still surface. Remove this fallback when the endpoint is available.
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.
## Verification
- `pnpm test`
- `pnpm build:weapp`
- In agent-h5: `pnpm exec vitest run src/utils/weixinMiniProgram.test.ts src/ui/map_label/utils/miniProgramEntry.test.ts`
- In agent-h5: `pnpm build`
Open the mini-program project in WeChat DevTools, using `dist` as the mini-program root. Set the compile page to `pages/a5/index` and use an authorized test phone and matching A5 link ID. Check auto-query, pending-submission status, three required storefront slots, optional license/identity slots, example previews, map return, deletion, resubmission refresh, platform screenshots, PDF opening and complaints. Revisit the original index to verify it still uses the corp service.
No real customer orders were submitted or modified during implementation verification.

View File

@ -1,6 +1,9 @@
export default defineAppConfig({
pages: [
'pages/index/index',
'pages/a5/index',
'pages/a5/resubmit/index',
'pages/a5/complaint/index',
'pages/resubmit/index',
'pages/complaint/index',
'pages/map/index',

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

67
src/lib/a5/api.test.ts Normal file
View File

@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ request: vi.fn(), uploadFile: vi.fn(), getStorageSync: vi.fn(), setStorageSync: vi.fn() }))
vi.mock('@tarojs/taro', () => ({ default: mocks }))
import { a5Request, getA5Config, getA5Files, getA5Orders, uploadA5Image } from './api'
describe('A5 API isolation', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('TARO_APP_API_A5', 'http://ag-test.batiao8.com')
mocks.request.mockResolvedValue({ statusCode: 200, data: { code: 0, data: [] } })
})
it('queries the A5 path and paid map orders without corp headers or signatures', async () => {
mocks.request.mockResolvedValue({ statusCode: 200, data: { code: 0, data: { items: [{ id: 1, goods_type: 'map_label' }, { id: 2, goods_type: 'license_year' }] } } })
expect((await getA5Orders('13800138000')).data).toHaveLength(1)
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({
url: 'http://ag-test.batiao8.com/h5/order?phone=13800138000&page=1&size=20&status=2',
header: { 'content-type': 'application/json' },
}))
})
it('loads configuration and all platform files with the link ID', async () => {
await getA5Config('42')
expect(mocks.request.mock.calls[0][0].url).toBe('http://ag-test.batiao8.com/h5/link?id=42')
for (const scene of ['order_process_gd', 'order_process_bd', 'order_process_tx']) {
await getA5Files('42', '7', scene)
expect(mocks.request).toHaveBeenLastCalledWith(expect.objectContaining({ url: `http://ag-test.batiao8.com/h5/file?link_id=42&LinkId=42&scene=${scene}&scene_id=7` }))
}
})
it('refuses file operations without a link instead of using the corp link', async () => {
await expect(getA5Files('', '7', 'order_biz2')).rejects.toThrow('缺少链接信息')
expect(mocks.request).not.toHaveBeenCalled()
})
it('uploads multipart files directly with scene and loc', async () => {
mocks.uploadFile.mockImplementation((options) => {
options.success({ statusCode: 200, data: JSON.stringify({ code: 0, data: { id: 8, cdn_url: 'https://cdn/a.png' } }) })
return { onProgressUpdate: vi.fn() }
})
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.pathname).toBe('/h5/file')
expect(JSON.parse(url.searchParams.get('extra')!)).toEqual({ loc: '左面' })
expect(options.name).toBe('file')
expect(mocks.request).not.toHaveBeenCalled()
})
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' } }))
.resolves.toEqual({ code: 0, data: null })
})
it('does not suppress other feedback failures', async () => {
mocks.request.mockResolvedValue({ statusCode: 500, data: { code: 500, message: '服务异常' } })
await expect(a5Request('/api/user/feedback', { method: 'POST' })).rejects.toThrow('服务异常')
mocks.request.mockResolvedValue({ statusCode: 200, data: { code: 404, message: '业务失败' } })
await expect(a5Request('/api/user/feedback', { method: 'POST' })).rejects.toThrow('业务失败')
mocks.request.mockRejectedValue(new Error('网络异常'))
await expect(a5Request('/api/user/feedback', { method: 'POST' })).rejects.toThrow('网络异常')
})
it('still rejects 404 for other paths and methods', async () => {
mocks.request.mockResolvedValue({ statusCode: 404, data: { code: 404, message: 'Not Found' } })
await expect(getA5Orders('13800138000')).rejects.toThrow('Not Found')
await expect(a5Request('/api/user/feedback')).rejects.toThrow('Not Found')
})
it('reports complaints to the A5 origin', async () => {
await a5Request('/api/user/feedback', { method: 'POST', data: { order_id: '7' } })
expect(mocks.request).toHaveBeenCalledWith(expect.objectContaining({ url: 'http://ag-test.batiao8.com/api/user/feedback', method: 'POST', data: { order_id: '7' } }))
})
})

79
src/lib/a5/api.ts Normal file
View File

@ -0,0 +1,79 @@
import Taro from '@tarojs/taro'
import { adaptA5Files, adaptA5Order, items, record, text, unwrapA5Response } from './data'
import type { OrderLike } from '../order'
export function a5Url (path: string, params: Record<string, string> = {}) {
const origin = text(process.env.TARO_APP_API_A5).replace(/\/+$/, '')
if (!/^https?:\/\//.test(origin)) throw new Error('未配置 A5 接口域名')
const query = Object.entries(params).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
return `${origin}${path}${query ? `?${query}` : ''}`
}
export async function a5Request (path: string, options: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE', params?: Record<string, string>, data?: any } = {}) {
const response = await Taro.request({
url: a5Url(path, options.params), method: options.method || 'GET', data: options.data,
header: { 'content-type': 'application/json' }, timeout: 60000,
})
// Temporary UI-only success until the A5 feedback endpoint is available; nothing was persisted.
if (path === '/api/user/feedback' && options.method === 'POST' && response.statusCode === 404) {
return { code: 0, data: null }
}
return unwrapA5Response(response.statusCode, response.data)
}
export async function getA5Orders (phone: string) {
const response = await a5Request('/h5/order', { params: { phone, page: '1', size: '20', status: '2' } })
return { data: items(response.data).map(adaptA5Order).filter((order) => order.goods_type === 'map_label') }
}
export async function getA5Config (linkId: string) {
if (!linkId) return {}
const response = await a5Request('/h5/link', { params: { id: linkId } })
return record(response.data?.config)
}
function fileParams (linkId: string, scene: string, orderId: string) {
if (!linkId) throw new Error('缺少链接信息,请从原网页重新进入')
return { link_id: linkId, LinkId: linkId, scene, scene_id: orderId }
}
export async function getA5Files (linkId: string, orderId: string, scene: string) {
const response = await a5Request('/h5/file', { params: fileParams(linkId, scene, orderId) })
return adaptA5Files(response.data)
}
export function deleteA5File (id: string) {
return a5Request('/h5/file', { method: 'DELETE', params: { id } })
}
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)
onProgress?.(0)
return new Promise<{ id: string, url: string }>((resolve, reject) => {
const task = Taro.uploadFile({
url, filePath, name: 'file', timeout: 60000,
success (response) {
try {
const body = unwrapA5Response(response.statusCode, response.data)
const id = text(body.data?.id)
const imageUrl = text(body.data?.cdn_url || body.data?.url)
if (!id || !imageUrl) throw new Error('上传接口未返回图片地址')
onProgress?.(100)
resolve({ id, url: imageUrl })
} catch (error) { reject(error) }
},
fail: reject,
})
task.onProgressUpdate?.((event) => onProgress?.(event.progress))
})
}
export type A5Draft = { order: OrderLike, linkId: string }
const DRAFT_KEY = 'a5OrderDraft'
let draft: A5Draft | null = null
export function setA5Draft (value: A5Draft) {
draft = value
try { Taro.setStorageSync(DRAFT_KEY, value) } catch { /* Memory remains available. */ }
}
export function getA5Draft (): A5Draft | null {
if (draft) return draft
try {
const stored = Taro.getStorageSync(DRAFT_KEY)
if (stored?.order && typeof stored.linkId === 'string') draft = stored
} catch { /* Missing storage is handled by the page. */ }
return draft
}

47
src/lib/a5/data.test.ts Normal file
View File

@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { A5_STATUSES, A5_TABS, adaptA5Files, adaptA5Order, buildA5Update, parseA5Launch, storefrontFromFiles, unwrapA5Response } from './data'
describe('A5 launch', () => {
it('accepts the H5 contract without injecting corp defaults', () => {
expect(parseA5Launch({ phone: '13800138000', link_id: '42', token: 'corp-token' })).toEqual({ phone: '13800138000', linkId: '42' })
expect(parseA5Launch({})).toEqual({ phone: '', linkId: '' })
})
it('supports legacy encoded scenes and URL Link separators', () => {
expect(parseA5Launch({ scene: '%2Fh%2F42%26phone%3D13800138000' })).toEqual({ phone: '13800138000', linkId: '42' })
expect(parseA5Launch({ phone: '13800138000%26link_id=42' })).toEqual({ phone: '13800138000', linkId: '42' })
expect(parseA5Launch({ phone: '13800138000&link_id=old', link_id: 'new' }).linkId).toBe('new')
expect(parseA5Launch({ scene: '/h/old', link_id: 'new' }).linkId).toBe('new')
expect(parseA5Launch({ scene: '/p/corp' }).linkId).toBe('')
})
})
describe('A5 responses', () => {
it('accepts numeric or string success codes, rejects failed HTTP and business responses', () => {
expect(unwrapA5Response(200, '{"code":"0","data":[]}').data).toEqual([])
expect(() => unwrapA5Response(404, { code: 0 })).toThrow()
expect(() => unwrapA5Response(200, { code: 1, message: '失败' })).toThrow('失败')
expect(() => unwrapA5Response(200, '<html>')).toThrow('服务返回格式异常')
})
it('adapts biz_info and retains unrelated business values on resubmit', () => {
const order = adaptA5Order({ id: 7, goods_type: ' map_label ', process_status: 5, goods_info: '{"name":"地图标注"}', biz_info: JSON.stringify({ corp_name: '门店', phone: '13800138000', custom: 'keep', location: '120,30', corp_address: '地址', phone2: '13900139000' }), extra: { reject_reason: '缺资料', goods_param: { unused: true } } })
expect(order).toMatchObject({ order_id: '7', entity_name: '门店', process_status: '5', extra: { entity_address: '120,30', entity_address_name: '地址', entity_phone2: '13900139000' } })
const image = (id: string) => ({ id, url: 'https://cdn/a.png', preview: 'local' })
const body = buildA5Update(order, { entity_name: '新店', entity_phone: '13800138000' }, ['1', '2', '3', '4', '5', '6'].map(image))
expect(body.id).toBe('7')
expect(JSON.parse(body.biz_info)).toMatchObject({ custom: 'keep', corp_name: '新店', entity_storefront_image: '1,2,3', entity_business_license_image: '4', entity_identity_card_image: '5,6' })
expect(JSON.parse(body.biz_info)).not.toHaveProperty('goods_param')
})
it('does not classify pending submission as completed', () => {
expect(A5_STATUSES['5'].result).toBe('待提交')
expect(A5_TABS.find((tab) => tab.key === 'done')?.statuses).toEqual(['2'])
})
it('maps file locations without shifting missing storefront slots', () => {
const files = adaptA5Files({ items: [
{ id: 1, cdn_url: 'http://cdn/front.png', extra: '{"loc":"正面"}' },
{ id: 2, cdn_url: 'https://cdn/right.png', extra: { loc: '右面' } },
{ id: 3, cdn_url: 'https://cdn/front-new.png', extra: { loc: '正面' } },
] })
const slots = storefrontFromFiles(files)
expect(slots.map((slot) => slot?.id || null)).toEqual(['3', null, '2'])
expect(files[0].url).toBe('https://cdn/front.png')
})
})

108
src/lib/a5/data.ts Normal file
View File

@ -0,0 +1,108 @@
import { isCoordinateAddress, joinStorefrontSlots, joinUploadedImageIds, type OrderLike, type ResubmitValues, type StorefrontSlot } from '../order'
export function text (value: unknown) { return String(value ?? '').trim() }
export function record (value: any): Record<string, any> {
if (typeof value === 'string') {
try { return record(JSON.parse(value)) } catch { return {} }
}
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}
}
export function items (value: any): any[] {
return Array.isArray(value) ? value : Array.isArray(value?.items) ? value.items : []
}
export function parseA5Launch (raw: Record<string, string | undefined>) {
const decode = (value: string) => { try { return decodeURIComponent(value) } catch { return value } }
const expanded: Record<string, string | undefined> = { ...raw }
// URL Link providers may deliver encoded separators inside the first value.
for (const key of ['scene', 'phone']) {
const value = decode(text(raw[key])).replace(/%26/gi, '&')
const [first, ...rest] = value.split('&')
if (rest.length) {
for (const [name, item] of new URLSearchParams(rest.join('&'))) {
if (expanded[name] === undefined) expanded[name] = item
}
}
if (raw[key] !== undefined) expanded[key] = first
}
const scene = text(expanded.scene)
return {
phone: text(expanded.phone),
linkId: text(expanded.link_id || expanded.linkId) || scene.match(/^\/h\/([^/?#&]+)/)?.[1] || '',
}
}
export function unwrapA5Response (status: number, raw: any) {
let body = raw
if (typeof body === 'string') {
try { body = JSON.parse(body) } catch { throw new Error('服务返回格式异常') }
}
if (status < 200 || status >= 300 || !body || String(body.code) !== '0') {
throw new Error(text(body?.message || body?.msg) || `请求失败 (${status})`)
}
return body
}
export function adaptA5Order (raw: any): OrderLike {
const biz = record(raw.biz_info)
const extra = { ...record(raw.extra), ...biz, ...record(raw.entity_extra) }
extra.entity_address = text(extra.entity_address) || (isCoordinateAddress(biz.location) ? text(biz.location) : '')
extra.entity_address_name = text(extra.entity_address_name || biz.corp_address) || (!isCoordinateAddress(biz.location) ? text(biz.location) : '')
extra.entity_phone2 = text(biz.entity_phone2 || biz.phone2 || extra.entity_phone2)
return {
...raw,
order_id: text(raw.id || raw.order_id),
goods_type: text(raw.goods_type),
goods_name: text(record(raw.goods_info).name || raw.goods_name || raw.goods_type),
entity_name: text(biz.corp_name || raw.entity_name),
entity_phone: text(biz.phone || raw.entity_phone),
extra,
process_status: text(raw.process_status),
transaction_id: text(raw.transaction_id || raw.trade_no),
out_trade_no: text(raw.out_trade_no || raw.id || raw.order_id),
total_fee: Number(raw.total_fee ?? raw.price ?? 0),
reject_reason: text(extra.reject_reason || raw.reject_reason),
}
}
export const A5_STATUSES: Record<string, { result: string, color: string, bg: string }> = {
'1': { result: '待处理', color: '#d43838', bg: '#ffeaea' },
'2': { result: '处理完成', color: '#079b50', bg: '#e6f8ef' },
'3': { result: '处理失败', color: '#d43838', bg: '#ffeaea' },
'4': { result: '处理中', color: '#0261fc', bg: '#e5effe' },
'5': { result: '待提交', color: '#c05b00', bg: '#fff1e5' },
}
export const A5_TABS = [
{ key: 'all', label: '全部', statuses: [] },
{ key: 'doing', label: '办理中', statuses: ['4'] },
{ key: 'rejected', label: '待处理', statuses: ['3', '5'] },
{ key: 'waiting', label: '待办理', statuses: ['1'] },
{ key: 'done', label: '已完成', statuses: ['2'] },
] as const
export type A5File = { id: string, url: string, preview: string, name: string, fileType: string, extra: Record<string, any> }
export function adaptA5Files (data: any): A5File[] {
return items(data).map((file) => {
const url = text(file.cdn_url || file.url).replace(/^http:\/\//i, 'https://')
return { id: text(file.id), url, preview: url, name: text(file.name), fileType: text(file.file_type), extra: record(file.extra) }
}).filter((file) => file.url)
}
export const STOREFRONT_LOCS = ['正面', '左面', '右面']
export function storefrontFromFiles (files: A5File[]): StorefrontSlot[] {
const slots: StorefrontSlot[] = [null, null, null]
files.forEach((file) => {
const index = STOREFRONT_LOCS.indexOf(text(file.extra.loc))
const target = index >= 0 ? index : slots.findIndex((slot) => !slot)
if (target >= 0 && file.id) slots[target] = file
})
return slots
}
export function buildA5Update (order: OrderLike, values: ResubmitValues, slots: StorefrontSlot[]) {
const biz = { ...record(order.biz_info), ...record(order.entity_extra), ...record(order.extra) }
for (const key of ['origin_price', 'qrCodeImgUrl', 'remoteIp', 'weixinAppId', 'goods_param', 'entity_storefront_image_url', 'entity_business_license_image_url', 'entity_identity_card_image_url']) delete biz[key]
return {
id: text(order.order_id),
biz_info: JSON.stringify({
...biz, corp_name: text(values.entity_name), phone: text(values.entity_phone),
entity_address_name: text(values.entity_address_name), entity_address: text(values.entity_address), entity_phone2: text(values.entity_phone2),
entity_storefront_image: joinStorefrontSlots(slots.slice(0, 3)),
entity_business_license_image: joinUploadedImageIds(slots.slice(3, 4).filter((slot): slot is NonNullable<StorefrontSlot> => Boolean(slot))),
entity_identity_card_image: joinUploadedImageIds(slots.slice(4, 6).filter((slot): slot is NonNullable<StorefrontSlot> => Boolean(slot))),
}),
}
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '投诉' })

View File

@ -0,0 +1,12 @@
import ComplaintPage, { type ComplaintServices } from '@/pages/complaint/index'
import { a5Request, getA5Draft, uploadA5Image } from '@/lib/a5/api'
const services: ComplaintServices = {
getDraft: () => getA5Draft()?.order || null,
upload: (path, onProgress) => {
const draft = getA5Draft()
return uploadA5Image(path, draft?.linkId || '', String(draft?.order.order_id || ''), 'process', undefined, onProgress)
},
submit: (data) => a5Request('/api/user/feedback', { method: 'POST', data }),
}
export default function A5Complaint () { return <ComplaintPage services={services} /> }

73
src/pages/a5/detail.tsx Normal file
View File

@ -0,0 +1,73 @@
import { Image, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { getA5Files } from '@/lib/a5/api'
import type { A5File } from '@/lib/a5/data'
import { LOCAL_IMAGES } from '@/lib/assets'
import type { OrderLike } from '@/lib/order'
const PLATFORMS = [
{ scene: 'order_process_gd', name: '高德地图' },
{ scene: 'order_process_bd', name: '百度地图' },
{ scene: 'order_process_tx', name: '腾讯地图' },
]
export default function A5Detail ({ order, linkId, onCancel }: { order: OrderLike, linkId: string, onCancel: () => void }) {
return (
<View className='detail-mask' onClick={onCancel}>
<View className='detail-drawer' onClick={(event) => event.stopPropagation()}>
<View className='detail-dialog__head'>
<Text className='detail-dialog__title'></Text>
<View className='detail-dialog__close' onClick={onCancel}><Image className='detail-dialog__close-img' src={LOCAL_IMAGES.close} /></View>
</View>
<View className='detail-dialog__body'>
<Text className='detail-dialog__section-title'></Text>
{order.process_status === '2' ? PLATFORMS.map((platform) => (
<PlatformFiles key={`${order.order_id}-${platform.scene}`} orderId={String(order.order_id)} linkId={linkId} {...platform} />
)) : <Text className='detail-dialog__empty'></Text>}
</View>
</View>
</View>
)
}
function PlatformFiles ({ orderId, linkId, scene, name }: { orderId: string, linkId: string, scene: string, name: string }) {
const [files, setFiles] = useState<A5File[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let active = true
setLoading(true)
setError('')
void getA5Files(linkId, orderId, scene).then((result) => {
if (active) setFiles(result)
}).catch((reason) => {
if (active) setError(reason instanceof Error ? reason.message : '截图加载失败')
}).finally(() => { if (active) setLoading(false) })
return () => { active = false }
}, [linkId, orderId, scene, attempt])
const isDocument = (file: A5File) => file.fileType === 'document' || /\.pdf(?:\?|$)/i.test(file.url)
const images = files.filter((file) => !isDocument(file))
return (
<View className='detail-dialog__section'>
<Text className='detail-dialog__section-title'>{name}</Text>
{loading ? <Text>...</Text> : error ? (
<View onClick={() => setAttempt((value) => value + 1)}><Text>{error}</Text></View>
) : !files.length ? <Text className='detail-dialog__empty'></Text> : <>
{images.map((file) => <Image key={file.id || file.url} className='detail-dialog__shot' src={file.url} mode='widthFix' onClick={() => Taro.previewImage({ current: file.url, urls: images.map((image) => image.url) })} />)}
{files.filter(isDocument).map((file) => <View key={file.id || file.url} className='detail-dialog__pdf' onClick={() => void openDocument(file)}><Text>{file.name || '报告文件'}</Text></View>)}
</>}
</View>
)
}
async function openDocument (file: A5File) {
try {
if (Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) {
await Taro.setClipboardData({ data: file.url })
return
}
Taro.showLoading({ title: '打开中...' })
const response = await Taro.downloadFile({ url: file.url })
if (response.statusCode !== 200) throw new Error('下载失败')
await Taro.openDocument({ filePath: response.tempFilePath, showMenu: true })
} catch { Taro.showToast({ title: '报告打开失败', icon: 'none' }) } finally { Taro.hideLoading() }
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationStyle: 'custom', backgroundColor: '#F6F8FC' })

3
src/pages/a5/index.tsx Normal file
View File

@ -0,0 +1,3 @@
import Index from '@/pages/index/index'
export default function A5Page () { return <Index a5 /> }

View File

@ -0,0 +1,65 @@
import { Button, Image, Text, View } from '@tarojs/components'
import { useState } from 'react'
import Taro from '@tarojs/taro'
import { LOCAL_IMAGES } from '@/lib/assets'
import { MAP_ASSET_BASE } from '@/lib/cdn'
import type { StorefrontSlot } from '@/lib/order'
import identityFront from '@/assets/images/example-identity-front.png'
import identityBack from '@/assets/images/example-identity-back.png'
export const DOCUMENTS = [
{ title: '门头照片', required: true, captions: ['门头正面', '门头左面', '门头右面'], start: 0, examples: [LOCAL_IMAGES.exampleStorefrontFront, LOCAL_IMAGES.exampleStorefrontRight, LOCAL_IMAGES.exampleStorefrontLeft] },
{ title: '营业执照', required: false, captions: ['营业执照'], start: 3, examples: [`${MAP_ASSET_BASE}/pay-success-license-example.png`] },
{ title: '身份证照片', required: false, captions: ['身份证正面', '身份证反面'], start: 4, examples: [identityFront, identityBack] },
]
export default function Documents ({ slots, busy, progress, disabled, onUpload, onRemove }: {
slots: StorefrontSlot[], busy: number | null, progress: number, disabled: boolean,
onUpload: (index: number) => void, onRemove: (index: number) => void,
}) {
const [example, setExample] = useState<number | null>(null)
const group = example === null ? null : DOCUMENTS[example]
const requirements = example === 0
? ['门头完整,无遮挡', '文字清晰可辨认', '光线充足,画面清晰', '真实拍摄,不得涂改']
: ['证件完整,四角齐全', '文字清晰可辨认', '无反光,无遮挡', '真实拍摄,不得涂改']
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>
<Text className='a5-doc__example' onClick={() => setExample(groupIndex)}> </Text>
</View>
<View className='a5-doc__grid'>
{document.captions.map((caption, offset) => {
const index = document.start + offset
const image = slots[index]
const source = image?.preview || image?.url
return <View key={caption} className='a5-doc__item'>
<View className='a5-doc__media'>
{image ? <>
{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>
</Button>}
{busy === index ? <View className='a5-doc__progress'>{Math.round(progress)}%</View> : null}
</View>
<Text className='a5-doc__caption'>{caption}</Text>
</View>
})}
</View>
</View>)}
{group ? <View className='a5-example' onClick={() => setExample(null)}>
<View className='a5-example__dialog' onClick={(event) => event.stopPropagation()}>
<Text className='a5-example__title'>{group.title}</Text>
<View className='a5-doc__grid'>
{group.examples.map((source, index) => <View className='a5-doc__item' key={source}>
<Image className='a5-example__image' src={source} mode='aspectFit' onClick={() => Taro.previewImage({ current: source, urls: [...group.examples] })} />
<Text className='a5-doc__caption'>{group.captions[index]}</Text>
</View>)}
</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>
</View>
</View> : null}
</>
}

View File

@ -0,0 +1 @@
export default definePageConfig({ navigationBarTitleText: '补充资料' })

View File

@ -0,0 +1,41 @@
.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; }
}
.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; }
&__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; }
&__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::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; }
}
.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; }
&__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; }
&__requirements { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; font-size: 24px; color: #666; }
.a5-doc__item { max-width: none; }
}

View File

@ -0,0 +1,140 @@
import { Button, Input, 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'
import { buildA5Update, STOREFRONT_LOCS, storefrontFromFiles } from '@/lib/a5/data'
import { formatMapCoordinate, getInitialPhone2, getOrderAddressName, getOrderExtraImages, getOrderMapAddress, parseCoordinate, parseStorefrontSlots, type ResubmitValues, type StorefrontSlot } from '@/lib/order'
import { isValidPhone } from '@/lib/phone'
import { consumeMapPickerResult } from '@/pages/map/picker'
import PrivacyConsent from '@/components/privacy-consent'
import { hasPrivacyConsent, savePrivacyConsent } from '@/lib/consent'
import Documents from './documents'
import './index.scss'
export default function A5Resubmit () {
const [draft, setDraft] = useState<A5Draft | null>(null)
const [values, setValues] = useState<ResubmitValues>({})
const [slots, setSlots] = useState<StorefrontSlot[]>(Array(6).fill(null))
const [loading, setLoading] = useState(true)
const [filesReady, setFilesReady] = useState(false)
const [busy, setBusy] = useState<number | null>(null)
const [progress, setProgress] = useState(0)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [agreed, setAgreed] = useState(hasPrivacyConsent)
const lock = useRef(false)
const returningMap = useRef(false)
async function loadFiles (current: A5Draft) {
setLoading(true)
setFilesReady(false)
setError('')
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) => [
...(storefront.length ? storefrontFromFiles(storefront) : previous.slice(0, 3)),
...(license.length ? [license[license.length - 1]] : previous.slice(3, 4)),
...(identity.length ? [identity.slice(-2)[0] || null, identity.slice(-2)[1] || null] : previous.slice(4, 6)),
])
setFilesReady(true)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '图片加载失败')
} finally { setLoading(false) }
}
useLoad(() => {
const current = getA5Draft()
if (!current) {
setLoading(false)
setError('订单不存在,请返回查询页重新进入')
return
}
consumeMapPickerResult()
setDraft(current)
const order = current.order
setValues({ entity_name: order.entity_name || '', entity_phone: order.entity_phone || '', entity_phone2: getInitialPhone2(order), entity_address_name: getOrderAddressName(order), entity_address: getOrderMapAddress(order) })
const license = getOrderExtraImages(order, ['entity_business_license_image', 'business_license_image'])
const identity = getOrderExtraImages(order, ['entity_identity_card_image', 'identity_card_image'])
setSlots([...parseStorefrontSlots(order), license[0] || null, identity[0] || null, identity[1] || null])
void loadFiles(current)
})
useDidShow(() => {
if (!returningMap.current) return
returningMap.current = false
const point = consumeMapPickerResult()
if (point) setValues((current) => ({ ...current, entity_address: formatMapCoordinate(point), entity_address_name: point.address || point.name || current.entity_address_name }))
})
const disabled = loading || busy !== null || submitting
const update = (field: keyof ResubmitValues, value: string) => setValues((current) => ({ ...current, [field]: value }))
async function upload (index: number) {
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]
if (!path) return
setBusy(index)
setProgress(0)
setError('')
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 : '图片上传失败')
} finally { setBusy(null); lock.current = false }
}
async function remove (index: number) {
if (disabled || !filesReady || lock.current) return
lock.current = true
try {
const confirmation = await Taro.showModal({ title: '删除照片', content: '确定删除这张照片?' })
if (!confirmation.confirm) return
setBusy(index)
setProgress(0)
if (slots[index]?.id) await deleteA5File(slots[index]!.id)
setSlots((current) => current.map((slot, position) => position === index ? null : slot))
} catch (reason) { setError(reason instanceof Error ? reason.message : '删除失败') } finally { setBusy(null); lock.current = false }
}
async function submit () {
if (!draft || disabled || !filesReady || lock.current) return
const invalid = !agreed ? '请先阅读并同意协议'
: !values.entity_name?.trim() ? '请输入门店名称'
: !values.entity_address_name?.trim() ? '请输入实际经营地址'
: !values.entity_address ? '请选择门店位置'
: !isValidPhone(values.entity_phone || '') ? '请填写正确的门店电话'
: !isValidPhone(values.entity_phone2 || '') ? '请填写正确的联系人电话'
: slots.slice(0, 3).some((slot) => !slot?.id) ? '请上传门头正面、左面和右面照片' : ''
if (invalid) { Taro.showToast({ title: invalid, icon: 'none' }); return }
lock.current = true
setSubmitting(true)
try {
await a5Request('/h5/order', { method: 'PUT', data: buildA5Update(draft.order, values, slots) })
Taro.showToast({ title: '重新提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500)
} catch (reason) { setError(reason instanceof Error ? reason.message : '重新提交失败') } finally { setSubmitting(false); lock.current = false }
}
function chooseMap () {
const point = parseCoordinate(values.entity_address)
consumeMapPickerResult()
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>
</View>
}

View File

@ -31,7 +31,13 @@ const TYPE_ICONS: Record<ComplaintType, { idle: string, active: string }> = {
},
}
export default function ComplaintPage () {
export type ComplaintServices = {
getDraft: () => OrderLike | null
upload: typeof uploadImage
submit: typeof report
}
export default function ComplaintPage ({ services }: { services?: ComplaintServices }) {
const [order, setOrder] = useState<OrderLike | null>(null)
const [type, setType] = useState('')
const [content, setContent] = useState('')
@ -41,7 +47,7 @@ export default function ComplaintPage () {
const [submitting, setSubmitting] = useState(false)
useLoad(() => {
const draft = getOrderDraft()
const draft = services ? services.getDraft() : getOrderDraft()
if (!draft) {
Taro.showToast({ title: '订单不存在', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 400)
@ -67,7 +73,7 @@ export default function ComplaintPage () {
].slice(0, 5))
setUploading(true)
try {
const result = await uploadImage(filePath)
const result = await (services?.upload || uploadImage)(filePath)
setImages((current) => current.map((image) => (
image.preview === filePath
? { id: result.id, url: result.url, preview: filePath }
@ -101,7 +107,7 @@ export default function ComplaintPage () {
setSubmitting(true)
try {
await report(buildFeedbackPayload({
await (services?.submit || report)(buildFeedbackPayload({
contact: contact.trim(),
content: content.trim(),
images: images.map((image) => image.url),
@ -110,8 +116,8 @@ export default function ComplaintPage () {
}))
Taro.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500)
} catch {
Taro.showToast({ title: '提交失败,请稍后再试', icon: 'none' })
} catch (error) {
Taro.showToast({ title: error instanceof Error ? error.message : '提交失败,请稍后再试', icon: 'none' })
} finally {
setSubmitting(false)
}

View File

@ -1,6 +1,9 @@
import { Button, Image, Input, Text, View } from '@tarojs/components'
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useMemo, useRef, useState } from 'react'
import { getA5Config, getA5Orders, setA5Draft } from '@/lib/a5/api'
import { A5_STATUSES, A5_TABS, parseA5Launch } from '@/lib/a5/data'
import A5Detail from '@/pages/a5/detail'
import { getOrdersByPhone } from '@/api/pay'
import { LOCAL_IMAGES } from '@/lib/assets'
import { bootstrapSession } from '@/lib/bootstrap'
@ -22,7 +25,9 @@ import ContactModal, { ContactFab } from './contact'
import DetailDrawer from './detail'
import './index.scss'
export default function Index () {
export default function Index ({ a5 = false }: { a5?: boolean }) {
const linkIdRef = useRef('')
const queryIdRef = useRef(0)
const nav = useMemo(() => getNavMetrics(), [])
const [ready, setReady] = useState(false)
const [inputPhone, setInputPhone] = useState('')
@ -40,6 +45,17 @@ export default function Index () {
useLoad(async (options) => {
try {
if (a5) {
const launch = parseA5Launch(options || {})
linkIdRef.current = launch.linkId
setInputPhone(launch.phone)
setReady(true)
void getA5Config(launch.linkId).then((config) => setContact(pickServiceContact(config))).catch(() => {
Taro.showToast({ title: '客服配置加载失败', icon: 'none' })
})
if (launch.phone) await queryOrders(launch.phone)
return
}
const session = await bootstrapSession(options || {})
setInputPhone(session.phone)
setContact(pickServiceContact(session.config))
@ -65,16 +81,18 @@ export default function Index () {
return
}
const queryId = ++queryIdRef.current
setChecking(true)
try {
const res = await getOrdersByPhone(phone)
const nextOrders = filterOrdersForProduct(getSession().goodsType, res.data || [])
const res = a5 ? await getA5Orders(phone) : await getOrdersByPhone(phone)
if (queryId !== queryIdRef.current) return
const nextOrders = a5 ? res.data : filterOrdersForProduct(getSession().goodsType, res.data || [])
setOrders(nextOrders)
setSearched(true)
} catch {
Taro.showToast({ title: '当前无法查询,请稍后再试', icon: 'none' })
} finally {
setChecking(false)
if (queryId === queryIdRef.current) setChecking(false)
}
}
@ -84,12 +102,21 @@ export default function Index () {
}
})
const visibleOrders = filterOrdersByTab(orders, activeTab)
const tabs = a5 ? A5_TABS : ORDER_TABS
const a5Statuses: readonly string[] = A5_TABS.find((tab) => tab.key === activeTab)?.statuses || []
const visibleOrders = a5
? orders.filter((order) => !a5Statuses.length || a5Statuses.includes(order.process_status))
: filterOrdersByTab(orders, activeTab)
const emptyText = searched && orders.length === 0
? '该手机号未查询到订单,请联系客服处理'
: '暂无订单列表'
const openOrderPage = (path: string, order: OrderLike) => {
if (a5) {
setA5Draft({ order, linkId: linkIdRef.current })
Taro.navigateTo({ url: path.replace('/pages/', '/pages/a5/') })
return
}
setOrderDraft(order)
Taro.navigateTo({ url: path })
}
@ -132,7 +159,7 @@ export default function Index () {
</View>
<View className='home-tabs'>
{ORDER_TABS.map((tab) => (
{tabs.map((tab) => (
<View
key={tab.key}
className={`home-tabs__item ${activeTab === tab.key ? 'home-tabs__item--active' : ''}`}
@ -149,6 +176,7 @@ export default function Index () {
{visibleOrders.map((order, index) => (
<OrderCard
key={order.order_id || order.transaction_id || index}
a5={a5}
order={order}
onCopy={() => {
if (!order.transaction_id) return
@ -170,7 +198,8 @@ export default function Index () {
<ContactFab onClick={() => setShowContact(true)} />
{detailOrder ? (
<DetailDrawer order={detailOrder} onCancel={() => setDetailOrder(null)} />
a5 ? <A5Detail order={detailOrder} linkId={linkIdRef.current} onCancel={() => setDetailOrder(null)} />
: <DetailDrawer order={detailOrder} onCancel={() => setDetailOrder(null)} />
) : null}
<ContactModal
@ -183,20 +212,22 @@ export default function Index () {
}
function OrderCard ({
a5,
order,
onCopy,
onViewDetail,
onResubmit,
onComplaint,
}: {
a5?: boolean
order: OrderLike
onCopy: () => void
onViewDetail: () => void
onResubmit: () => void
onComplaint: () => void
}) {
const status = getStatusMeta(order.process_status, order.process_status_name)
const actions = getOrderCardActions(order)
const status = (a5 && A5_STATUSES[order.process_status]) || getStatusMeta(order.process_status, order.process_status_name)
const actions = a5 ? { complaint: true, submit: true, detail: order.process_status === '2' } : getOrderCardActions(order)
return (
<View className='order-card'>

2
types/global.d.ts vendored
View File

@ -25,6 +25,8 @@ declare namespace NodeJS {
TARO_APP_ID: string
/** 接口域名,开发/生产分别来自 .env.development / .env.production */
TARO_APP_API_ORIGIN: string
/** A5 独立接口域名 */
TARO_APP_API_A5: string
/** x-host 回退值 */
TARO_APP_API_HOST: string
/** 天地图逆地理编码 key用于地图选点回填地址名称 */