diff --git a/.gitignore b/.gitignore index c91abac..19526e2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules/ .swc *.local .env +design/ \ No newline at end of file diff --git a/src/api/user.ts b/src/api/user.ts index d362ee9..6973fba 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -22,6 +22,20 @@ export function initCorp (params: Record = {}) { }) } +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', diff --git a/src/app.config.ts b/src/app.config.ts index 1184111..4caf298 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -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': { diff --git a/src/app.scss b/src/app.scss index e69de29..3a01f8c 100644 --- a/src/app.scss +++ b/src/app.scss @@ -0,0 +1,3 @@ +page { + background: #f6f8fc; +} diff --git a/src/assets/images/empty-orders.png b/src/assets/images/empty-orders.png new file mode 100644 index 0000000..138dffc Binary files /dev/null and b/src/assets/images/empty-orders.png differ diff --git a/src/assets/images/icon-camera.png b/src/assets/images/icon-camera.png new file mode 100644 index 0000000..a1fa8ab Binary files /dev/null and b/src/assets/images/icon-camera.png differ diff --git a/src/assets/images/icon-close.png b/src/assets/images/icon-close.png new file mode 100644 index 0000000..470ab28 Binary files /dev/null and b/src/assets/images/icon-close.png differ diff --git a/src/assets/images/icon-input-clear.png b/src/assets/images/icon-input-clear.png new file mode 100644 index 0000000..4c0aff0 Binary files /dev/null and b/src/assets/images/icon-input-clear.png differ diff --git a/src/assets/images/icon-logo.png b/src/assets/images/icon-logo.png new file mode 100644 index 0000000..6d83997 Binary files /dev/null and b/src/assets/images/icon-logo.png differ diff --git a/src/assets/images/icon-pen.png b/src/assets/images/icon-pen.png new file mode 100644 index 0000000..eda6135 Binary files /dev/null and b/src/assets/images/icon-pen.png differ diff --git a/src/assets/images/icon-search.png b/src/assets/images/icon-search.png new file mode 100644 index 0000000..3314dfb Binary files /dev/null and b/src/assets/images/icon-search.png differ diff --git a/src/lib/assets.ts b/src/lib/assets.ts new file mode 100644 index 0000000..9184e12 --- /dev/null +++ b/src/lib/assets.ts @@ -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, +} diff --git a/src/lib/cdn.ts b/src/lib/cdn.ts index 746d1c7..849e335 100644 --- a/src/lib/cdn.ts +++ b/src/lib/cdn.ts @@ -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` diff --git a/src/lib/feedback.test.ts b/src/lib/feedback.test.ts new file mode 100644 index 0000000..d3f0295 --- /dev/null +++ b/src/lib/feedback.test.ts @@ -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: '地图标注代办服务', + }) + }) +}) diff --git a/src/lib/feedback.ts b/src/lib/feedback.ts new file mode 100644 index 0000000..edd3888 --- /dev/null +++ b/src/lib/feedback.ts @@ -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 || ''), + } +} diff --git a/src/lib/nav.ts b/src/lib/nav.ts new file mode 100644 index 0000000..56d74f1 --- /dev/null +++ b/src/lib/nav.ts @@ -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, + } +} diff --git a/src/lib/order.test.ts b/src/lib/order.test.ts index 3a3436c..5f834e4 100644 --- a/src/lib/order.test.ts +++ b/src/lib/order.test.ts @@ -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', }, }) diff --git a/src/lib/order.ts b/src/lib/order.ts index 27b307c..dafdd54 100644 --- a/src/lib/order.ts +++ b/src/lib/order.ts @@ -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 = []) { + 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>((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), }, } diff --git a/src/lib/orderDraft.ts b/src/lib/orderDraft.ts new file mode 100644 index 0000000..54c3332 --- /dev/null +++ b/src/lib/orderDraft.ts @@ -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 +} diff --git a/src/lib/status.test.ts b/src/lib/status.test.ts new file mode 100644 index 0000000..9d65688 --- /dev/null +++ b/src/lib/status.test.ts @@ -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) + }) +}) diff --git a/src/lib/status.ts b/src/lib/status.ts index 46d42bb..0120d47 100644 --- a/src/lib/status.ts +++ b/src/lib/status.ts @@ -1,12 +1,14 @@ +import { isCorpGoodsType } from './detail' + export const STATUS_CONFIG: Record = { - '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 = { @@ -14,10 +16,42 @@ export const PAY_ICON: Record = { 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> (orders: T[] = [], tab: OrderTabKey) { + const current = ORDER_TABS.find((item) => item.key === tab) + if (!current || !current.statuses) return orders + const statuses = new Set(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, } } diff --git a/src/pages/complaint/index.config.ts b/src/pages/complaint/index.config.ts new file mode 100644 index 0000000..6d26eb2 --- /dev/null +++ b/src/pages/complaint/index.config.ts @@ -0,0 +1,6 @@ +export default definePageConfig({ + navigationBarTitleText: '投诉', + navigationBarBackgroundColor: '#00C261', + navigationBarTextStyle: 'white', + backgroundColor: '#F6F8FC', +}) diff --git a/src/pages/complaint/index.scss b/src/pages/complaint/index.scss new file mode 100644 index 0000000..870ad99 --- /dev/null +++ b/src/pages/complaint/index.scss @@ -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; +} diff --git a/src/pages/complaint/index.tsx b/src/pages/complaint/index.tsx new file mode 100644 index 0000000..ff62d5e --- /dev/null +++ b/src/pages/complaint/index.tsx @@ -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 = { + 莫名扣费: '¥', + 超过7天未解决: '7', + 交易被骗: '¥', + 不满意商家服务: '服', + 其他问题: '…', +} + +export default function ComplaintPage () { + const [order, setOrder] = useState(null) + const [type, setType] = useState('') + const [content, setContent] = useState('') + const [contact, setContact] = useState('') + const [images, setImages] = useState([]) + 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 ( + + + + {order?.goods_name || '-'} + 已支付 ¥{order?.total_fee || '-'} + + + + 交易ID + {order?.out_trade_no || '-'} + + + 交易时间 + {order?.create_time || order?.pay_time || '-'} + + + + + + + + 投诉类型 + * + + + {COMPLAINT_TYPES.map((item) => ( + setType(item.value)} + > + + {TYPE_ICONS[item.value]} + + {item.label} + + ))} + + + + + + 问题描述 + * + + +