274 lines
8.0 KiB
TypeScript
274 lines
8.0 KiB
TypeScript
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>
|
||
);
|
||
}
|