diff --git a/.env.development b/.env.development index d231aa4..5c9d3ce 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/.env.production b/.env.production index bd14bb7..23d0c5d 100644 --- a/.env.production +++ b/.env.production @@ -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 diff --git a/docs/a5-integration.md b/docs/a5-integration.md new file mode 100644 index 0000000..10c6bf3 --- /dev/null +++ b/docs/a5-integration.md @@ -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/` 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/`. + +## 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 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. diff --git a/src/app.config.ts b/src/app.config.ts index 95b6a0c..1c5ccf9 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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', diff --git a/src/assets/images/example-identity-back.png b/src/assets/images/example-identity-back.png new file mode 100644 index 0000000..8ef7d12 Binary files /dev/null and b/src/assets/images/example-identity-back.png differ diff --git a/src/assets/images/example-identity-front.png b/src/assets/images/example-identity-front.png new file mode 100644 index 0000000..ab9bb5c Binary files /dev/null and b/src/assets/images/example-identity-front.png differ diff --git a/src/lib/a5/api.test.ts b/src/lib/a5/api.test.ts new file mode 100644 index 0000000..90535d9 --- /dev/null +++ b/src/lib/a5/api.test.ts @@ -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(['404 Not Found', { code: 404, message: 'Not Found' }])('treats feedback HTTP 404 as UI-only success (%j)', async (data) => { + mocks.request.mockResolvedValue({ statusCode: 404, data }) + await expect(a5Request('/api/user/feedback', { method: 'POST', data: { order_id: '7' } })) + .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' } })) + }) +}) diff --git a/src/lib/a5/api.ts b/src/lib/a5/api.ts new file mode 100644 index 0000000..d8537f7 --- /dev/null +++ b/src/lib/a5/api.ts @@ -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 = {}) { + 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, 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, onProgress?: (percent: number) => void) { + const params: Record = 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 +} diff --git a/src/lib/a5/data.test.ts b/src/lib/a5/data.test.ts new file mode 100644 index 0000000..6edc591 --- /dev/null +++ b/src/lib/a5/data.test.ts @@ -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, '')).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') + }) +}) diff --git a/src/lib/a5/data.ts b/src/lib/a5/data.ts new file mode 100644 index 0000000..303b128 --- /dev/null +++ b/src/lib/a5/data.ts @@ -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 { + 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) { + const decode = (value: string) => { try { return decodeURIComponent(value) } catch { return value } } + const expanded: Record = { ...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 = { + '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 } +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 => Boolean(slot))), + entity_identity_card_image: joinUploadedImageIds(slots.slice(4, 6).filter((slot): slot is NonNullable => Boolean(slot))), + }), + } +} diff --git a/src/pages/a5/complaint/index.config.ts b/src/pages/a5/complaint/index.config.ts new file mode 100644 index 0000000..d9ba842 --- /dev/null +++ b/src/pages/a5/complaint/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '投诉' }) diff --git a/src/pages/a5/complaint/index.tsx b/src/pages/a5/complaint/index.tsx new file mode 100644 index 0000000..c77688e --- /dev/null +++ b/src/pages/a5/complaint/index.tsx @@ -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 } diff --git a/src/pages/a5/detail.tsx b/src/pages/a5/detail.tsx new file mode 100644 index 0000000..67af0bc --- /dev/null +++ b/src/pages/a5/detail.tsx @@ -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 ( + + event.stopPropagation()}> + + 详情 + + + + 完成截图 + {order.process_status === '2' ? PLATFORMS.map((platform) => ( + + )) : 暂无完成截图} + + + + ) +} +function PlatformFiles ({ orderId, linkId, scene, name }: { orderId: string, linkId: string, scene: string, name: string }) { + const [files, setFiles] = useState([]) + 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 ( + + {name} + {loading ? 截图加载中... : error ? ( + setAttempt((value) => value + 1)}>{error},点击重试 + ) : !files.length ? 暂无完成截图 : <> + {images.map((file) => Taro.previewImage({ current: file.url, urls: images.map((image) => image.url) })} />)} + {files.filter(isDocument).map((file) => void openDocument(file)}>{file.name || '报告文件'})} + } + + ) +} +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() } +} diff --git a/src/pages/a5/index.config.ts b/src/pages/a5/index.config.ts new file mode 100644 index 0000000..a35ff31 --- /dev/null +++ b/src/pages/a5/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationStyle: 'custom', backgroundColor: '#F6F8FC' }) diff --git a/src/pages/a5/index.tsx b/src/pages/a5/index.tsx new file mode 100644 index 0000000..8bf1387 --- /dev/null +++ b/src/pages/a5/index.tsx @@ -0,0 +1,3 @@ +import Index from '@/pages/index/index' + +export default function A5Page () { return } diff --git a/src/pages/a5/resubmit/documents.tsx b/src/pages/a5/resubmit/documents.tsx new file mode 100644 index 0000000..342a997 --- /dev/null +++ b/src/pages/a5/resubmit/documents.tsx @@ -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(null) + const group = example === null ? null : DOCUMENTS[example] + const requirements = example === 0 + ? ['门头完整,无遮挡', '文字清晰可辨认', '光线充足,画面清晰', '真实拍摄,不得涂改'] + : ['证件完整,四角齐全', '文字清晰可辨认', '无反光,无遮挡', '真实拍摄,不得涂改'] + return <> + {DOCUMENTS.map((document, groupIndex) => + + {document.title}{document.required ? * : null} + setExample(groupIndex)}>查看示例 ⓘ + + + {document.captions.map((caption, offset) => { + const index = document.start + offset + const image = slots[index] + const source = image?.preview || image?.url + return + + {image ? <> + {source ? Taro.previewImage({ current: source, urls: [source] })} /> : 已上传} + + : } + {busy === index ? {Math.round(progress)}% : null} + + {caption} + + })} + + )} + {group ? setExample(null)}> + event.stopPropagation()}> + {group.title}示例 + + {group.examples.map((source, index) => + Taro.previewImage({ current: source, urls: [...group.examples] })} /> + {group.captions[index]} + )} + + 拍摄要求 + {requirements.map((item) => {item})} + + + : null} + +} diff --git a/src/pages/a5/resubmit/index.config.ts b/src/pages/a5/resubmit/index.config.ts new file mode 100644 index 0000000..9029d96 --- /dev/null +++ b/src/pages/a5/resubmit/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '补充资料' }) diff --git a/src/pages/a5/resubmit/index.scss b/src/pages/a5/resubmit/index.scss new file mode 100644 index 0000000..f911af0 --- /dev/null +++ b/src/pages/a5/resubmit/index.scss @@ -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; } +} diff --git a/src/pages/a5/resubmit/index.tsx b/src/pages/a5/resubmit/index.tsx new file mode 100644 index 0000000..7a5b862 --- /dev/null +++ b/src/pages/a5/resubmit/index.tsx @@ -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(null) + const [values, setValues] = useState({}) + const [slots, setSlots] = useState(Array(6).fill(null)) + const [loading, setLoading] = useState(true) + const [filesReady, setFilesReady] = useState(false) + const [busy, setBusy] = useState(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 + {draft?.order.reject_reason ? 驳回理由:{draft.order.reject_reason} : null} + {([ + ['entity_name', '门店名称', '请输入店铺/公司名称'], + ['entity_address_name', '门店地址', '请输入实际经营地址'], + ['entity_address', '门店位置', '请选择门店位置'], + ['entity_phone', '门店电话', '请输入门店电话'], + ['entity_phone2', '联系人电话', '请输入联系人电话'], + ] as const).map(([field, label, placeholder]) => + {label}* + {field === 'entity_address' ? {values[field] || placeholder} : update(field, field.includes('phone') ? event.detail.value.replace(/\D/g, '') : event.detail.value)} />} + )} + 温馨提示:请确保您的电话畅通,工作人员会在1-7个工作日联系您 + {loading ? 图片加载中... : null} + void upload(index)} onRemove={(index) => void remove(index)} /> + {error ? {error}{draft ? !disabled && void loadFiles(draft)}> 重新加载资料 : null} : null} + { setAgreed(value); savePrivacyConsent(value) }} /> + + +} diff --git a/src/pages/complaint/index.tsx b/src/pages/complaint/index.tsx index 1d6ba91..702d65e 100644 --- a/src/pages/complaint/index.tsx +++ b/src/pages/complaint/index.tsx @@ -31,7 +31,13 @@ const TYPE_ICONS: Record = { }, } -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(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) } diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 92b1bfb..48c63db 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -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 () { - {ORDER_TABS.map((tab) => ( + {tabs.map((tab) => ( ( { if (!order.transaction_id) return @@ -170,7 +198,8 @@ export default function Index () { setShowContact(true)} /> {detailOrder ? ( - setDetailOrder(null)} /> + a5 ? setDetailOrder(null)} /> + : setDetailOrder(null)} /> ) : null} 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 ( diff --git a/types/global.d.ts b/types/global.d.ts index b1f60c3..3b73b0e 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -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,用于地图选点回填地址名称 */