feat: 统一后台与用户端图片与文件上传交互,支持点击放大预览、删除与多图批量多选上传

这个提交包含在:
wikiboss
2026-09-21 01:28:01 +08:00
父节点 56392d8f9e
当前提交 955c73e7ef
共有 47 个文件被更改,包括 2605 次插入 和 77 次删除

查看文件

@@ -0,0 +1,155 @@
import React, { useState, useCallback } from 'react';
import {
Upload,
Button,
Space,
Typography,
Message,
} from '@arco-design/web-react';
import type { UploadItem } from '@arco-design/web-react/es/Upload';
import {
IconFile,
IconDelete,
IconEye,
IconUpload,
} from '@arco-design/web-react/icon';
export interface FileUploaderProps {
value?: string;
onChange?: (value: string) => void;
accept?: string;
disabled?: boolean;
action?: string;
btnText?: string;
}
const DEFAULT_ACTION = '/api/admin/base/comm/upload';
function parseUploadedUrl(response: unknown): string {
if (!response || typeof response !== 'object') {
return '';
}
const rec = response as Record<string, unknown>;
if (typeof rec.data === 'string' && rec.data) {
return rec.data;
}
if (typeof rec.url === 'string' && rec.url) {
return rec.url;
}
return '';
}
export const FileUploader: React.FC<FileUploaderProps> = ({
value = '',
onChange,
accept = '.pdf',
disabled = false,
action = DEFAULT_ACTION,
btnText = 'Upload File',
}) => {
const [uploading, setUploading] = useState(false);
const handleUploadChange = useCallback(
(_fileList: UploadItem[], currentFile: UploadItem) => {
if (currentFile.status === 'uploading') {
setUploading(true);
} else if (currentFile.status === 'done') {
setUploading(false);
const uploadedUrl = parseUploadedUrl(currentFile.response);
if (uploadedUrl) {
Message.success('File uploaded successfully');
onChange?.(uploadedUrl);
} else {
Message.error('Upload failed: missing url');
}
} else if (currentFile.status === 'error') {
setUploading(false);
Message.error('Upload failed');
}
},
[onChange]
);
const handleDelete = useCallback(() => {
if (!disabled) {
onChange?.('');
}
}, [disabled, onChange]);
const handlePreview = useCallback(() => {
if (value) {
window.open(value, '_blank', 'noopener,noreferrer');
}
}, [value]);
const fileName = value ? value.split('/').pop() || 'document.pdf' : '';
return (
<div style={{ display: 'inline-block', width: '100%' }}>
{value ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 12px',
borderRadius: 8,
border: '1px solid var(--color-border-2, #E3E7ED)',
backgroundColor: 'var(--color-fill-2, #F7F8FA)',
}}
>
<Space size={8} style={{ overflow: 'hidden' }}>
<IconFile style={{ fontSize: 18, color: 'var(--color-primary-6, #0B5CFF)' }} />
<Typography.Text
ellipsis
style={{ maxWidth: 260, fontSize: 13, color: 'var(--color-text-1, #1D2129)' }}
>
{fileName}
</Typography.Text>
</Space>
<Space size={4}>
<Button
type="text"
size="small"
icon={<IconEye />}
onClick={handlePreview}
>
Preview
</Button>
{!disabled && (
<Button
type="text"
status="danger"
size="small"
icon={<IconDelete />}
onClick={handleDelete}
>
Delete
</Button>
)}
</Space>
</div>
) : (
<Upload
action={action}
accept={accept}
showUploadList={false}
disabled={disabled}
onChange={handleUploadChange}
>
<Button
type="outline"
size="small"
loading={uploading}
disabled={disabled}
icon={<IconUpload />}
>
{btnText}
</Button>
</Upload>
)}
</div>
);
};
export default FileUploader;

查看文件

