This commit is contained in:
zhangjianjun 2026-08-20 14:13:36 +08:00
parent 7d16ae8e8f
commit 7ecb4ef976
33 changed files with 1813 additions and 944 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ node_modules/
.swc
*.local
.env
design/

View File

@ -22,6 +22,20 @@ export function initCorp (params: Record<string, any> = {}) {
})
}
export function report (data: {
contact: string
content: string
images?: string[]
type: string
[property: string]: any
}) {
return request({
url: '/api/user/feedback',
method: 'POST',
data,
})
}
export function presign (ext = '') {
return request({
url: '/api/presign',

View File

@ -1,13 +1,15 @@
export default defineAppConfig({
pages: [
'pages/index/index',
'pages/resubmit/index',
'pages/complaint/index',
'pages/map/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#2F4FEE',
navigationBarTitleText: '网上快办平台',
navigationBarTextStyle: 'white'
navigationBarBackgroundColor: '#FFFFFF',
navigationBarTitleText: '网上快办',
navigationBarTextStyle: 'black'
},
permission: {
'scope.userLocation': {

View File

@ -0,0 +1,3 @@
page {
background: #f6f8fc;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

17
src/lib/assets.ts Normal file
View File

@ -0,0 +1,17 @@
import iconCamera from '@/assets/images/icon-camera.png'
import iconClose from '@/assets/images/icon-close.png'
import emptyOrders from '@/assets/images/empty-orders.png'
import iconInputClear from '@/assets/images/icon-input-clear.png'
import iconLogo from '@/assets/images/icon-logo.png'
import iconPen from '@/assets/images/icon-pen.png'
import iconSearch from '@/assets/images/icon-search.png'
export const LOCAL_IMAGES = {
camera: iconCamera,
close: iconClose,
empty: emptyOrders,
clear: iconInputClear,
logo: iconLogo,
pen: iconPen,
search: iconSearch,
}

View File

@ -5,12 +5,15 @@ export const HOME_ICON_BASE = `${MAP_ASSET_BASE}/images-pc/home`
export const MOBILE_ASSET_BASE = `${MAP_ASSET_BASE}/images-mobile`
export const BANNER_BG = `${MOBILE_ASSET_BASE}/bg-banner.png`
export const CARD_HEADER = `${MOBILE_ASSET_BASE}/card_header.png`
export const PAGE_TITLE = '网上快办平台'
export const PAGE_TITLE = '网上快办'
export const RECEIPT_STAMP_DONE = `${STATIC_CDN}/images/oderDetailCallBackSuccess.png`
export const RECEIPT_STAMP_PENDING = `${STATIC_CDN}/images/oderDetailCallBack.png`
export const ENTITY_LABELS = {
entityName: '门店名称',
entityAddress: '门店地址',
entityMapLocation: '门店定位',
entityName: '标注名称',
entityAddress: '标注地址',
entityMapLocation: '地图位置',
}
export const COPY_ICON = `${CORP_ASSET_BASE}/images-mobile/icon-copy.png`
export const INPUT_ARROW_ICON = `${MAP_ASSET_BASE}/icon_input_arrow@2x.png`

38
src/lib/feedback.test.ts Normal file
View File

@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { buildFeedbackPayload, COMPLAINT_TYPES } from './feedback'
describe('complaint types', () => {
it('uses the five Chinese labels from the design as type values', () => {
expect(COMPLAINT_TYPES.map((item) => item.value)).toEqual([
'莫名扣费',
'超过7天未解决',
'交易被骗',
'不满意商家服务',
'其他问题',
])
})
})
describe('buildFeedbackPayload', () => {
it('sends contact, content, images, Chinese type and order identifiers', () => {
expect(buildFeedbackPayload({
contact: '13800138000',
content: '好好好',
images: ['https://cdn.example/a.jpg'],
type: '其他问题',
order: {
order_id: '9',
out_trade_no: '123456789562323256',
goods_name: '地图标注代办服务',
},
})).toEqual({
contact: '13800138000',
content: '好好好',
images: ['https://cdn.example/a.jpg'],
type: '其他问题',
order_id: '9',
out_trade_no: '123456789562323256',
goods_name: '地图标注代办服务',
})
})
})

33
src/lib/feedback.ts Normal file
View File

@ -0,0 +1,33 @@
export const COMPLAINT_TYPES = [
{ value: '莫名扣费', label: '莫名扣费' },
{ value: '超过7天未解决', label: '超过7天未解决' },
{ value: '交易被骗', label: '交易被骗' },
{ value: '不满意商家服务', label: '不满意商家服务' },
{ value: '其他问题', label: '其他问题' },
] as const
export type ComplaintType = (typeof COMPLAINT_TYPES)[number]['value']
export type FeedbackOrder = {
order_id?: string
out_trade_no?: string
goods_name?: string
}
export function buildFeedbackPayload (input: {
contact: string
content: string
images?: string[]
type: string
order?: FeedbackOrder | null
}) {
return {
contact: input.contact,
content: input.content,
images: input.images || [],
type: input.type,
order_id: String(input.order?.order_id || ''),
out_trade_no: String(input.order?.out_trade_no || ''),
goods_name: String(input.order?.goods_name || ''),
}
}

20
src/lib/nav.ts Normal file
View File

@ -0,0 +1,20 @@
import Taro from '@tarojs/taro'
export function getNavMetrics () {
const sys = Taro.getSystemInfoSync()
const statusBarHeight = sys.statusBarHeight || 20
if (Taro.getEnv() === Taro.ENV_TYPE.WEAPP) {
const menu = Taro.getMenuButtonBoundingClientRect()
const gap = Math.max((menu.top || statusBarHeight) - statusBarHeight, 4)
return {
statusBarHeight,
navBarHeight: (menu.height || 32) + gap * 2,
paddingRight: Math.max((sys.windowWidth || 375) - (menu.left || 280) + 12, 24),
}
}
return {
statusBarHeight,
navBarHeight: 44,
paddingRight: 24,
}
}

View File

@ -4,11 +4,14 @@ import {
filterMapLabelOrders,
filterOrdersForProduct,
formatMapCoordinate,
formatMapCoordinateDisplay,
getOrderAddressName,
getOrderExtraImages,
getOrderMapAddress,
joinStorefrontSlots,
joinUploadedImageIds,
parseCoordinate,
parseStorefrontSlots,
} from './order'
const mapOrder = {
@ -88,8 +91,42 @@ describe('order images', () => {
})
})
describe('storefront slots', () => {
const front = { id: '123', url: 'https://cdn.example/front.jpg', preview: 'https://cdn.example/front.jpg' }
const left = { id: '45', url: 'https://cdn.example/left.jpg', preview: 'https://cdn.example/left.jpg' }
it('joins three slots and writes null for empty ones', () => {
expect(joinStorefrontSlots([front, left, null])).toBe('123,45,null')
expect(joinStorefrontSlots([front, null, null])).toBe('123,null,null')
expect(joinStorefrontSlots([null, null, null])).toBe('null,null,null')
})
it('parses comma ids into front/left/right, treating null as empty', () => {
expect(parseStorefrontSlots({
extra: {
entity_storefront_image: '123,45,null',
entity_storefront_image_url: 'https://cdn.example/front.jpg,https://cdn.example/left.jpg,null',
},
})).toEqual([front, left, null])
})
it('pads legacy two-image storefront data into three slots', () => {
expect(parseStorefrontSlots(mapOrder as any)).toEqual([
{ id: '11', url: 'https://cdn.example/a.jpg', preview: 'https://cdn.example/a.jpg' },
{ id: '12', url: 'https://cdn.example/b.jpg', preview: 'https://cdn.example/b.jpg' },
null,
])
})
})
describe('map coordinate display', () => {
it('renders northing and easting for the resubmit map field', () => {
expect(formatMapCoordinateDisplay({ lng: 120.156892, lat: 30.278956 })).toBe('N30.278956°, E120.156892°')
})
})
describe('buildResubmitPayload', () => {
it('updates extra while dropping upload-only keys', () => {
it('updates extra while dropping upload-only keys and keeping storefront null placeholders', () => {
expect(
buildResubmitPayload({
order: mapOrder as any,
@ -100,8 +137,12 @@ describe('buildResubmitPayload', () => {
entity_phone: '13900139000',
entity_phone2: '13700137000',
},
storefrontImages: [{ id: '21', url: 'https://cdn.example/c.jpg', preview: 'p' }],
licenseImages: [{ id: '31', url: 'https://cdn.example/d.jpg', preview: 'p' }],
storefrontSlots: [
{ id: '123', url: 'https://cdn.example/c.jpg', preview: 'p' },
{ id: '45', url: 'https://cdn.example/d.jpg', preview: 'p' },
null,
],
licenseImages: [{ id: '31', url: 'https://cdn.example/e.jpg', preview: 'p' }],
})
).toEqual({
id: '4',
@ -113,7 +154,7 @@ describe('buildResubmitPayload', () => {
entity_address_name: '淮海路2号',
entity_address: '121.480000,31.240000',
entity_phone2: '13700137000',
entity_storefront_image: '21',
entity_storefront_image: '123,45,null',
entity_business_license_image: '31',
},
})

View File

@ -44,6 +44,47 @@ function splitValues (value: unknown) {
.filter(Boolean)
}
function isEmptySlotToken (value?: string) {
return !value || value === 'null'
}
function splitSlotValues (value: unknown) {
if (value === undefined || value === null || value === '') return []
return String(value).split(',').map((item) => item.trim())
}
export type StorefrontSlot = UploadedImage | null
export function joinStorefrontSlots (slots: Array<StorefrontSlot | undefined> = []) {
return [0, 1, 2].map((index) => {
const id = slots[index]?.id?.trim()
return id ? id : 'null'
}).join(',')
}
export function parseStorefrontSlots (order: OrderLike | null | undefined): StorefrontSlot[] {
const extra = order?.extra || {}
const ids = splitSlotValues(extra.entity_storefront_image)
const urls = splitSlotValues(extra.entity_storefront_image_url)
return [0, 1, 2].map((index) => {
const id = ids[index] || ''
const url = urls[index] || ''
if (isEmptySlotToken(id) && isEmptySlotToken(url)) return null
if (isEmptySlotToken(id) && !isEmptySlotToken(url)) {
return { id: '', url, preview: url }
}
const resolvedUrl = !isEmptySlotToken(url)
? url
: (/^https?:\/\//i.test(id) ? id : '')
return {
id: /^https?:\/\//i.test(id) ? '' : id,
url: resolvedUrl,
preview: resolvedUrl,
}
})
}
export function isCoordinateAddress (address?: string) {
if (!address) return false
const parts = address.split(',').map((part) => part.trim())
@ -61,6 +102,10 @@ export function formatMapCoordinate (point: { lng: number, lat: number }) {
return `${point.lng.toFixed(6)},${point.lat.toFixed(6)}`
}
export function formatMapCoordinateDisplay (point: { lng: number, lat: number }) {
return `N${point.lat.toFixed(6)}°, E${point.lng.toFixed(6)}°`
}
export function getOrderAddressName (order: OrderLike) {
const extra = order.extra || {}
return extra.entity_address_name || (!isCoordinateAddress(extra.entity_address) ? extra.entity_address : '') || ''
@ -143,7 +188,7 @@ export function joinUploadedImageIds (images: UploadedImage[]) {
export function buildResubmitPayload (input: {
order: OrderLike
values: ResubmitValues
storefrontImages: UploadedImage[]
storefrontSlots: StorefrontSlot[]
licenseImages: UploadedImage[]
}) {
const existingExtra = Object.entries(input.order.extra || {}).reduce<Record<string, unknown>>((result, [key, value]) => {
@ -164,7 +209,7 @@ export function buildResubmitPayload (input: {
entity_address_name: input.values.entity_address_name || '',
entity_address: input.values.entity_address || '',
entity_phone2: input.values.entity_phone2 || '',
entity_storefront_image: joinUploadedImageIds(input.storefrontImages),
entity_storefront_image: joinStorefrontSlots(input.storefrontSlots),
entity_business_license_image: joinUploadedImageIds(input.licenseImages),
},
}

29
src/lib/orderDraft.ts Normal file
View File

@ -0,0 +1,29 @@
import Taro from '@tarojs/taro'
import type { OrderLike } from './order'
const ORDER_DRAFT_KEY = 'orderDraft'
let memoryDraft: OrderLike | null = null
export function setOrderDraft (order: OrderLike) {
memoryDraft = order
try {
Taro.setStorageSync(ORDER_DRAFT_KEY, order)
} catch {
// ignore quota / private-mode failures; in-memory draft still works
}
}
export function getOrderDraft (): OrderLike | null {
if (memoryDraft) return memoryDraft
try {
const stored = Taro.getStorageSync(ORDER_DRAFT_KEY)
if (stored && typeof stored === 'object') {
memoryDraft = stored
return stored
}
} catch {
// ignore
}
return null
}

73
src/lib/status.test.ts Normal file
View File

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
filterOrdersByTab,
getOrderCardActions,
getStatusMeta,
ORDER_TABS,
} from './status'
describe('order status labels', () => {
it('uses the redesigned tab labels for known process statuses', () => {
expect(getStatusMeta('4').result).toBe('办理中')
expect(getStatusMeta('6').result).toBe('待处理')
expect(getStatusMeta('1').result).toBe('待办理')
expect(getStatusMeta('3').result).toBe('待办理')
expect(getStatusMeta('2').result).toBe('已完成')
expect(ORDER_TABS.map((tab) => tab.label)).toEqual(['全部', '办理中', '待处理', '待办理', '已完成'])
})
})
describe('filterOrdersByTab', () => {
const orders = [
{ order_id: 'doing', process_status: '4' },
{ order_id: 'rejected', process_status: '6' },
{ order_id: 'waiting', process_status: '1' },
{ order_id: 'waiting-3', process_status: '3' },
{ order_id: 'done', process_status: '2' },
]
it('filters list orders into the five redesigned tabs', () => {
expect(filterOrdersByTab(orders, 'all').map((item) => item.order_id)).toEqual([
'doing', 'rejected', 'waiting', 'waiting-3', 'done',
])
expect(filterOrdersByTab(orders, 'doing').map((item) => item.order_id)).toEqual(['doing'])
expect(filterOrdersByTab(orders, 'rejected').map((item) => item.order_id)).toEqual(['rejected'])
expect(filterOrdersByTab(orders, 'waiting').map((item) => item.order_id)).toEqual(['waiting', 'waiting-3'])
expect(filterOrdersByTab(orders, 'done').map((item) => item.order_id)).toEqual(['done'])
})
})
describe('getOrderCardActions', () => {
it('shows complaint plus submit on 办理中 and 待处理', () => {
expect(getOrderCardActions({ process_status: '4', goods_type: 'map_label' })).toEqual({
complaint: true,
submit: true,
detail: false,
})
expect(getOrderCardActions({ process_status: '6', goods_type: 'map_label' })).toEqual({
complaint: true,
submit: true,
detail: false,
})
})
it('shows only complaint on 待办理', () => {
expect(getOrderCardActions({ process_status: '1', goods_type: 'map_label' })).toEqual({
complaint: true,
submit: false,
detail: false,
})
})
it('shows view-detail on completed orders and hides complaint', () => {
expect(getOrderCardActions({ process_status: '2', goods_type: 'map_label' })).toEqual({
complaint: false,
submit: false,
detail: true,
})
})
it('hides submit for corp goods even when status would otherwise allow it', () => {
expect(getOrderCardActions({ process_status: '4', goods_type: 'license_year' }).submit).toBe(false)
})
})

View File

@ -1,12 +1,14 @@
import { isCorpGoodsType } from './detail'
export const STATUS_CONFIG: Record<string, { color: string, bg: string, result: string }> = {
'4': { color: '#FF8E13', bg: '#FFF2E4', result: '处理中' },
'1': { color: '#FF431D', bg: '#fff1e6', result: '待办理' },
'3': { color: '#FF431D', bg: '#fff1e6', result: '待办理' },
'2': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'5': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'7': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'8': { color: '#37D2AD', bg: '#DFF8F2', result: '已完成' },
'6': { color: '#FF8E13', bg: '#FFF2E4', result: '待提交' },
'4': { color: '#2F7BFF', bg: '#EAF2FF', result: '办理中' },
'1': { color: '#FF5A5A', bg: '#FFF1F0', result: '待办理' },
'3': { color: '#FF5A5A', bg: '#FFF1F0', result: '待办理' },
'2': { color: '#22C58B', bg: '#E8F9F2', result: '已完成' },
'5': { color: '#22C58B', bg: '#E8F9F2', result: '已完成' },
'7': { color: '#22C58B', bg: '#E8F9F2', result: '已完成' },
'8': { color: '#22C58B', bg: '#E8F9F2', result: '已完成' },
'6': { color: '#FF8E13', bg: '#FFF2E4', result: '待处理' },
}
export const PAY_ICON: Record<string, string> = {
@ -14,10 +16,42 @@ export const PAY_ICON: Record<string, string> = {
weixin: 'https://cdn.u8t.cn/frontend-static/corp-h5/static/images/weixin.png',
}
export const ORDER_TABS = [
{ key: 'all', label: '全部', statuses: null },
{ key: 'doing', label: '办理中', statuses: ['4'] },
{ key: 'rejected', label: '待处理', statuses: ['6'] },
{ key: 'waiting', label: '待办理', statuses: ['1', '3'] },
{ key: 'done', label: '已完成', statuses: ['2', '5', '7', '8'] },
] as const
export type OrderTabKey = (typeof ORDER_TABS)[number]['key']
const DONE_STATUSES = new Set(['2', '5', '7', '8'])
const SUBMIT_STATUSES = new Set(['4', '6'])
export function getStatusMeta (status?: string, name?: string) {
const config = STATUS_CONFIG[String(status || '')] || { color: '#666666', bg: '#F5F5F5', result: '未知' }
const config = STATUS_CONFIG[String(status || '')]
if (config) return { ...config }
return {
...config,
result: name || config.result,
color: '#666666',
bg: '#F5F5F5',
result: name || '未知',
}
}
export function filterOrdersByTab<T extends Record<string, any>> (orders: T[] = [], tab: OrderTabKey) {
const current = ORDER_TABS.find((item) => item.key === tab)
if (!current || !current.statuses) return orders
const statuses = new Set<string>(current.statuses)
return orders.filter((order) => statuses.has(String(order.process_status || '')))
}
export function getOrderCardActions (order: { process_status?: string, goods_type?: string }) {
const status = String(order.process_status || '')
const detail = DONE_STATUSES.has(status)
return {
complaint: !detail,
submit: SUBMIT_STATUSES.has(status) && !isCorpGoodsType(order.goods_type),
detail,
}
}

View File

@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '投诉',
navigationBarBackgroundColor: '#00C261',
navigationBarTextStyle: 'white',
backgroundColor: '#F6F8FC',
})

View File

@ -0,0 +1,284 @@
.complaint {
min-height: 100vh;
box-sizing: border-box;
padding-bottom: 180px;
background: #f6f8fc;
}
.complaint-hero {
padding: 8px 32px 56px;
background: #00c261;
}
.complaint-hero__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.complaint-hero__name {
flex: 1;
min-width: 0;
padding-right: 16px;
color: #fff;
font-size: 32px;
font-weight: 700;
line-height: 44px;
}
.complaint-hero__paid {
flex-shrink: 0;
color: #fff;
font-size: 26px;
line-height: 44px;
}
.complaint-hero__meta {
margin-top: 20px;
padding: 20px 24px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.16);
}
.complaint-hero__meta-row {
display: flex;
align-items: flex-start;
}
.complaint-hero__meta-row + .complaint-hero__meta-row {
margin-top: 12px;
}
.complaint-hero__meta-label {
width: 140px;
color: rgba(255, 255, 255, 0.85);
font-size: 24px;
line-height: 36px;
}
.complaint-hero__meta-value {
flex: 1;
min-width: 0;
color: #fff;
font-size: 24px;
line-height: 36px;
word-break: break-all;
}
.complaint-sheet {
margin-top: -32px;
padding: 32px 32px 0;
border-radius: 32px 32px 0 0;
background: #f6f8fc;
}
.complaint-field {
margin-bottom: 32px;
}
.complaint-label {
display: flex;
align-items: center;
margin-bottom: 16px;
color: #1d2129;
font-size: 30px;
font-weight: 700;
}
.complaint-label__required {
margin-left: 4px;
color: #f53f3f;
}
.complaint-label__extra {
margin-left: 8px;
color: #86909c;
font-size: 24px;
font-weight: 400;
}
.complaint-types {
display: flex;
flex-wrap: wrap;
padding: 8px 8px 0;
border-radius: 24px;
background: #fff;
}
.complaint-type {
box-sizing: border-box;
display: flex;
align-items: center;
width: calc(50% - 16px);
margin: 8px;
padding: 20px 16px;
border: 2px solid #e5e6eb;
border-radius: 16px;
background: #fff;
}
.complaint-type--active {
border-color: #165dff;
}
.complaint-type__icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-right: 8px;
border-radius: 22px;
background: #f2f3f5;
color: #4e5969;
font-size: 22px;
font-weight: 700;
}
.complaint-type--active .complaint-type__icon {
background: #e8f3ff;
color: #165dff;
}
.complaint-type__text {
flex: 1;
min-width: 0;
color: #1d2129;
font-size: 26px;
line-height: 36px;
}
.complaint-type--active .complaint-type__text {
color: #165dff;
}
.complaint-textarea {
position: relative;
box-sizing: border-box;
min-height: 280px;
padding: 20px 24px 48px;
border-radius: 20px;
background: #fff;
}
.complaint-textarea__input {
width: 100%;
height: 220px;
color: #1d2129;
font-size: 28px;
line-height: 40px;
}
.complaint-textarea__count {
position: absolute;
right: 24px;
bottom: 16px;
color: #c9cdd4;
font-size: 24px;
}
.complaint-placeholder {
color: #c9cdd4;
}
.complaint-uploads {
display: flex;
flex-wrap: wrap;
}
.complaint-upload {
position: relative;
width: 160px;
height: 160px;
margin: 0 16px 16px 0;
overflow: hidden;
border-radius: 16px;
background: #fff;
}
.complaint-upload--add {
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #e5e6eb;
}
.complaint-upload__image,
.complaint-upload__camera {
width: 100%;
height: 100%;
}
.complaint-upload__camera {
width: 56px;
height: 56px;
}
.complaint-upload__remove {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 18px;
background: rgba(29, 33, 41, 0.45);
}
.complaint-upload__remove-text {
color: #fff;
font-size: 28px;
line-height: 32px;
}
.complaint-contact {
display: flex;
align-items: center;
box-sizing: border-box;
height: 88px;
padding: 0 24px;
border-radius: 16px;
background: #fff;
}
.complaint-contact__input {
flex: 1;
min-width: 0;
height: 88px;
color: #1d2129;
font-size: 28px;
}
.complaint-contact__clear {
width: 36px;
height: 36px;
margin-left: 12px;
}
.complaint-footer {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
padding: 16px 32px calc(24px + env(safe-area-inset-bottom));
background: #f6f8fc;
}
.complaint-submit {
height: 88px;
margin: 0;
border: 0;
border-radius: 16px;
background: #165dff;
color: #fff;
font-size: 32px;
font-weight: 700;
line-height: 88px;
}
.complaint-submit::after {
border: 0;
}

View File

@ -0,0 +1,216 @@
import { Button, Image, Input, Text, Textarea, View } from '@tarojs/components'
import Taro, { useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { report, uploadImage } from '@/api/user'
import { LOCAL_IMAGES } from '@/lib/assets'
import { buildFeedbackPayload, COMPLAINT_TYPES } from '@/lib/feedback'
import { getOrderDraft } from '@/lib/orderDraft'
import type { OrderLike, UploadedImage } from '@/lib/order'
import './index.scss'
const TYPE_ICONS: Record<string, string> = {
: '¥',
7: '7',
: '¥',
: '服',
: '…',
}
export default function ComplaintPage () {
const [order, setOrder] = useState<OrderLike | null>(null)
const [type, setType] = useState('')
const [content, setContent] = useState('')
const [contact, setContact] = useState('')
const [images, setImages] = useState<UploadedImage[]>([])
const [uploading, setUploading] = useState(false)
const [submitting, setSubmitting] = useState(false)
useLoad(() => {
const draft = getOrderDraft()
if (!draft) {
Taro.showToast({ title: '订单不存在', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 400)
return
}
setOrder(draft)
setContact(String(draft.entity_phone || ''))
})
const handleChooseImage = async () => {
if (uploading || images.length >= 5) return
const res = await Taro.chooseImage({
count: Math.max(1, 5 - images.length),
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
})
const paths = res.tempFilePaths || []
if (!paths.length) return
setUploading(true)
try {
const uploaded: UploadedImage[] = []
for (const filePath of paths) {
const result = await uploadImage(filePath)
uploaded.push({ id: result.id, url: result.url, preview: filePath })
}
setImages((current) => [...current, ...uploaded].slice(0, 5))
} catch (error) {
Taro.showToast({
title: error instanceof Error ? error.message : '图片上传失败',
icon: 'none',
})
} finally {
setUploading(false)
}
}
const handleSubmit = async () => {
if (!order || submitting) return
if (!type) {
Taro.showToast({ title: '请选择投诉类型', icon: 'none' })
return
}
if (!content.trim()) {
Taro.showToast({ title: '请填写问题描述', icon: 'none' })
return
}
if (uploading) {
Taro.showToast({ title: '图片正在上传,请稍候', icon: 'none' })
return
}
setSubmitting(true)
try {
await report(buildFeedbackPayload({
contact: contact.trim(),
content: content.trim(),
images: images.map((image) => image.url),
type,
order,
}))
Taro.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500)
} catch {
Taro.showToast({ title: '提交失败,请稍后再试', icon: 'none' })
} finally {
setSubmitting(false)
}
}
return (
<View className='complaint'>
<View className='complaint-hero'>
<View className='complaint-hero__row'>
<Text className='complaint-hero__name'>{order?.goods_name || '-'}</Text>
<Text className='complaint-hero__paid'> {order?.total_fee || '-'}</Text>
</View>
<View className='complaint-hero__meta'>
<View className='complaint-hero__meta-row'>
<Text className='complaint-hero__meta-label'>ID</Text>
<Text className='complaint-hero__meta-value'>{order?.out_trade_no || '-'}</Text>
</View>
<View className='complaint-hero__meta-row'>
<Text className='complaint-hero__meta-label'></Text>
<Text className='complaint-hero__meta-value'>{order?.create_time || order?.pay_time || '-'}</Text>
</View>
</View>
</View>
<View className='complaint-sheet'>
<View className='complaint-field'>
<View className='complaint-label'>
<Text></Text>
<Text className='complaint-label__required'>*</Text>
</View>
<View className='complaint-types'>
{COMPLAINT_TYPES.map((item) => (
<View
key={item.value}
className={`complaint-type ${type === item.value ? 'complaint-type--active' : ''}`}
onClick={() => setType(item.value)}
>
<View className='complaint-type__icon'>
<Text>{TYPE_ICONS[item.value]}</Text>
</View>
<Text className='complaint-type__text'>{item.label}</Text>
</View>
))}
</View>
</View>
<View className='complaint-field'>
<View className='complaint-label'>
<Text></Text>
<Text className='complaint-label__required'>*</Text>
</View>
<View className='complaint-textarea'>
<Textarea
className='complaint-textarea__input'
maxlength={200}
placeholder='请描述您遇到的问题'
placeholderClass='complaint-placeholder'
value={content}
onInput={(event) => setContent(event.detail.value.slice(0, 200))}
/>
<Text className='complaint-textarea__count'>{content.length}/200</Text>
</View>
</View>
<View className='complaint-field'>
<View className='complaint-label'>
<Text></Text>
<Text className='complaint-label__extra'>5</Text>
</View>
<View className='complaint-uploads'>
{images.map((image, index) => (
<View className='complaint-upload' key={`${image.id}-${index}`}>
<Image className='complaint-upload__image' src={image.preview || image.url} mode='aspectFill' />
<View
className='complaint-upload__remove'
onClick={() => setImages((current) => current.filter((_, itemIndex) => itemIndex !== index))}
>
<Text className='complaint-upload__remove-text'>×</Text>
</View>
</View>
))}
{images.length < 5 ? (
<View className='complaint-upload complaint-upload--add' onClick={() => void handleChooseImage()}>
<Image className='complaint-upload__camera' src={LOCAL_IMAGES.camera} />
</View>
) : null}
</View>
</View>
<View className='complaint-field'>
<View className='complaint-label'>
<Text></Text>
<Text className='complaint-label__extra'></Text>
</View>
<View className='complaint-contact'>
<Input
className='complaint-contact__input'
value={contact}
placeholder='请输入联系方式'
placeholderClass='complaint-placeholder'
onInput={(event) => setContact(event.detail.value)}
/>
{contact ? (
<Image className='complaint-contact__clear' src={LOCAL_IMAGES.clear} onClick={() => setContact('')} />
) : null}
</View>
</View>
</View>
<View className='complaint-footer'>
<Button
className='complaint-submit'
loading={submitting}
disabled={uploading}
onClick={() => void handleSubmit()}
>
</Button>
</View>
</View>
)
}

View File

@ -1,7 +1,7 @@
import { Image, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { CORP_ASSET_BASE, STATIC_CDN } from '@/lib/cdn'
import { pickServiceContact, type ServiceContact } from '@/lib/contact'
import { CORP_ASSET_BASE } from '@/lib/cdn'
import type { ServiceContact } from '@/lib/contact'
type Props = {
open: boolean
@ -91,11 +91,11 @@ export default function ContactModal ({ open, contact, onCancel }: Props) {
)
}
export function ContactEntry ({ onClick }: { onClick: () => void }) {
export function ContactFab ({ onClick }: { onClick: () => void }) {
return (
<View className='contact-entry' onClick={onClick}>
<Image className='contact-entry__icon' src={`${STATIC_CDN}/images/icon-contract.png`} />
<Text className='contact-entry__text'></Text>
<View className='contact-fab' onClick={onClick}>
<Image className='contact-fab__icon' src={`${CORP_ASSET_BASE}/images-pc/icon-contactus-service.png`} />
<Text className='contact-fab__text'></Text>
</View>
)
}

View File

@ -1,5 +1,6 @@
import { Image, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { LOCAL_IMAGES } from '@/lib/assets'
import { RECEIPT_STAMP_DONE, RECEIPT_STAMP_PENDING } from '@/lib/cdn'
import {
getReceiptMeta,
@ -15,7 +16,7 @@ type Props = {
onCancel: () => void
}
export default function DetailModal ({ order, onCancel }: Props) {
export default function DetailDrawer ({ order, onCancel }: Props) {
const showReceipt = shouldShowReceipt(order.goods_type)
const receipt = getReceiptMeta(order.goods_type, order.goods_name)
const files = order.process_status === '2' ? splitOrderFiles(order.files) : { images: [], pdfs: [] }
@ -24,11 +25,12 @@ export default function DetailModal ({ order, onCancel }: Props) {
return (
<View className='detail-mask' onClick={onCancel}>
<View className='detail-dialog' onClick={(event) => event.stopPropagation()}>
<View className='detail-drawer' onClick={(event) => event.stopPropagation()}>
<View className='detail-drawer__handle' />
<View className='detail-dialog__head'>
<Text className='detail-dialog__title'></Text>
<View className='detail-dialog__close' onClick={onCancel}>
<Text className='detail-dialog__close-icon'>×</Text>
<Image className='detail-dialog__close-img' src={LOCAL_IMAGES.close} />
</View>
</View>

View File

@ -1,5 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '网上快办平台',
navigationBarBackgroundColor: '#2F4FEE',
navigationBarTextStyle: 'white',
navigationStyle: 'custom',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#F6F8FC',
backgroundColor: '#F6F8FC',
backgroundColorTop: '#F6F8FC',
})

View File

@ -1,228 +1,193 @@
.check-page {
.home {
min-height: 100vh;
box-sizing: border-box;
padding-bottom: 48px;
background: #2f4fee;
background: #f6f8fc;
}
.check-hero {
position: relative;
.home-nav {
display: flex;
align-items: center;
justify-content: center;
height: 420px;
padding-left: 32px;
}
.check-hero__bg {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
.home-nav__logo {
width: 48px;
height: 48px;
margin-right: 12px;
}
.check-hero__title {
position: relative;
z-index: 1;
color: #fff;
font-size: 72px;
.home-nav__title {
color: #165dff;
font-size: 36px;
font-weight: 700;
letter-spacing: 6px;
line-height: 96px;
text-align: center;
text-shadow: 0 4px 8px #1840ff;
line-height: 50px;
}
.check-card {
position: relative;
z-index: 1;
margin: 0 24px 48px;
}
.check-card--lift {
margin-top: -80px;
}
.check-card__header {
position: relative;
height: 154px;
}
.check-card__header-bg {
display: block;
width: 100%;
height: 154px;
}
.check-card__header-title {
position: absolute;
right: 0;
bottom: 28px;
left: 16px;
color: #fff;
font-size: 50px;
font-weight: 700;
line-height: 60px;
text-align: center;
text-shadow: 0 8px 0 #0026b3;
}
.check-card__shell {
padding: 0 16px 16px;
border-radius: 0 0 28px 28px;
background: #cbdeff;
}
.check-card__body,
.check-card__list {
padding: 40px 24px 40px;
border-radius: 0 0 28px 28px;
.home-search {
display: flex;
align-items: center;
margin: 16px 32px 8px;
padding: 8px 8px 8px 24px;
border-radius: 20px;
background: #fff;
box-shadow: 0 8px 24px rgba(22, 93, 255, 0.06);
}
.check-card__note {
display: block;
margin-bottom: 24px;
color: #ff2626;
font-size: 24px;
line-height: 32px;
}
.check-card__label {
display: flex;
align-items: center;
margin-bottom: 16px;
color: #666;
font-size: 28px;
line-height: 40px;
}
.check-card__label-icon {
width: 40px;
height: 40px;
margin-right: 8px;
}
.check-card__required {
margin-left: 4px;
color: #ff2f2f;
}
.check-card__input {
box-sizing: border-box;
height: 88px;
padding: 0 24px;
border: 1px solid #ddd;
border-radius: 8px;
color: #222;
font-size: 28px;
}
.check-card__button {
margin-top: 32px;
height: 88px;
border: 0;
border-radius: 8px;
background: #0261fc;
color: #fff;
font-size: 30px;
font-weight: 700;
line-height: 88px;
}
.check-card__button::after {
border: 0;
}
.check-card__empty {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 28px;
}
.order-card {
padding: 24px 0 32px;
border-top: 1px solid #eee;
}
.order-card--first {
padding-top: 0;
border-top: 0;
}
.order-card__head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
}
.order-card__goods {
.home-search__field {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
}
.order-card__goods-icon {
width: 40px;
height: 40px;
margin-right: -8px;
border-radius: 20px;
.home-search__icon {
width: 36px;
height: 36px;
margin-right: 12px;
flex-shrink: 0;
}
.home-search__input {
flex: 1;
min-width: 0;
height: 72px;
color: #1d2129;
font-size: 28px;
}
.home-search__placeholder {
color: #c9cdd4;
}
.home-search__button {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 128px;
height: 72px;
margin: 0;
padding: 0;
border: 0;
border-radius: 16px;
background: #165dff;
color: #fff;
font-size: 28px;
font-weight: 600;
line-height: 1.2;
}
.home-search__button::after {
border: 0;
}
.home-tabs {
display: flex;
align-items: flex-end;
padding: 16px 12px 0;
}
.home-tabs__item {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 16px;
}
.home-tabs__label {
color: #86909c;
font-size: 28px;
line-height: 40px;
}
.home-tabs__item--active .home-tabs__label {
color: #165dff;
font-weight: 700;
}
.home-tabs__line {
position: absolute;
bottom: 0;
width: 48px;
height: 6px;
border-radius: 6px;
background: #165dff;
}
.home-list {
padding: 16px 24px 160px;
}
.home-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 160px 48px 0;
}
.home-empty__image {
width: 360px;
height: 280px;
}
.home-empty__text {
margin-top: 24px;
color: #86909c;
font-size: 28px;
line-height: 40px;
text-align: center;
}
.order-card {
margin-bottom: 24px;
padding: 32px 28px 28px;
border-radius: 24px;
background: #fff;
}
.order-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.order-card__goods-name {
margin-left: 16px;
color: #222;
font-size: 28px;
font-weight: 500;
flex: 1;
min-width: 0;
padding-right: 16px;
color: #1d2129;
font-size: 32px;
font-weight: 700;
line-height: 44px;
}
.order-card__status {
flex-shrink: 0;
min-width: 102px;
height: 44px;
padding: 0 12px;
border-radius: 8px;
font-size: 24px;
font-size: 26px;
line-height: 44px;
text-align: center;
}
.order-card__reject {
margin-bottom: 16px;
padding: 16px;
border-radius: 8px;
background: #fff3f3;
color: #ff0707;
font-size: 24px;
line-height: 36px;
}
.order-card__row {
display: flex;
align-items: flex-start;
margin-top: 16px;
font-size: 28px;
margin-top: 12px;
font-size: 26px;
line-height: 36px;
}
.order-card__label {
flex-shrink: 0;
color: #666;
color: #86909c;
}
.order-card__value {
flex: 1;
min-width: 0;
color: #222;
color: #4e5969;
word-break: break-all;
}
@ -239,17 +204,27 @@
}
.order-card__pay-icon {
width: 40px;
height: 40px;
width: 36px;
height: 36px;
margin-right: 8px;
}
.order-card__fee {
color: #ff2f2f;
font-size: 30px;
color: #f53f3f;
font-size: 28px;
font-weight: 700;
}
.order-card__reject {
margin-top: 20px;
padding: 16px 20px;
border-radius: 12px;
background: #fff1f0;
color: #f53f3f;
font-size: 24px;
line-height: 36px;
}
.order-card__actions {
display: flex;
justify-content: flex-end;
@ -258,39 +233,49 @@
.order-card__action {
min-width: 160px;
height: 64px;
height: 72px;
margin-left: 16px;
border-radius: 12px;
background: #eaf3ff;
color: #0261fc;
padding: 0 28px;
border-radius: 16px;
background: #f2f3f5;
color: #4e5969;
font-size: 28px;
line-height: 64px;
line-height: 72px;
text-align: center;
}
.detail-mask,
.resubmit-mask {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.8);
.order-card__action--primary {
background: #165dff;
color: #fff;
font-weight: 600;
}
.detail-mask {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
align-items: flex-end;
background: rgba(0, 0, 0, 0.45);
}
.detail-dialog {
.detail-drawer {
box-sizing: border-box;
width: 686px;
max-height: 80vh;
width: 100%;
max-height: 82vh;
overflow: hidden;
border-radius: 20px;
border-radius: 24px 24px 0 0;
background: #fff;
}
.detail-drawer__handle {
width: 64px;
height: 8px;
margin: 16px auto 0;
border-radius: 8px;
background: #e5e6eb;
}
.detail-dialog__head {
position: relative;
display: flex;
@ -300,7 +285,7 @@
}
.detail-dialog__title {
color: #222;
color: #1d2129;
font-size: 34px;
font-weight: 700;
line-height: 44px;
@ -317,15 +302,14 @@
height: 48px;
}
.detail-dialog__close-icon {
color: #999;
font-size: 48px;
line-height: 40px;
.detail-dialog__close-img {
width: 28px;
height: 28px;
}
.detail-dialog__body {
box-sizing: border-box;
max-height: calc(80vh - 88px);
max-height: calc(82vh - 112px);
padding: 0 32px 40px;
overflow-y: auto;
}
@ -478,220 +462,33 @@
white-space: pre-wrap;
}
.resubmit-mask {
display: flex;
align-items: flex-end;
}
.resubmit {
position: relative;
.contact-fab {
position: fixed;
right: 32px;
bottom: 80px;
z-index: 15;
box-sizing: border-box;
width: 100%;
max-height: 84vh;
padding: 32px 24px 40px;
overflow-y: auto;
border-radius: 24px 24px 0 0;
background: linear-gradient(180deg, #c5eeff 0, #fff 136px) #fff;
}
.resubmit__close {
position: absolute;
top: 24px;
right: 24px;
width: 40px;
height: 40px;
}
.resubmit__close-text {
color: #b3b3b3;
font-size: 48px;
line-height: 40px;
}
.resubmit__title {
display: block;
margin-bottom: 24px;
color: #222;
font-size: 34px;
font-weight: 500;
text-align: center;
}
.resubmit__reject {
margin-bottom: 16px;
padding: 20px 24px;
border-radius: 12px;
background: #fff1f1;
color: #ff0707;
font-size: 24px;
line-height: 36px;
}
.resubmit__field {
margin-bottom: 24px;
}
.resubmit__label {
display: flex;
align-items: center;
margin-bottom: 12px;
color: #3d4044;
font-size: 28px;
}
.resubmit__label-icon {
width: 40px;
height: 40px;
margin-right: 8px;
}
.resubmit__required {
margin-left: 4px;
color: #ff2f2f;
}
.resubmit__input,
.resubmit__map-input {
box-sizing: border-box;
height: 88px;
padding: 0 18px;
border: 1px solid #ddd;
border-radius: 12px;
color: #222;
font-size: 28px;
}
.resubmit__map-input {
display: flex;
align-items: center;
justify-content: space-between;
}
.resubmit__map-value {
flex: 1;
color: #222;
}
.resubmit__map-placeholder {
flex: 1;
color: #b3b3b3;
}
.resubmit__map-arrow {
width: 40px;
height: 40px;
}
.resubmit__tip {
display: block;
margin-bottom: 24px;
color: #999;
font-size: 24px;
line-height: 36px;
}
.resubmit__upload-list {
display: flex;
flex-wrap: wrap;
}
.resubmit__upload-item,
.resubmit__upload-slot {
position: relative;
width: 200px;
height: 200px;
margin: 0 16px 16px 0;
overflow: hidden;
border-radius: 12px;
background: #f7f8fa;
}
.resubmit__upload-image {
width: 100%;
height: 100%;
}
.resubmit__upload-remove {
position: absolute;
top: 8px;
right: 8px;
width: 36px;
height: 36px;
border-radius: 18px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 28px;
line-height: 36px;
text-align: center;
}
.resubmit__upload-slot {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px dashed #cfd6de;
width: 120px;
height: 120px;
border-radius: 60px;
background: #fff;
box-shadow: 0 8px 24px rgba(22, 93, 255, 0.16);
}
.resubmit__upload-plus {
width: 56px;
height: 56px;
.contact-fab__icon {
width: 48px;
height: 48px;
}
.resubmit__upload-text {
margin-top: 12px;
color: #999;
.contact-fab__text {
margin-top: 4px;
color: #165dff;
font-size: 22px;
}
.resubmit__error {
display: block;
margin-bottom: 16px;
color: #ff2f2f;
font-size: 24px;
}
.resubmit__submit {
height: 88px;
border: 0;
border-radius: 12px;
background: #0261fc;
color: #fff;
font-size: 32px;
font-weight: 700;
line-height: 88px;
}
.resubmit__submit::after {
border: 0;
}
.contact-entry {
position: fixed;
top: 24px;
right: 0;
z-index: 15;
box-sizing: border-box;
display: flex;
align-items: center;
width: 204px;
height: 60px;
padding-left: 20px;
border-radius: 268px 0 0 268px;
background: rgba(0, 0, 0, 0.5);
}
.contact-entry__icon {
width: 40px;
height: 40px;
margin-right: 4px;
flex-shrink: 0;
}
.contact-entry__text {
color: #fff;
font-size: 30px;
line-height: 44px;
line-height: 32px;
}
.contact-mask {

View File

@ -1,41 +1,43 @@
import { Button, Image, Input, Text, View } from '@tarojs/components'
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { getOrdersByPhone } from '@/api/pay'
import { LOCAL_IMAGES } from '@/lib/assets'
import { bootstrapSession } from '@/lib/bootstrap'
import { getSession } from '@/lib/session'
import { BANNER_BG, CARD_HEADER, CORP_ASSET_BASE, ENTITY_LABELS, HOME_ICON_BASE, PAGE_TITLE } from '@/lib/cdn'
import { COPY_ICON, PAGE_TITLE } from '@/lib/cdn'
import { getNavMetrics } from '@/lib/nav'
import { pickServiceContact, type ServiceContact } from '@/lib/contact'
import { isCorpGoodsType } from '@/lib/detail'
import {
filterOrdersForProduct,
getGoodsIcons,
getOrderAddressName,
parseCoordinate,
type OrderLike,
type ResubmitValues,
} from '@/lib/order'
import { setOrderDraft } from '@/lib/orderDraft'
import { filterOrdersForProduct, type OrderLike } from '@/lib/order'
import { isValidPhone } from '@/lib/phone'
import { getStatusMeta, PAY_ICON } from '@/lib/status'
import { consumeMapPickerResult, type MapPickerResult } from '@/pages/map/picker'
import ContactModal, { ContactEntry } from './contact'
import DetailModal from './detail'
import ResubmitDrawer from './resubmit'
import { getSession } from '@/lib/session'
import {
filterOrdersByTab,
getOrderCardActions,
getStatusMeta,
ORDER_TABS,
PAY_ICON,
type OrderTabKey,
} from '@/lib/status'
import ContactModal, { ContactFab } from './contact'
import DetailDrawer from './detail'
import './index.scss'
const COPY_ICON = `${CORP_ASSET_BASE}/images-mobile/icon-copy.png`
export default function Index () {
const nav = useMemo(() => getNavMetrics(), [])
const [ready, setReady] = useState(false)
const [inputPhone, setInputPhone] = useState('')
const [checking, setChecking] = useState(false)
const [searched, setSearched] = useState(false)
const [activeTab, setActiveTab] = useState<OrderTabKey>('all')
const [orders, setOrders] = useState<OrderLike[]>([])
const [detailOrder, setDetailOrder] = useState<OrderLike | null>(null)
const [resubmitOrder, setResubmitOrder] = useState<OrderLike | null>(null)
const [mapResult, setMapResult] = useState<MapPickerResult | null>(null)
const [showContact, setShowContact] = useState(false)
const [contact, setContact] = useState<ServiceContact>({ online: '', phone: '', weixin: '' })
const inputPhoneRef = useRef(inputPhone)
const searchedRef = useRef(searched)
inputPhoneRef.current = inputPhone
searchedRef.current = searched
useLoad(async (options) => {
try {
@ -51,13 +53,6 @@ export default function Index () {
}
})
useDidShow(() => {
const result = consumeMapPickerResult()
if (result) {
setMapResult(result)
}
})
const queryOrders = async (phone: string) => {
if (!phone) {
Taro.showToast({ title: '请输入联系电话', icon: 'none' })
@ -74,9 +69,6 @@ export default function Index () {
const nextOrders = filterOrdersForProduct(getSession().goodsType, res.data || [])
setOrders(nextOrders)
setSearched(true)
if (!nextOrders.length) {
Taro.showToast({ title: '无记录', icon: 'none' })
}
} catch {
Taro.showToast({ title: '当前无法查询,请稍后再试', icon: 'none' })
} finally {
@ -84,95 +76,92 @@ export default function Index () {
}
}
const handleChooseMap = (draft: ResubmitValues) => {
const point = parseCoordinate(draft.entity_address)
const query = [
point ? `lng=${point.lng}` : '',
point ? `lat=${point.lat}` : '',
draft.entity_address_name ? `name=${encodeURIComponent(draft.entity_address_name)}` : '',
].filter(Boolean).join('&')
useDidShow(() => {
if (searchedRef.current && inputPhoneRef.current) {
void queryOrders(inputPhoneRef.current)
}
})
Taro.navigateTo({
url: query ? `/pages/map/index?${query}` : '/pages/map/index',
})
const visibleOrders = filterOrdersByTab(orders, activeTab)
const emptyText = searched && orders.length === 0
? '该手机号未查询到订单,请联系客服处理'
: '暂无订单列表'
const openOrderPage = (path: string, order: OrderLike) => {
setOrderDraft(order)
Taro.navigateTo({ url: path })
}
return (
<View className='check-page'>
<View className='check-hero'>
<Image className='check-hero__bg' src={BANNER_BG} mode='aspectFill' />
<Text className='check-hero__title'>{PAGE_TITLE}</Text>
<View className='home' style={{ paddingTop: nav.statusBarHeight }}>
<View className='home-nav' style={{ height: nav.navBarHeight, paddingRight: nav.paddingRight }}>
<Image className='home-nav__logo' src={LOCAL_IMAGES.logo} />
<Text className='home-nav__title'>{PAGE_TITLE}</Text>
</View>
<ContactEntry onClick={() => setShowContact(true)} />
<View className='check-card check-card--lift'>
<View className='check-card__header'>
<Image className='check-card__header-bg' src={CARD_HEADER} mode='scaleToFill' />
<Text className='check-card__header-title'></Text>
<View className='home-search'>
<View className='home-search__field'>
<Image className='home-search__icon' src={LOCAL_IMAGES.search} />
<Input
className='home-search__input'
type='number'
maxlength={12}
placeholder='请输入手机号查询订单'
placeholderClass='home-search__placeholder'
value={inputPhone}
onInput={(event) => setInputPhone(event.detail.value.replace(/\D/g, ''))}
/>
</View>
<View className='check-card__shell'>
<View className='check-card__body'>
<Text className='check-card__note'></Text>
<View className='check-card__label'>
<Image className='check-card__label-icon' src={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} />
<Text></Text>
<Text className='check-card__required'>*</Text>
</View>
<Input
className='check-card__input'
type='number'
maxlength={12}
placeholder='请输入联系电话'
value={inputPhone}
onInput={(event) => setInputPhone(event.detail.value.replace(/\D/g, ''))}
<Button
className='home-search__button'
loading={checking}
disabled={!ready || checking}
onClick={() => void queryOrders(inputPhone)}
>
</Button>
</View>
<View className='home-tabs'>
{ORDER_TABS.map((tab) => (
<View
key={tab.key}
className={`home-tabs__item ${activeTab === tab.key ? 'home-tabs__item--active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
<Text className='home-tabs__label'>{tab.label}</Text>
{activeTab === tab.key ? <View className='home-tabs__line' /> : null}
</View>
))}
</View>
{visibleOrders.length ? (
<View className='home-list'>
{visibleOrders.map((order, index) => (
<OrderCard
key={order.order_id || order.out_trade_no || index}
order={order}
onCopy={() => {
if (!order.out_trade_no) return
Taro.setClipboardData({ data: String(order.out_trade_no) })
}}
onViewDetail={() => setDetailOrder(order)}
onResubmit={() => openOrderPage('/pages/resubmit/index', order)}
onComplaint={() => openOrderPage('/pages/complaint/index', order)}
/>
<Button
className='check-card__button'
loading={checking}
disabled={!ready || checking}
onClick={() => void queryOrders(inputPhone)}
>
</Button>
</View>
))}
</View>
</View>
) : (
<View className='home-empty'>
<Image className='home-empty__image' src={LOCAL_IMAGES.empty} mode='aspectFit' />
<Text className='home-empty__text'>{emptyText}</Text>
</View>
)}
{searched ? (
<View className='check-card'>
<View className='check-card__header'>
<Image className='check-card__header-bg' src={CARD_HEADER} mode='scaleToFill' />
<Text className='check-card__header-title'></Text>
</View>
<View className='check-card__shell'>
<View className='check-card__list'>
{orders.length ? orders.map((order, index) => (
<OrderCard
key={order.order_id || order.out_trade_no || index}
order={order}
first={index === 0}
onCopy={() => {
if (!order.out_trade_no) return
Taro.setClipboardData({ data: String(order.out_trade_no) })
}}
onViewDetail={() => setDetailOrder(order)}
onResubmit={() => {
setMapResult(null)
setResubmitOrder(order)
}}
/>
)) : (
<View className='check-card__empty'>
<Text></Text>
</View>
)}
</View>
</View>
</View>
) : null}
<ContactFab onClick={() => setShowContact(true)} />
{detailOrder ? (
<DetailModal order={detailOrder} onCancel={() => setDetailOrder(null)} />
<DetailDrawer order={detailOrder} onCancel={() => setDetailOrder(null)} />
) : null}
<ContactModal
@ -180,79 +169,35 @@ export default function Index () {
contact={contact}
onCancel={() => setShowContact(false)}
/>
<ResubmitDrawer
open={Boolean(resubmitOrder)}
order={resubmitOrder}
mapResult={mapResult}
onCancel={() => {
setResubmitOrder(null)
setMapResult(null)
}}
onChooseMap={handleChooseMap}
onSuccess={() => {
setResubmitOrder(null)
setMapResult(null)
void queryOrders(inputPhone)
}}
/>
</View>
)
}
function OrderCard ({
order,
first,
onCopy,
onViewDetail,
onResubmit,
onComplaint,
}: {
order: OrderLike
first: boolean
onCopy: () => void
onViewDetail: () => void
onResubmit: () => void
onComplaint: () => void
}) {
const status = getStatusMeta(order.process_status, order.process_status_name)
const goodsIcons = getGoodsIcons(order)
const showDetail = order.process_status === '2'
const showResubmit = !isCorpGoodsType(order.goods_type)
const actions = getOrderCardActions(order)
const payIcon = PAY_ICON[order.pay_type]
const isCorpOrder = isCorpGoodsType(order.goods_type)
return (
<View className={`order-card ${first ? 'order-card--first' : ''}`}>
<View className='order-card'>
<View className='order-card__head'>
<View className='order-card__goods'>
{goodsIcons.map((icon: string, index: number) => (
<Image className='order-card__goods-icon' key={`${icon}-${index}`} src={icon} />
))}
<Text className='order-card__goods-name'>{order.goods_name || '-'}</Text>
</View>
<View className='order-card__status' style={{ color: status.color, background: status.bg }}>
<Text>{status.result}</Text>
</View>
<Text className='order-card__goods-name'>{order.goods_name || '-'}</Text>
<Text className='order-card__status' style={{ color: status.color }}>{status.result}</Text>
</View>
{order.reject_reason ? (
<View className='order-card__reject'>{order.reject_reason}</View>
) : null}
{isCorpOrder ? (
<>
<InfoLine label='企业名称:' value={order.entity_name || '-'} />
<InfoLine label='法人:' value={order.entity_legal || '-'} />
<InfoLine label='统一社会信用代码:' value={order.entity_id || '-'} />
<InfoLine label='联系电话:' value={order.entity_phone || '-'} />
</>
) : (
<>
<InfoLine label={`${ENTITY_LABELS.entityName}`} value={order.entity_name || '-'} />
<InfoLine label='联系电话:' value={order.entity_phone || '-'} />
<InfoLine label={`${ENTITY_LABELS.entityAddress}`} value={getOrderAddressName(order) || '-'} />
<InfoLine label={`${ENTITY_LABELS.entityMapLocation}`} value={order.extra?.entity_address || '-'} />
</>
)}
<InfoLine label='联系电话:' value={order.entity_phone || '-'} />
<InfoLine label='订单时间:' value={order.create_time || order.pay_time || '-'} />
<View className='order-card__row'>
@ -269,14 +214,23 @@ function OrderCard ({
<Text className='order-card__fee'>{order.total_fee || '-'}</Text>
</View>
<View className='order-card__actions'>
{showDetail ? (
<View className='order-card__action' onClick={onViewDetail}></View>
) : null}
{showResubmit ? (
<View className='order-card__action' onClick={onResubmit}></View>
) : null}
</View>
{order.reject_reason ? (
<View className='order-card__reject'>{order.reject_reason}</View>
) : null}
{actions.complaint || actions.submit || actions.detail ? (
<View className='order-card__actions'>
{actions.complaint ? (
<View className='order-card__action' onClick={onComplaint}></View>
) : null}
{actions.detail ? (
<View className='order-card__action' onClick={onViewDetail}></View>
) : null}
{actions.submit ? (
<View className='order-card__action order-card__action--primary' onClick={onResubmit}></View>
) : null}
</View>
) : null}
</View>
)
}

View File

@ -1,331 +0,0 @@
import { Button, Image, Input, Text, View } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { useEffect, useState } from 'react'
import { uploadImage } from '@/api/user'
import { updateOrderExtra } from '@/api/pay'
import { ENTITY_LABELS, HOME_ICON_BASE, MAP_ASSET_BASE } from '@/lib/cdn'
import { isValidPhone } from '@/lib/phone'
import {
buildResubmitPayload,
formatMapCoordinate,
getInitialPhone2,
getOrderAddressName,
getOrderExtraImages,
getOrderMapAddress,
parseCoordinate,
type OrderLike,
type ResubmitValues,
type UploadedImage,
} from '@/lib/order'
import type { MapPickerResult } from '@/pages/map/picker'
type Props = {
open: boolean
order: OrderLike | null
mapResult?: MapPickerResult | null
onCancel: () => void
onChooseMap: (draft: ResubmitValues) => void
onSuccess: () => void
}
function FieldLabel ({ icon, text, required }: { icon: string, text: string, required?: boolean }) {
return (
<View className='resubmit__label'>
<Image className='resubmit__label-icon' src={icon} />
<Text>{text}</Text>
{required ? <Text className='resubmit__required'>*</Text> : null}
</View>
)
}
function UploadField ({
label,
values,
multiple = false,
uploading,
progress,
onChange,
onRemove,
}: {
label: string
values: UploadedImage[]
multiple?: boolean
uploading: boolean
progress: number
onChange: (filePaths: string[]) => void
onRemove: (index: number) => void
}) {
const showUpload = multiple || values.length === 0
const handleChoose = async () => {
const res = await Taro.chooseImage({
count: multiple ? 6 : 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
})
if (res.tempFilePaths?.length) {
onChange(res.tempFilePaths)
}
}
return (
<View className='resubmit__upload-list'>
{values.map((value, index) => (
<View className='resubmit__upload-item' key={`${value.id}-${index}`}>
<Image className='resubmit__upload-image' src={value.preview || value.url} mode='aspectFill' />
<View className='resubmit__upload-remove' onClick={() => onRemove(index)}>
<Text>×</Text>
</View>
</View>
))}
{showUpload ? (
<View className='resubmit__upload-slot' onClick={() => void handleChoose()}>
<Image className='resubmit__upload-plus' src={`${MAP_ASSET_BASE}/pay-success-upload.png`} />
<Text className='resubmit__upload-text'>
{uploading ? `上传中 ${Math.round(progress)}%` : (multiple && values.length > 0 ? '继续上传' : `请上传${label}`)}
</Text>
</View>
) : null}
</View>
)
}
export default function ResubmitDrawer ({
open,
order,
mapResult,
onCancel,
onChooseMap,
onSuccess,
}: Props) {
const [values, setValues] = useState<ResubmitValues>({})
const [storefrontImages, setStorefrontImages] = useState<UploadedImage[]>([])
const [licenseImages, setLicenseImages] = useState<UploadedImage[]>([])
const [uploadingField, setUploadingField] = useState<'storefront' | 'license' | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [formError, setFormError] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!order || !open) return
const mapAddress = mapResult ? formatMapCoordinate(mapResult) : ''
const mapAddressName = mapResult?.address || mapResult?.name || ''
setValues({
entity_name: values.entity_name || order.entity_name || mapResult?.name || '',
entity_address_name: mapResult ? mapAddressName || getOrderAddressName(order) : values.entity_address_name || getOrderAddressName(order),
entity_address: mapResult ? mapAddress : values.entity_address || getOrderMapAddress(order),
entity_phone: values.entity_phone || order.entity_phone || '',
entity_phone2: values.entity_phone2 || getInitialPhone2(order),
})
if (storefrontImages.length === 0) {
setStorefrontImages(getOrderExtraImages(order, ['entity_storefront_image', 'storefront_image']))
}
if (licenseImages.length === 0) {
setLicenseImages(getOrderExtraImages(order, ['entity_business_license_image', 'business_license_image']).slice(0, 1))
}
setFormError('')
// Only seed from the current order / returned map point.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, order, mapResult])
const updateField = <K extends keyof ResubmitValues>(field: K, value: ResubmitValues[K]) => {
setValues((current) => ({ ...current, [field]: value }))
}
const handleUpload = async (field: 'storefront' | 'license', filePaths: string[]) => {
setUploadingField(field)
setUploadProgress(0)
setFormError('')
try {
const images: UploadedImage[] = []
for (const [index, filePath] of filePaths.entries()) {
const uploaded = await uploadImage(filePath, (percent) => {
setUploadProgress(((index + percent / 100) / filePaths.length) * 100)
})
images.push({
id: uploaded.id,
url: uploaded.url,
preview: filePath,
})
}
if (field === 'storefront') {
setStorefrontImages((current) => [...current, ...images])
} else {
setLicenseImages(images.slice(0, 1))
}
} catch (error) {
setFormError(error instanceof Error ? error.message : '图片上传失败,请稍后重试')
} finally {
setUploadingField(null)
setUploadProgress(0)
}
}
const handleSubmit = async () => {
if (!order || submitting) return
if (uploadingField) {
setFormError('图片正在上传,请稍候')
return
}
if (!values.entity_name?.trim()) {
Taro.showToast({ title: `请输入${ENTITY_LABELS.entityName}`, icon: 'none' })
return
}
if (!values.entity_address_name?.trim()) {
Taro.showToast({ title: `请输入${ENTITY_LABELS.entityAddress}`, icon: 'none' })
return
}
if (!values.entity_address?.trim()) {
Taro.showToast({ title: `请选择${ENTITY_LABELS.entityMapLocation}`, icon: 'none' })
return
}
if (!isValidPhone(values.entity_phone || '')) {
Taro.showToast({ title: '请填写正确的联系电话1', icon: 'none' })
return
}
if (!isValidPhone(values.entity_phone2 || '')) {
Taro.showToast({ title: '请填写正确的联系电话2', icon: 'none' })
return
}
if (storefrontImages.length === 0) {
Taro.showToast({ title: '请上传门头照片', icon: 'none' })
return
}
setSubmitting(true)
setFormError('')
try {
await updateOrderExtra(buildResubmitPayload({
order,
values,
storefrontImages,
licenseImages,
}))
Taro.showToast({ title: '重新提交成功', icon: 'success' })
onSuccess()
} catch {
Taro.showToast({ title: '重新提交失败,请稍后再试', icon: 'none' })
} finally {
setSubmitting(false)
}
}
if (!open || !order) return null
const mapPoint = parseCoordinate(values.entity_address)
return (
<View className='resubmit-mask'>
<View className='resubmit'>
<View className='resubmit__close' onClick={onCancel}>
<Text className='resubmit__close-text'>×</Text>
</View>
<Text className='resubmit__title'></Text>
{order.reject_reason ? (
<View className='resubmit__reject'>{order.reject_reason}</View>
) : null}
<View className='resubmit__form'>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_marker@2x.png`} text={ENTITY_LABELS.entityName} required />
<Input
className='resubmit__input'
value={values.entity_name}
placeholder='请输入店铺/公司名称'
onInput={(event) => updateField('entity_name', event.detail.value)}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location@2x.png`} text={ENTITY_LABELS.entityAddress} required />
<Input
className='resubmit__input'
value={values.entity_address_name}
placeholder='请输入实际经营地址'
onInput={(event) => updateField('entity_address_name', event.detail.value)}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location_lnglat@2x.png`} text={ENTITY_LABELS.entityMapLocation} required />
<View
className='resubmit__map-input'
onClick={() => onChooseMap({
...values,
entity_address: mapPoint ? formatMapCoordinate(mapPoint) : values.entity_address,
})}
>
<Text className={values.entity_address ? 'resubmit__map-value' : 'resubmit__map-placeholder'}>
{values.entity_address || '请选择地图位置'}
</Text>
<Image className='resubmit__map-arrow' src={`${MAP_ASSET_BASE}/icon_input_arrow@2x.png`} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='联系电话1' required />
<Input
className='resubmit__input'
type='number'
maxlength={12}
value={values.entity_phone}
placeholder='请输入'
onInput={(event) => updateField('entity_phone', event.detail.value.replace(/\D/g, ''))}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='联系电话2' required />
<Input
className='resubmit__input'
type='number'
maxlength={12}
value={values.entity_phone2}
placeholder='请输入'
onInput={(event) => updateField('entity_phone2', event.detail.value.replace(/\D/g, ''))}
/>
</View>
<Text className='resubmit__tip'>1-7</Text>
<View className='resubmit__field'>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-storefront.png`} text='门头照片' required />
<UploadField
label='门头照片'
values={storefrontImages}
multiple
uploading={uploadingField === 'storefront'}
progress={uploadProgress}
onChange={(paths) => void handleUpload('storefront', paths)}
onRemove={(index) => setStorefrontImages((current) => current.filter((_, itemIndex) => itemIndex !== index))}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-license.png`} text='营业执照' />
<UploadField
label='营业执照'
values={licenseImages}
uploading={uploadingField === 'license'}
progress={uploadProgress}
onChange={(paths) => void handleUpload('license', paths)}
onRemove={() => setLicenseImages([])}
/>
</View>
{formError ? <Text className='resubmit__error'>{formError}</Text> : null}
<Button
className='resubmit__submit'
loading={submitting}
disabled={Boolean(uploadingField)}
onClick={() => void handleSubmit()}
>
</Button>
</View>
</View>
</View>
)
}

View File

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

View File

@ -0,0 +1,218 @@
.resubmit-page {
min-height: 100vh;
box-sizing: border-box;
background: #f7f8fa;
}
.resubmit-page__reject {
padding: 20px 32px;
background: #fff1f0;
color: #f53f3f;
font-size: 26px;
font-weight: 600;
line-height: 40px;
}
.resubmit-page__body {
padding: 24px 24px calc(172px + env(safe-area-inset-bottom));
}
.resubmit-card {
box-sizing: border-box;
width: 702px;
padding: 32px 24px 40px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 0 20px rgba(0, 78, 206, 0.1);
}
.resubmit__field {
margin-bottom: 28px;
}
.resubmit-card .resubmit__field:last-child {
margin-bottom: 0;
}
.resubmit__label {
display: flex;
align-items: center;
margin-bottom: 16px;
color: #1d2129;
font-size: 28px;
font-weight: 600;
}
.resubmit__label-icon {
width: 36px;
height: 36px;
margin-right: 8px;
}
.resubmit__required {
margin-left: 4px;
color: #f53f3f;
}
.resubmit__control {
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
min-height: 88px;
padding: 0 24px;
border: 2px solid #e5e6eb;
border-radius: 16px;
background: #fff;
}
.resubmit__control--nav {
justify-content: space-between;
}
.resubmit__input {
flex: 1;
min-width: 0;
height: 88px;
color: #1d2129;
font-size: 28px;
}
.resubmit__placeholder {
color: #c9cdd4;
font-size: 28px;
}
.resubmit__control-value {
flex: 1;
min-width: 0;
color: #1d2129;
font-size: 28px;
}
.resubmit__clear,
.resubmit__arrow {
width: 36px;
height: 36px;
margin-left: 12px;
flex-shrink: 0;
}
.resubmit__hint {
display: block;
margin-top: 12px;
color: #f53f3f;
font-size: 24px;
line-height: 36px;
}
.resubmit__shot {
position: relative;
width: 240px;
height: 180px;
overflow: hidden;
border-radius: 16px;
background: #fff;
}
.resubmit__shot--empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px solid #e5e6eb;
}
.resubmit__shot-image {
width: 100%;
height: 100%;
}
.resubmit__shot-camera {
width: 56px;
height: 56px;
}
.resubmit__shot-text {
margin-top: 8px;
color: #c9cdd4;
font-size: 22px;
}
.resubmit__shot-edit {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 0 16px 0 16px;
background: rgba(29, 33, 41, 0.55);
}
.resubmit__shot-edit-icon {
width: 32px;
height: 32px;
}
.resubmit__disclaimer,
.resubmit__online {
display: block;
width: 100%;
margin-top: 24px;
font-size: 24px;
font-weight: 400;
line-height: 36px;
text-align: left;
}
.resubmit__disclaimer-label {
color: #ff2626;
}
.resubmit__online {
margin-top: 16px;
}
.resubmit__disclaimer-text {
color: #333333;
}
.resubmit__error {
display: block;
margin-top: 16px;
color: #f53f3f;
font-size: 24px;
}
.resubmit-page__footer {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10;
padding: 60px 32px calc(24px + env(safe-area-inset-bottom));
background: #f7f8fa;
}
.resubmit__submit {
display: flex;
align-items: center;
justify-content: center;
height: 88px;
margin: 0;
padding: 0;
border: 0;
border-radius: 16px;
background: #165dff;
color: #fff;
font-size: 32px;
font-weight: 700;
line-height: 1.2;
}
.resubmit__submit::after {
border: 0;
}

View File

@ -0,0 +1,362 @@
import { Button, Image, Input, Text, View } from '@tarojs/components'
import Taro, { useDidShow, useLoad } from '@tarojs/taro'
import { useState } from 'react'
import { updateOrderExtra } from '@/api/pay'
import { uploadImage } from '@/api/user'
import { LOCAL_IMAGES } from '@/lib/assets'
import { ENTITY_LABELS, HOME_ICON_BASE, INPUT_ARROW_ICON, MAP_ASSET_BASE } from '@/lib/cdn'
import { getOrderDraft } from '@/lib/orderDraft'
import { isValidPhone } from '@/lib/phone'
import {
buildResubmitPayload,
formatMapCoordinate,
formatMapCoordinateDisplay,
getInitialPhone2,
getOrderAddressName,
getOrderExtraImages,
getOrderMapAddress,
parseCoordinate,
parseStorefrontSlots,
type OrderLike,
type ResubmitValues,
type StorefrontSlot,
type UploadedImage,
} from '@/lib/order'
import { consumeMapPickerResult } from '@/pages/map/picker'
import './index.scss'
const STOREFRONT_LABELS = ['门头正面', '门头左面', '门头右面']
const DISCLAIMER_BODY =
'我们只是代客户提交商户、企业位置资料,不是地图标注平台方,所提供服务为商业有偿帮助咨询服务,全程都是人工提交资料,自身并不能对第三方网站的原始内容进行编辑,请知悉。'
function FieldLabel ({ icon, text, required }: { icon: string, text: string, required?: boolean }) {
return (
<View className='resubmit__label'>
<Image className='resubmit__label-icon' src={icon} />
<Text>{text}</Text>
{required ? <Text className='resubmit__required'>*</Text> : null}
</View>
)
}
function ClearInput ({
value,
placeholder,
type,
maxlength,
onChange,
}: {
value?: string
placeholder: string
type?: 'text' | 'number'
maxlength?: number
onChange: (value: string) => void
}) {
return (
<View className='resubmit__control'>
<Input
className='resubmit__input'
type={type}
maxlength={maxlength}
value={value}
placeholder={placeholder}
placeholderClass='resubmit__placeholder'
onInput={(event) => onChange(event.detail.value)}
/>
{value ? (
<Image
className='resubmit__clear'
src={LOCAL_IMAGES.clear}
onClick={() => onChange('')}
/>
) : null}
</View>
)
}
function ImageSlot ({
value,
uploading,
progress,
onChange,
}: {
value: StorefrontSlot
uploading: boolean
progress: number
onChange: (filePath: string) => void
}) {
const handleChoose = async () => {
const res = await Taro.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
})
const filePath = res.tempFilePaths?.[0]
if (filePath) onChange(filePath)
}
if (value) {
return (
<View className='resubmit__shot'>
<Image
className='resubmit__shot-image'
src={value.preview || value.url}
mode='aspectFill'
onClick={() => Taro.previewImage({ current: value.preview || value.url, urls: [value.preview || value.url] })}
/>
<View className='resubmit__shot-edit' onClick={() => void handleChoose()}>
<Image className='resubmit__shot-edit-icon' src={LOCAL_IMAGES.pen} />
</View>
</View>
)
}
return (
<View className='resubmit__shot resubmit__shot--empty' onClick={() => void handleChoose()}>
<Image className='resubmit__shot-camera' src={LOCAL_IMAGES.camera} />
<Text className='resubmit__shot-text'>
{uploading ? `上传中 ${Math.round(progress)}%` : '请上传图片'}
</Text>
</View>
)
}
export default function ResubmitPage () {
const [order, setOrder] = useState<OrderLike | null>(null)
const [values, setValues] = useState<ResubmitValues>({})
const [storefrontSlots, setStorefrontSlots] = useState<StorefrontSlot[]>([null, null, null])
const [licenseImages, setLicenseImages] = useState<UploadedImage[]>([])
const [uploadingField, setUploadingField] = useState<'storefront0' | 'storefront1' | 'storefront2' | 'license' | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [formError, setFormError] = useState('')
const [submitting, setSubmitting] = useState(false)
useLoad(() => {
const draft = getOrderDraft()
if (!draft) {
Taro.showToast({ title: '订单不存在', icon: 'none' })
setTimeout(() => Taro.navigateBack(), 400)
return
}
setOrder(draft)
setValues({
entity_name: draft.entity_name || '',
entity_address_name: getOrderAddressName(draft),
entity_address: getOrderMapAddress(draft),
entity_phone: draft.entity_phone || '',
entity_phone2: getInitialPhone2(draft),
})
setStorefrontSlots(parseStorefrontSlots(draft))
setLicenseImages(getOrderExtraImages(draft, ['entity_business_license_image', 'business_license_image']).slice(0, 1))
})
useDidShow(() => {
const result = consumeMapPickerResult()
if (!result) return
const mapAddress = formatMapCoordinate(result)
const mapAddressName = result.address || result.name || ''
setValues((current) => ({
...current,
entity_address: mapAddress,
entity_address_name: mapAddressName || current.entity_address_name,
}))
})
const updateField = <K extends keyof ResubmitValues>(field: K, value: ResubmitValues[K]) => {
setValues((current) => ({ ...current, [field]: value }))
}
const handleUpload = async (field: 'storefront0' | 'storefront1' | 'storefront2' | 'license', filePath: string) => {
setUploadingField(field)
setUploadProgress(0)
setFormError('')
try {
const uploaded = await uploadImage(filePath, (percent) => setUploadProgress(percent))
const image: UploadedImage = { id: uploaded.id, url: uploaded.url, preview: filePath }
if (field === 'license') {
setLicenseImages([image])
return
}
const index = Number(field.replace('storefront', ''))
setStorefrontSlots((current) => {
const next = [...current]
next[index] = image
return next
})
} catch (error) {
setFormError(error instanceof Error ? error.message : '图片上传失败,请稍后重试')
} finally {
setUploadingField(null)
setUploadProgress(0)
}
}
const handleChooseMap = () => {
const point = parseCoordinate(values.entity_address)
const query = [
point ? `lng=${point.lng}` : '',
point ? `lat=${point.lat}` : '',
values.entity_address_name ? `name=${encodeURIComponent(values.entity_address_name)}` : '',
].filter(Boolean).join('&')
Taro.navigateTo({
url: query ? `/pages/map/index?${query}` : '/pages/map/index',
})
}
const handleSubmit = async () => {
if (!order || submitting) return
if (uploadingField) {
setFormError('图片正在上传,请稍候')
return
}
if (!values.entity_name?.trim()) {
Taro.showToast({ title: `请输入${ENTITY_LABELS.entityName}`, icon: 'none' })
return
}
if (!values.entity_address_name?.trim()) {
Taro.showToast({ title: `请输入${ENTITY_LABELS.entityAddress}`, icon: 'none' })
return
}
if (!values.entity_address?.trim()) {
Taro.showToast({ title: `请选择${ENTITY_LABELS.entityMapLocation}`, icon: 'none' })
return
}
if (!isValidPhone(values.entity_phone || '')) {
Taro.showToast({ title: '请填写正确的联系电话1', icon: 'none' })
return
}
if (!isValidPhone(values.entity_phone2 || '')) {
Taro.showToast({ title: '请填写正确的联系电话2', icon: 'none' })
return
}
setSubmitting(true)
setFormError('')
try {
await updateOrderExtra(buildResubmitPayload({
order,
values,
storefrontSlots,
licenseImages,
}))
Taro.showToast({ title: '重新提交成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 500)
} catch {
Taro.showToast({ title: '重新提交失败,请稍后再试', icon: 'none' })
} finally {
setSubmitting(false)
}
}
const mapPoint = parseCoordinate(values.entity_address)
const mapDisplay = mapPoint ? formatMapCoordinateDisplay(mapPoint) : (values.entity_address || '')
return (
<View className='resubmit-page'>
{order?.reject_reason ? (
<View className='resubmit-page__reject'>{order.reject_reason}</View>
) : null}
<View className='resubmit-page__body'>
<View className='resubmit-card'>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_marker@2x.png`} text={ENTITY_LABELS.entityName} required />
<ClearInput
value={values.entity_name}
placeholder='请输入标注名称'
onChange={(value) => updateField('entity_name', value)}
/>
<Text className='resubmit__hint'></Text>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location@2x.png`} text={ENTITY_LABELS.entityAddress} />
<View className='resubmit__control resubmit__control--nav' onClick={handleChooseMap}>
<Text className={values.entity_address_name ? 'resubmit__control-value' : 'resubmit__placeholder'}>
{values.entity_address_name || '请选择标注地址'}
</Text>
<Image className='resubmit__arrow' src={INPUT_ARROW_ICON} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_location_lnglat@2x.png`} text={ENTITY_LABELS.entityMapLocation} />
<View className='resubmit__control resubmit__control--nav' onClick={handleChooseMap}>
<Text className={mapDisplay ? 'resubmit__control-value' : 'resubmit__placeholder'}>
{mapDisplay || '请选择地图位置'}
</Text>
<Image className='resubmit__arrow' src={INPUT_ARROW_ICON} />
</View>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='联系电话1' required />
<ClearInput
type='number'
maxlength={12}
value={values.entity_phone}
placeholder='请输入'
onChange={(value) => updateField('entity_phone', value.replace(/\D/g, ''))}
/>
</View>
<View className='resubmit__field'>
<FieldLabel icon={`${HOME_ICON_BASE}/icon_form_phone@2x.png`} text='联系电话2' required />
<ClearInput
type='number'
maxlength={12}
value={values.entity_phone2}
placeholder='请输入'
onChange={(value) => updateField('entity_phone2', value.replace(/\D/g, ''))}
/>
<Text className='resubmit__hint'>1-7</Text>
</View>
{STOREFRONT_LABELS.map((label, index) => (
<View className='resubmit__field' key={label}>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-storefront.png`} text={label} />
<ImageSlot
value={storefrontSlots[index] || null}
uploading={uploadingField === `storefront${index}`}
progress={uploadProgress}
onChange={(filePath) => void handleUpload(`storefront${index}` as 'storefront0' | 'storefront1' | 'storefront2', filePath)}
/>
</View>
))}
<View className='resubmit__field'>
<FieldLabel icon={`${MAP_ASSET_BASE}/pay-success-license.png`} text='营业执照' />
<ImageSlot
value={licenseImages[0] || null}
uploading={uploadingField === 'license'}
progress={uploadProgress}
onChange={(filePath) => void handleUpload('license', filePath)}
/>
</View>
</View>
<Text className='resubmit__disclaimer'>
<Text className='resubmit__disclaimer-label'></Text>
<Text className='resubmit__disclaimer-text'>{DISCLAIMER_BODY}</Text>
</Text>
<Text className='resubmit__online'>
<Text className='resubmit__disclaimer-label'>线</Text>
<Text className='resubmit__disclaimer-text'>1-7线</Text>
</Text>
{formError ? <Text className='resubmit__error'>{formError}</Text> : null}
</View>
<View className='resubmit-page__footer'>
<Button
className='resubmit__submit'
loading={submitting}
disabled={Boolean(uploadingField)}
onClick={() => void handleSubmit()}
>
</Button>
</View>
</View>
)
}