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) ? ( + 证据图片 + ) : ( + + + 外部链接 + + )} +
+ ))} + + { + 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}` +}