@@ -0,0 +1,239 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
Upload,
Image,
Space,
Button,
Message,
Modal,
} from '@arco-design/web-react';
import type { UploadItem } from '@arco-design/web-react/es/Upload';
import {
IconPlus,
IconEye,
IconDelete,
IconUpload,
} from '@arco-design/web-react/icon';
export interface ImageUploaderProps {
value?: string | string[];
onChange?: (value: string | string[]) => void;
multiple?: boolean;
maxCount?: number;
accept?: string;
disabled?: boolean;
action?: string;
tip?: string;
}
const DEFAULT_ACTION = '/api/admin/base/comm/upload';
function parseUploadedUrl(response: unknown): string {
if (!response || typeof response !== 'object') {
return '';
}
const rec = response as Record<string, unknown>;
if (typeof rec.data === 'string' && rec.data) {
return rec.data;
}
if (typeof rec.url === 'string' && rec.url) {
return rec.url;
}
return '';
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({
value,
onChange,
multiple = false,
maxCount = 9,
accept = 'image/*',
disabled = false,
action = DEFAULT_ACTION,
tip,
}) => {
const [previewVisible, setPreviewVisible] = useState(false);
const [previewSrc, setPreviewSrc] = useState('');
const currentList = useMemo<string[]>(() => {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.filter((v): v is string => typeof v === 'string' && v.trim() !== '');
}
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}, [value]);
const handlePreview = useCallback((url: string) => {
setPreviewSrc(url);
setPreviewVisible(true);
}, []);
const handleDelete = useCallback(
(indexToDelete: number) => {
if (disabled) {
return;
}
if (multiple) {
const nextList = currentList.filter((_, idx) => idx !== indexToDelete);
onChange?.(nextList);
} else {
onChange?.('');
}
},
[currentList, disabled, multiple, onChange]
);
const handleUploadChange = useCallback(
(_fileList: UploadItem[], currentFile: UploadItem) => {
if (currentFile.status === 'done') {
const uploadedUrl = parseUploadedUrl(currentFile.response);
if (uploadedUrl) {
Message.success('Upload success');
if (multiple) {
const nextList = [...currentList, uploadedUrl].slice(0, maxCount);
onChange?.(nextList);
} else {
onChange?.(uploadedUrl);
}
} else {
Message.error('Upload failed: missing url');
}
} else if (currentFile.status === 'error') {
Message.error('Upload failed');
}
},
[currentList, maxCount, multiple, onChange]
);
const canUploadMore = multiple ? currentList.length < maxCount : currentList.length === 0;
return (
<div style={{ display: 'inline-block', width: '100%' }}>
<Space wrap size={12} align="center">
{/* 多图画廊预览组 */}
<Image.PreviewGroup
srcList={currentList}
visible={previewVisible}
onVisibleChange={(visible) => setPreviewVisible(visible)}
current={currentList.indexOf(previewSrc) >= 0 ? currentList.indexOf(previewSrc) : 0}
>
{currentList.map((url, idx) => (
<div
key={`${url}-${idx}`}
style={{
position: 'relative',
width: 96,
height: 96,
borderRadius: 12,
overflow: 'hidden',
border: '1px solid var(--color-border-2, #E3E7ED)',
backgroundColor: 'var(--color-fill-2, #F7F8FA)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={url}
alt="thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.45)',
opacity: 0,
transition: 'opacity 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0';
}}
>
<Button
type="text"
shape="circle"
size="mini"
style={{ color: '#fff' }}
icon={<IconEye />}
onClick={(e) => {
e.stopPropagation();
handlePreview(url);
}}
/>
{!disabled && (
<Button
type="text"
shape="circle"
size="mini"
style={{ color: '#ff4d4f' }}
icon={<IconDelete />}
onClick={(e) => {
e.stopPropagation();
handleDelete(idx);
}}
/>
)}
</div>
</div>
))}
</Image.PreviewGroup>
{/* 上传触发按钮 */}
{canUploadMore && !disabled && (
<Upload
action={action}
accept={accept}
multiple={multiple}
showUploadList={false}
onChange={handleUploadChange}
>
<div
style={{
width: 96,
height: 96,
borderRadius: 12,
border: '1px dashed var(--color-border-3, #C9CDD4)',
backgroundColor: 'var(--color-fill-1, #F7F8FA)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
}}
>
<IconPlus style={{ fontSize: 20, color: 'var(--color-text-3, #86909C)' }} />
<span
style={{
fontSize: 12,
marginTop: 4,
color: 'var(--color-text-3, #86909C)',
}}
>
Upload
</span>
</div>
</Upload>
)}
</Space>
{tip && (
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--color-text-3, #86909C)' }}>
{tip}
</div>
)}
</div>
);
};
export default ImageUploader;

