feat(files): 新增前端上传与订单证据图片
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { App, Button, Image, Input, Space, Typography, Upload } from 'antd'
|
||||
import { DeleteOutlined, LinkOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { isImageFileURL, uploadFile } from '@/shared/api/files'
|
||||
|
||||
interface EvidenceUploadProps {
|
||||
scene: string
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
max?: number
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function EvidenceUpload({
|
||||
scene,
|
||||
value = [],
|
||||
onChange,
|
||||
max = 9,
|
||||
disabled = false,
|
||||
}: EvidenceUploadProps) {
|
||||
const { message } = App.useApp()
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [link, setLink] = useState('')
|
||||
|
||||
const add = (url: string) => {
|
||||
const trimmed = url.trim()
|
||||
if (!trimmed || value.includes(trimmed)) return
|
||||
onChange?.([...value, trimmed])
|
||||
}
|
||||
|
||||
const remove = (url: string) => {
|
||||
onChange?.(value.filter(item => item !== url))
|
||||
}
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (value.length >= max) {
|
||||
message.warning(`最多上传 ${max} 张图片`)
|
||||
return
|
||||
}
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('证据图片仅支持 JPG、PNG、WebP 格式')
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
message.error('单张图片不能超过 10MB')
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file, scene)
|
||||
add(uploaded.url)
|
||||
message.success('图片已上传')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '图片上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addLink = () => {
|
||||
const trimmed = link.trim()
|
||||
if (!trimmed) return
|
||||
if (!/^https?:\/\//i.test(trimmed)) {
|
||||
message.warning('请填写以 http:// 或 https:// 开头的链接')
|
||||
return
|
||||
}
|
||||
add(trimmed)
|
||||
setLink('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="evidence-upload">
|
||||
<Space wrap size={10} className="evidence-upload__grid">
|
||||
{value.map(url => (
|
||||
<div className="evidence-upload__item" key={url}>
|
||||
{isImageFileURL(url) ? (
|
||||
<Image src={url} alt="证据图片" width={92} height={92} className="evidence-upload__image" />
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noreferrer" className="evidence-upload__link-card">
|
||||
<LinkOutlined />
|
||||
<span>外部链接</span>
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
className="evidence-upload__remove"
|
||||
aria-label="删除证据"
|
||||
onClick={() => remove(url)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Upload
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
disabled={disabled || uploading}
|
||||
beforeUpload={file => {
|
||||
void handleFile(file)
|
||||
return false
|
||||
}}
|
||||
>
|
||||
<button type="button" className="evidence-upload__add" disabled={disabled || uploading}>
|
||||
<UploadOutlined />
|
||||
<span>{uploading ? '上传中…' : '上传图片'}</span>
|
||||
</button>
|
||||
</Upload>
|
||||
</Space>
|
||||
|
||||
<Space.Compact style={{ width: '100%', marginTop: 10 }}>
|
||||
<Input
|
||||
value={link}
|
||||
placeholder="也可以粘贴外部证据链接"
|
||||
prefix={<LinkOutlined />}
|
||||
disabled={disabled}
|
||||
onChange={event => setLink(event.target.value)}
|
||||
onPressEnter={addLink}
|
||||
/>
|
||||
<Button onClick={addLink} disabled={disabled || !link.trim()}>
|
||||
添加
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="secondary" className="evidence-upload__hint">
|
||||
最多 {max} 条,支持图片或外部链接。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Typography,
|
||||
} from 'antd'
|
||||
|
||||
import { EvidenceUpload } from '@/components/EvidenceUpload'
|
||||
import {
|
||||
acceptCheckout,
|
||||
cancelMyOrder,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
} from '@/features/orders/orderActions'
|
||||
import type { UserOrderDetail } from '@/features/orders/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { confirmModal } from '@/shared/utils/confirm'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
|
||||
type DialogKind = 'pay' | 'handoff' | 'checkout' | 'counter' | 'dispute' | null
|
||||
@@ -49,6 +51,7 @@ interface ActionFormValues {
|
||||
otherYuan?: number
|
||||
reason?: string
|
||||
evidenceText?: string
|
||||
evidenceUrls?: string[]
|
||||
disputeType?: string
|
||||
}
|
||||
|
||||
@@ -82,6 +85,11 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const evidencePayload = (values: ActionFormValues) => [
|
||||
...(values.evidenceUrls ?? []),
|
||||
...splitEvidenceLines(values.evidenceText ?? ''),
|
||||
]
|
||||
|
||||
const openDialog = (kind: Exclude<DialogKind, null>) => {
|
||||
form.resetFields()
|
||||
setPayURL('')
|
||||
@@ -95,6 +103,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
coinConsumed: checkout?.coin_consumed_m ?? 0,
|
||||
otherYuan: 0,
|
||||
evidenceText: '',
|
||||
evidenceUrls: [],
|
||||
})
|
||||
}
|
||||
if (kind === 'counter') {
|
||||
@@ -104,6 +113,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
otherYuan: (checkout?.deposit_deduct_amount_cent ?? 0) / 100,
|
||||
reason: '',
|
||||
evidenceText: '',
|
||||
evidenceUrls: [],
|
||||
})
|
||||
}
|
||||
if (kind === 'dispute') {
|
||||
@@ -111,6 +121,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
disputeType: actions.checkoutDisputeStage ? 'checkout_dispute' : 'cannot_login',
|
||||
content: '',
|
||||
evidenceText: '',
|
||||
evidenceUrls: [],
|
||||
})
|
||||
}
|
||||
setDialog(kind)
|
||||
@@ -186,7 +197,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
}
|
||||
|
||||
const submitReceive = async () => {
|
||||
const confirmed = await modal.confirm({
|
||||
const confirmed = await confirmModal(modal, {
|
||||
title: '确认收号',
|
||||
content: '请确认账号可以正常登录、资料与交接说明一致。确认后订单进入使用中。',
|
||||
okText: '确认收号',
|
||||
@@ -202,7 +213,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
const submitCheckout = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
const confirmed = await modal.confirm({
|
||||
const confirmed = await confirmModal(modal, {
|
||||
title: '确认提交结账',
|
||||
content: '结账会直接影响租金、押金赔付与双方结算金额,提交后将发送给对方确认。',
|
||||
okText: '提交结账',
|
||||
@@ -215,7 +226,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
consumable_amount_cent: yuanToCent(values.consumableYuan ?? 0),
|
||||
coin_consumed_m: toPositiveNumber(values.coinConsumed ?? 0),
|
||||
other_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
evidence_urls: evidencePayload(values),
|
||||
})
|
||||
message.success('结账已发起,等待对方确认')
|
||||
})
|
||||
@@ -226,7 +237,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
message.warning('押金不足,请修改方案或发起争议')
|
||||
return
|
||||
}
|
||||
const confirmed = await modal.confirm({
|
||||
const confirmed = await confirmModal(modal, {
|
||||
title: '确认结账',
|
||||
content: '确认账号状态和扣款金额无误吗?确认后订单将完成。',
|
||||
okText: '确认结账',
|
||||
@@ -244,7 +255,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
message.warning('押金不足,请修改方案或发起争议')
|
||||
return
|
||||
}
|
||||
const confirmed = await modal.confirm({
|
||||
const confirmed = await confirmModal(modal, {
|
||||
title: '同意结账方案',
|
||||
content: '确定同意当前结账方案吗?同意后订单将完成结算。',
|
||||
okText: '同意结账',
|
||||
@@ -268,7 +279,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
coin_consumed_m: toPositiveNumber(values.coinConsumed ?? 0),
|
||||
other_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
deposit_deduct_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
evidence_urls: evidencePayload(values),
|
||||
})
|
||||
message.success('结账方案已修改,等待对方处理')
|
||||
})
|
||||
@@ -277,7 +288,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
const submitDispute = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
const confirmed = await modal.confirm({
|
||||
const confirmed = await confirmModal(modal, {
|
||||
title: actions.checkoutDisputeStage ? '发起结账争议' : '发起申诉',
|
||||
content: '提交后订单会进入仲裁流程,客服会根据双方证据处理。确定提交吗?',
|
||||
okText: '提交',
|
||||
@@ -288,14 +299,14 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
await createOrderDispute(order.id, {
|
||||
type: actions.checkoutDisputeStage ? 'checkout_dispute' : values.disputeType ?? 'other',
|
||||
description: values.content ?? '',
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
evidence_urls: evidencePayload(values),
|
||||
})
|
||||
message.success('申诉已提交')
|
||||
})
|
||||
}
|
||||
|
||||
const cancelDispute = async () => {
|
||||
const confirmed = await modal.confirm({
|
||||
const confirmed = await confirmModal(modal, {
|
||||
title: '取消申诉',
|
||||
content: '取消后订单会恢复到发起申诉前的流程,确定继续吗?',
|
||||
okText: '取消申诉',
|
||||
@@ -310,7 +321,7 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
|
||||
const cancelOrder = async () => {
|
||||
const refundReview = order.status === 'pending_handoff'
|
||||
const confirmed = await modal.confirm({
|
||||
const confirmed = await confirmModal(modal, {
|
||||
title: refundReview ? '申请取消订单' : '取消订单',
|
||||
content: refundReview
|
||||
? '订单已支付,取消后需要客服审核退款,审核通过前账号仍会保持占用。'
|
||||
@@ -551,8 +562,11 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
<InputNumber min={0} precision={2} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="evidenceText" label="证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={3} placeholder="https://..." />
|
||||
<Form.Item name="evidenceUrls" label="证据图片(可选)">
|
||||
<EvidenceUpload scene="handoff" max={6} />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidenceText" label="外部证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={2} placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
@@ -581,8 +595,11 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
>
|
||||
<Input.TextArea rows={5} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidenceText" label="证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={3} placeholder="https://..." />
|
||||
<Form.Item name="evidenceUrls" label="证据图片(可选)">
|
||||
<EvidenceUpload scene="dispute" max={6} />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidenceText" label="外部证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={2} placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { apiClient } from './client'
|
||||
import type { ApiResponse } from './types'
|
||||
import { optimizeImageForUpload } from '@/shared/utils/imageUpload'
|
||||
|
||||
export interface UploadedFile {
|
||||
object_key: string
|
||||
url: string
|
||||
thumbnail_url?: string
|
||||
medium_url?: string
|
||||
filename: string
|
||||
content_type: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, scene: string) {
|
||||
const uploadTarget = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', uploadTarget)
|
||||
form.append('scene', scene)
|
||||
const { data } = await apiClient.post<ApiResponse<UploadedFile>>('/files/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export function isImageFileURL(url: string) {
|
||||
return (
|
||||
/\.(png|jpe?g|webp|gif|bmp)(\?.*)?$/i.test(url) ||
|
||||
url.includes('/api/files/object') ||
|
||||
url.includes('/api/public/files/object')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ModalFuncProps } from 'antd'
|
||||
|
||||
type ConfirmApi = {
|
||||
confirm: (props: ModalFuncProps) => unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* AntD v5 的 modal.confirm 返回控制对象而不是 Promise,直接 await 会立刻得到真值。
|
||||
* 这里包一层 Promise,确保「取消」时不会继续执行危险操作。
|
||||
*/
|
||||
export function confirmModal(modal: ConfirmApi, props: ModalFuncProps): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
let settled = false
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
modal.confirm({
|
||||
...props,
|
||||
onOk: async () => {
|
||||
finish(true)
|
||||
await props.onOk?.()
|
||||
},
|
||||
onCancel: (...args) => {
|
||||
finish(false)
|
||||
props.onCancel?.(...args)
|
||||
},
|
||||
afterClose: () => finish(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
avatar: 512,
|
||||
chat: 1600,
|
||||
announcement: 1600,
|
||||
'home-banner': 1920,
|
||||
listing: 1920,
|
||||
dispute: 1920,
|
||||
handoff: 1920,
|
||||
realname: 1920,
|
||||
'payment-cert': 1600,
|
||||
qrcode: 2560,
|
||||
}
|
||||
|
||||
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||
avatar: 0.82,
|
||||
chat: 0.8,
|
||||
announcement: 0.84,
|
||||
'home-banner': 0.84,
|
||||
listing: 0.84,
|
||||
dispute: 0.86,
|
||||
handoff: 0.86,
|
||||
realname: 0.86,
|
||||
'payment-cert': 0.86,
|
||||
qrcode: 0.95,
|
||||
}
|
||||
|
||||
const OPTIMIZABLE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/bmp']
|
||||
|
||||
export async function optimizeImageForUpload(file: File, scene: string) {
|
||||
if (typeof document === 'undefined') return file
|
||||
if (!OPTIMIZABLE_TYPES.includes(file.type)) return file
|
||||
|
||||
try {
|
||||
const image = await loadImage(file)
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.82
|
||||
const { width, height } = fitSize(image.naturalWidth, image.naturalHeight, maxSide)
|
||||
const needsResize = width !== image.naturalWidth || height !== image.naturalHeight
|
||||
// 尺寸已经合适且文件不大时保留原图,避免二次压缩损失画质。
|
||||
if (!needsResize && file.size <= 1.5 * 1024 * 1024) return file
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return file
|
||||
context.clearRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
|
||||
// WebP 支持透明通道,不会把透明区域压成白底。
|
||||
const blob = await canvasToBlob(canvas, 'image/webp', quality)
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
return new File([blob], replaceFileExt(file.name, 'webp'), {
|
||||
type: 'image/webp',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
} catch {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('图片读取失败'))
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function fitSize(width: number, height: number, maxSide: number) {
|
||||
if (width <= 0 || height <= 0) return { width: 1, height: 1 }
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height))
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
|
||||
return new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve, type, quality)
|
||||
})
|
||||
}
|
||||
|
||||
function replaceFileExt(filename: string, ext: string) {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
return `${base || 'image'}.${ext}`
|
||||
}
|
||||
Reference in New Issue
Block a user