Files
dukang/apps/h5-partner/src/components/MultiOssUploadField.tsx
T
jacy fc2e5b65de
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新1
2026-08-20 18:54:15 +08:00

274 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError, toastSuccess } from '../lib/toast';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
maxCount?: number;
disabled?: boolean;
label?: string;
/** 系统文件选择器的 accept,默认仅图片 */
accept?: string;
/** 计量单位文案,如「张」「个」 */
unit?: string;
/**
* stack:缩略图 + 下方独立上传按钮(合同等)
* grid:九宫格,末尾「+」格上传,无独立大按钮(环境图)
*/
variant?: 'stack' | 'grid';
};
function isCancelError(msg: string): boolean {
return /cancel|取消/i.test(msg);
}
function isPdf(url: string): boolean {
return /\.pdf(\?|$)/i.test(url);
}
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
/** 合伙人端多图批量上传(微信相册可多选) */
export default function MultiOssUploadField({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount = 20,
disabled,
label,
accept = 'image/*',
unit = '张',
variant = 'stack',
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const pickingRef = useRef(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const remaining = Math.max(0, maxCount - urls.length);
const inWechat = isWechatEnv();
const isGrid = variant === 'grid';
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (!inWechat) return;
void weixinSdk.init().catch(() => {});
}, [inWechat]);
function showUploadError(text: string) {
setError(text);
toastError(text);
}
async function uploadFiles(files: File[]) {
const current = urlsRef.current;
const room = Math.max(0, maxCount - current.length);
const picked = files.slice(0, room);
if (!picked.length) {
showUploadError(`最多 ${maxCount} ${unit}`);
return;
}
setUploading(true);
setError('');
const appended: string[] = [];
try {
for (const file of picked) {
if (!file.size) continue;
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
appended.push(result.url);
}
if (appended.length) {
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
toastSuccess(`已上传 ${appended.length} ${unit}`);
}
} catch (e) {
showUploadError(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function pickWechat() {
if (pickingRef.current || uploading || disabled || remaining <= 0) return;
pickingRef.current = true;
setError('');
try {
await weixinSdk.init();
const files = await weixinSdk.chooseImages({
count: Math.min(remaining, 9),
sourceType: ['album', 'camera'],
});
if (!files?.length) return;
await uploadFiles(files);
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (isCancelError(msg)) return;
const formatted = formatChooseImageFailMessage(msg) || msg;
showUploadError(formatted);
inputRef.current?.click();
} finally {
pickingRef.current = false;
}
}
function openPicker() {
if (inWechat) void pickWechat();
else inputRef.current?.click();
}
function removeAt(index: number) {
if (disabled) return;
onChange?.(urls.filter((_, i) => i !== index));
}
const thumb = (url: string, index: number) => (
<div key={`${url}-${index}`} className={isGrid ? 'partner-upload-grid-thumb' : undefined} style={isGrid ? undefined : { position: 'relative', width: 88, height: 88 }}>
{isPdf(url) ? (
<a
href={url}
target="_blank"
rel="noreferrer"
style={{
display: 'flex',
width: isGrid ? '100%' : 88,
height: isGrid ? '100%' : 88,
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
borderRadius: 8,
border: '1px solid rgba(0,0,0,0.08)',
background: '#f7f7f7',
fontSize: 12,
}}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 26 }}>
description
</span>
<span className="text-muted">PDF</span>
</a>
) : (
<img
src={url}
alt=""
style={{
width: isGrid ? '100%' : 88,
height: isGrid ? '100%' : 88,
objectFit: 'cover',
borderRadius: isGrid ? 12 : 8,
display: 'block',
}}
/>
)}
{!disabled ? (
<button
type="button"
className="partner-packages-remove"
style={{
position: 'absolute',
top: 2,
right: 2,
margin: 0,
padding: '2px 6px',
fontSize: 12,
background: 'rgba(0,0,0,0.55)',
color: '#fff',
borderRadius: 4,
}}
onClick={() => removeAt(index)}
>
</button>
) : null}
</div>
);
return (
<div className="partner-oss-upload">
<input
ref={inputRef}
type="file"
accept={accept}
multiple
className="partner-oss-upload-input"
disabled={disabled || uploading || remaining <= 0}
onChange={(e) => {
const list = Array.from(e.target.files ?? []);
if (list.length) void uploadFiles(list);
}}
/>
{isGrid ? (
<div className="partner-upload-grid">
{urls.map((url, index) => thumb(url, index))}
{remaining > 0 && !disabled ? (
<button
type="button"
className="partner-upload-dashed partner-upload-dashed--compact"
disabled={uploading}
onClick={openPicker}
aria-label={uploading ? '上传中' : `添加${unit}${urls.length}/${maxCount}`}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
{uploading ? 'hourglass_top' : 'add'}
</span>
</button>
) : null}
</div>
) : (
<>
{urls.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{urls.map((url, index) => thumb(url, index))}
</div>
) : null}
<button
type="button"
className="partner-upload-dashed partner-upload-dashed--compact"
disabled={disabled || uploading || remaining <= 0}
onClick={openPicker}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
{uploading ? 'hourglass_top' : 'add_a_photo'}
</span>
<span className="text-primary" style={{ fontWeight: 500 }}>
{uploading
? '上传中…'
: remaining <= 0
? `已达上限 ${maxCount}${unit}`
: label ?? `批量上传(${urls.length}/${maxCount}`}
</span>
</button>
</>
)}
{error ? (
<p className="partner-form-error" role="alert">
{error}
</p>
) : null}
</div>
);
}