查看文件

@@ -0,0 +1,2 @@
export * from './ImageUploader';
export * from './FileUploader';

查看文件

@@ -14,6 +14,7 @@ import {
Space,
Grid,
} from '@arco-design/web-react';
import { ImageUploader } from '@/components/Upload';
import type { PaginationProps, TableColumnProps } from '@arco-design/web-react';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
@@ -528,7 +529,7 @@ export default function CardArticlesPage() {
/>
</Form.Item>
<Form.Item label={t['cardArticles.field.coverImage']} field="coverImage">
<Input placeholder="https://cdn.omnicard.ai/articles/cover.jpg" />
<ImageUploader />
</Form.Item>
<Form.Item
label={t['cardArticles.field.content']}

查看文件

@@ -12,6 +12,7 @@ import {
Card,
Tabs,
} from '@arco-design/web-react';
import { ImageUploader } from '@/components/Upload';
import useLocale from '@/utils/useLocale';
import { getCssVariables } from '@/style/tokens';
import type {
@@ -230,20 +231,26 @@ function CardBuilderPage() {
};
}, [hasId, id, t]);
const lastSelectedIdRef = useRef<string | number | null>(null);
useEffect(() => {
if (!selectedBlock) {
form.resetFields();
lastSelectedIdRef.current = null;
return;
}
form.setFieldsValue({
title: selectedBlock.title,
content: selectedBlock.content,
mediaUrl: selectedBlock.mediaUrl,
linkUrl: selectedBlock.linkUrl,
colSpan: selectedBlock.colSpan,
rowSpan: selectedBlock.rowSpan,
type: selectedBlock.type,
});
if (lastSelectedIdRef.current !== selectedBlock.id) {
lastSelectedIdRef.current = selectedBlock.id;
form.setFieldsValue({
title: selectedBlock.title,
content: selectedBlock.content,
mediaUrl: selectedBlock.mediaUrl,
linkUrl: selectedBlock.linkUrl,
colSpan: selectedBlock.colSpan,
rowSpan: selectedBlock.rowSpan,
type: selectedBlock.type,
});
}
}, [form, selectedBlock]);
const handleAdd = useCallback(
@@ -487,7 +494,7 @@ function CardBuilderPage() {
field="mediaUrl"
label={t['cardBuilder.field.mediaUrl']}
>
<Input />
<ImageUploader />
</Form.Item>
<Form.Item
field="linkUrl"

查看文件

@@ -15,6 +15,7 @@ import {
} from '@arco-design/web-react';
import type { TableColumnProps, PaginationProps } from '@arco-design/web-react';
import { IconPlus, IconSearch, IconRefresh, IconFile, IconUpload } from '@arco-design/web-react/icon';
import { ImageUploader, FileUploader } from '@/components/Upload';
import axios from 'axios';
import { useLocation } from 'react-router-dom';
import queryString from 'query-string';
@@ -302,12 +303,13 @@ export default function CardCasesPage(): React.ReactElement {
width: 110,
render: (url?: string) =>
url ? (
<img
<Image
src={url}
alt="cover"
width={64}
height={40}
preview
style={{
width: 64,
height: 40,
objectFit: 'cover',
borderRadius: 4,
border: `1px solid ${lightColors.border}`,
@@ -570,44 +572,14 @@ export default function CardCasesPage(): React.ReactElement {
</div>
<Form.Item field="coverImage" label={t['cases.coverImage']}>
<Input
placeholder={t['cases.coverImagePlaceholder']}
addAfter={
<Upload
action="/api/common/upload"
showUploadList={false}
onChange={(_, file) => {
if (file.response?.url) {
editForm.setFieldsValue({ coverImage: file.response.url });
}
}}
>
<Button type="text" size="mini" icon={<IconUpload />}>
Upload
</Button>
</Upload>
}
<ImageUploader
tip={t['cases.uploadHint']}
/>
</Form.Item>
<Form.Item field="pdfUrl" label={t['cases.pdfUrl']}>
<Input
placeholder={t['cases.pdfUrlPlaceholder']}
addAfter={
<Upload
action="/api/common/upload"
showUploadList={false}
onChange={(_, file) => {
if (file.response?.url) {
editForm.setFieldsValue({ pdfUrl: file.response.url });
}
}}
>
<Button type="text" size="mini" icon={<IconUpload />}>
Upload
</Button>
</Upload>
}
<FileUploader
btnText={t['cases.uploadText']}
/>
</Form.Item>

查看文件

@@ -11,6 +11,7 @@ import {
Typography,
Image,
} from '@arco-design/web-react';
import { ImageUploader } from '@/components/Upload';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
import { lightColors } from '@/style/tokens';
@@ -279,9 +280,8 @@ function TeamBrandKitPage() {
field="logoUrl"
label={t['teamBrandKit.form.logoUrl']}
>
<Input
allowClear
placeholder={t['teamBrandKit.form.logoUrl.placeholder']}
<ImageUploader
tip={t['teamBrandKit.form.logoUrl.placeholder']}
/>
</Form.Item>
<Form.Item

查看文件

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import {
Button,
Avatar,
@@ -7,8 +7,10 @@ import {
Tag,
Skeleton,
Link,
Image,
Space,
} from '@arco-design/web-react';
import { IconCamera, IconPlus } from '@arco-design/web-react/icon';
import { IconCamera, IconPlus, IconEye, IconDelete } from '@arco-design/web-react/icon';
import useLocale from '@/utils/useLocale';
import locale from './locale';
import styles from './style/header.module.less';
@@ -17,19 +19,34 @@ export default function Info({
userInfo = {},
loading,
}: {
userInfo: any;
userInfo: Record<string, unknown>;
loading: boolean;
}) {
const t = useLocale(locale);
const [avatar, setAvatar] = useState('');
const [previewVisible, setPreviewVisible] = useState(false);
function onAvatarChange(_, file) {
setAvatar(file.originFile ? URL.createObjectURL(file.originFile) : '');
function onAvatarChange(_: unknown, file: { originFile?: File; response?: { data?: string; url?: string } }) {
if (file.response?.data) {
setAvatar(file.response.data);
} else if (file.response?.url) {
setAvatar(file.response.url);
} else if (file.originFile) {
setAvatar(URL.createObjectURL(file.originFile));
} else {
setAvatar('');
}
}
const handleDelete = useCallback(() => {
setAvatar('');
}, []);
useEffect(() => {
setAvatar(userInfo.avatar);
if (typeof userInfo.avatar === 'string') {
setAvatar(userInfo.avatar);
}
}, [userInfo]);
const loadingImg = (
@@ -43,19 +60,59 @@ export default function Info({
const loadingNode = <Skeleton text={{ rows: 1 }} animation />;
return (
<div className={styles['info-wrapper']}>
<Upload showUploadList={false} onChange={onAvatarChange}>
{loading ? (
loadingImg
) : (
<Avatar
size={100}
triggerIcon={<IconCamera />}
className={styles['info-avatar']}
>
{avatar ? <img src={avatar} /> : <IconPlus />}
</Avatar>
)}
</Upload>
{loading ? (
loadingImg
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{ position: 'relative' }}>
<Avatar
size={100}
className={styles['info-avatar']}
>
{avatar ? <img src={avatar} alt="avatar" /> : <IconPlus />}
</Avatar>
</div>
<Space size={8}>
<Upload
action="/api/admin/base/comm/upload"
showUploadList={false}
onChange={onAvatarChange}
>
<Button size="mini" type="outline" icon={<IconCamera />}>
Upload
</Button>
</Upload>
{avatar && (
<>
<Button
size="mini"
type="text"
icon={<IconEye />}
onClick={() => setPreviewVisible(true)}
>
Preview
</Button>
<Button
size="mini"
type="text"
status="danger"
icon={<IconDelete />}
onClick={handleDelete}
>
Delete
</Button>
</>
)}
</Space>
{avatar && (
<Image.Preview
src={avatar}
visible={previewVisible}
onVisibleChange={setPreviewVisible}
/>
)}
</div>
)}
<Descriptions
className={styles['info-content']}
column={2}

查看文件

@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const uploadCompDir = path.join(adminRoot, 'src/components/Upload');
const imageUploaderPath = path.join(uploadCompDir, 'ImageUploader.tsx');
const fileUploaderPath = path.join(uploadCompDir, 'FileUploader.tsx');
const uploadIndexPath = path.join(uploadCompDir, 'index.ts');
const casesPagePath = path.join(adminRoot, 'src/pages/card/cases/index.tsx');
const builderPagePath = path.join(adminRoot, 'src/pages/card/builder/index.tsx');
const articlesPagePath = path.join(adminRoot, 'src/pages/card/articles/index.tsx');
const brandKitPagePath = path.join(adminRoot, 'src/pages/team/brand-kit/index.tsx');
const userSettingHeaderPath = path.join(adminRoot, 'src/pages/user/setting/header.tsx');
test('1. components/Upload 必须存在 ImageUploader 与 FileUploader 并正确导出', () => {
assert.ok(fs.existsSync(imageUploaderPath), '必须存在 ImageUploader.tsx');
assert.ok(fs.existsSync(fileUploaderPath), '必须存在 FileUploader.tsx');
assert.ok(fs.existsSync(uploadIndexPath), '必须存在 index.ts 统一导出');
const indexContent = fs.readFileSync(uploadIndexPath, 'utf-8');
assert.ok(indexContent.includes('ImageUploader'), 'index.ts 需导出 ImageUploader');
assert.ok(indexContent.includes('FileUploader'), 'index.ts 需导出 FileUploader');
});
test('2. ImageUploader 必须支持点击放大预览、删除、以及多图模式多选批量上传', () => {
const content = fs.readFileSync(imageUploaderPath, 'utf-8');
assert.ok(content.includes('Upload'), '必须基于 Arco Upload');
assert.ok(content.includes('Image'), '必须使用 Arco Image 组件或 Preview 进行放大预览');
assert.ok(content.includes('multiple'), '必须支持 multiple 属性用于多选批量上传');
assert.ok(content.includes('IconEye') || content.includes('IconZoomIn') || content.includes('preview'), '必须支持点击放大预览');
assert.ok(content.includes('IconDelete') || content.includes('onRemove') || content.includes('handleDelete'), '必须支持点击删除');
assert.ok(!content.includes('any'), '严禁使用 any');
});
test('3. FileUploader 必须支持文件上传、点击预览/打开与点击删除', () => {
const content = fs.readFileSync(fileUploaderPath, 'utf-8');
assert.ok(content.includes('Upload'), '必须基于 Arco Upload');
assert.ok(content.includes('IconDelete') || content.includes('onRemove') || content.includes('handleDelete'), '必须支持删除文件');
assert.ok(!content.includes('any'), '严禁使用 any');
});
test('4. cases 页面封面图与 PDF 必须接入 ImageUploader 与 FileUploader', () => {
const content = fs.readFileSync(casesPagePath, 'utf-8');
assert.ok(content.includes('ImageUploader'), 'cases 必须使用 ImageUploader 上传封面图');
assert.ok(content.includes('FileUploader'), 'cases 必须使用 FileUploader 上传 PDF');
});
test('5. builder 页面必须接入 ImageUploader 管理组件 mediaUrl', () => {
const content = fs.readFileSync(builderPagePath, 'utf-8');
assert.ok(content.includes('ImageUploader'), 'builder 页面 mediaUrl 必须使用 ImageUploader');
});
test('6. articles 页面必须接入 ImageUploader 管理文章封面 coverImage', () => {
const content = fs.readFileSync(articlesPagePath, 'utf-8');
assert.ok(content.includes('ImageUploader'), 'articles 页面必须使用 ImageUploader');
});
test('7. team/brand-kit 页面必须接入 ImageUploader 管理品牌 logoUrl', () => {
const content = fs.readFileSync(brandKitPagePath, 'utf-8');
assert.ok(content.includes('ImageUploader'), 'brand-kit 页面必须使用 ImageUploader');
});
test('8. user/setting/header 必须支持头像放大预览与删除头像', () => {
const content = fs.readFileSync(userSettingHeaderPath, 'utf-8');
assert.ok(content.includes('Image') || content.includes('Modal') || content.includes('preview'), '必须支持头像放大预览');
assert.ok(content.includes('IconDelete') || content.includes('handleDelete') || content.includes('onDelete'), '必须支持头像删除');
});