This commit is contained in:
parent
2890248afa
commit
ef6e138ea9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
|
@ -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' } }))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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))),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export default definePageConfig({ navigationBarTitleText: '投诉' })
|
||||
|
|
@ -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} /> }
|
||||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export default definePageConfig({ navigationStyle: 'custom', backgroundColor: '#F6F8FC' })
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import Index from '@/pages/index/index'
|
||||
|
||||
export default function A5Page () { return <Index a5 /> }
|
||||
|
|
@ -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}
|
||||
</>
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export default definePageConfig({ navigationBarTitleText: '补充资料' })
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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,用于地图选点回填地址名称 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue