diff --git a/frontend/src/components/EvidenceUpload.tsx b/frontend/src/components/EvidenceUpload.tsx
new file mode 100644
index 0000000..e68a018
--- /dev/null
+++ b/frontend/src/components/EvidenceUpload.tsx
@@ -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 (
+
+
+ {value.map(url => (
+
+ {isImageFileURL(url) ? (
+
+ ) : (
+
+
+ 外部链接
+
+ )}
+
}
+ className="evidence-upload__remove"
+ aria-label="删除证据"
+ onClick={() => remove(url)}
+ />
+
+ ))}
+
+ {
+ void handleFile(file)
+ return false
+ }}
+ >
+
+
+
+
+
+ }
+ disabled={disabled}
+ onChange={event => setLink(event.target.value)}
+ onPressEnter={addLink}
+ />
+
+
+
+ 最多 {max} 条,支持图片或外部链接。
+
+
+ )
+}
diff --git a/frontend/src/features/orders/OrderActionPanel.tsx b/frontend/src/features/orders/OrderActionPanel.tsx
index c288096..9514f5f 100644
--- a/frontend/src/features/orders/OrderActionPanel.tsx
+++ b/frontend/src/features/orders/OrderActionPanel.tsx
@@ -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) => {
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) {
-
-
+
+
+
+
+
) : null}
@@ -581,8 +595,11 @@ export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
>
-
-
+
+
+
+
+
) : null}
diff --git a/frontend/src/shared/api/files.ts b/frontend/src/shared/api/files.ts
new file mode 100644
index 0000000..d622ed5
--- /dev/null
+++ b/frontend/src/shared/api/files.ts
@@ -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>('/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')
+ )
+}
diff --git a/frontend/src/shared/utils/confirm.ts b/frontend/src/shared/utils/confirm.ts
new file mode 100644
index 0000000..54334dd
--- /dev/null
+++ b/frontend/src/shared/utils/confirm.ts
@@ -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 {
+ 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),
+ })
+ })
+}
diff --git a/frontend/src/shared/utils/imageUpload.ts b/frontend/src/shared/utils/imageUpload.ts
new file mode 100644
index 0000000..df63451
--- /dev/null
+++ b/frontend/src/shared/utils/imageUpload.ts
@@ -0,0 +1,96 @@
+const IMAGE_UPLOAD_MAX_SIDE: Record = {
+ 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 = {
+ 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((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(resolve => {
+ canvas.toBlob(resolve, type, quality)
+ })
+}
+
+function replaceFileExt(filename: string, ext: string) {
+ const base = filename.replace(/\.[^.]+$/, '')
+ return `${base || 'image'}.${ext}`
+}