feat(files): 新增前端上传与订单证据图片

This commit is contained in:
yml2213
2026-10-30 14:08:11 +08:00
parent 8499dac163
commit 4fc4e9236b
5 changed files with 324 additions and 14 deletions
+132
View File
@@ -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}
+32
View File
@@ -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')
)
}
+33
View File
@@ -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),
})
})
}
+96
View File
@@ -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}`
}