feat(component): 支持批量导入和导出组件功能

master
钟良源 2 months ago
parent 3401bbc408
commit b866b14e64

@ -47,7 +47,15 @@ export const getImportComponentInfo = (params) => {
// 组件导入
export const importComponent = (params) => {
const formData = new FormData();
// 支持多个文件上传
if (Array.isArray(params.file)) {
params.file.forEach(file => {
formData.append('files', file);
});
}
else {
formData.append('files', params.file);
}
return axios.post(`${urlPrefix}/componentBase/import`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
@ -57,7 +65,7 @@ export const importComponent = (params) => {
// 组件导出
export const exportComponent = (id) => {
return axios.get(`${urlPrefix}/componentBase/export?id=${id}`);
return axios.get(`${urlPrefix}/componentBase/export?id=${id}`, { responseType: 'blob' });
};
// 复制代码和设计

@ -22,7 +22,7 @@ axios.interceptors.request.use(
if (!config.headers) {
config.headers = {};
}
// config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`
}
return config;
},
@ -33,6 +33,11 @@ axios.interceptors.request.use(
// add response interceptors
axios.interceptors.response.use(
(response: AxiosResponse<HttpResponse>) => {
// 如果是 blob 类型响应(文件下载),直接返回 response.data
if (response.config.responseType === 'blob') {
return response.data;
}
const res = response.data;
if (res.code && res.code !== 200) {
Message.error({

@ -13,12 +13,11 @@ interface HandleButtonGroupProps {
onHandlePublishComponent: (row: ComponentItem) => void;
onGoToReview: (row: ComponentItem) => void;
onPublishOrRevokeComponent: (action: 'publish' | 'revoke', identifier: string, version: string) => void;
onNavTo: (id: number, type: string) => void;
onSourceCodeView: (row: ComponentItem) => void;
onShowEdit: (row: ComponentItem, index: number) => void;
onCopyHandler: (row: ComponentItem) => void;
onShareCollaboration: (row: ComponentItem) => void;
onExportComponent: (id: number) => void;
onExportComponent: (row: ComponentItem) => void;
onStopComponentShow: (row: ComponentItem) => void;
onRowDel: (row: ComponentItem) => void;
}
@ -29,7 +28,6 @@ const HandleButtonGroup: React.FC<HandleButtonGroupProps> = ({
onHandlePublishComponent,
onGoToReview,
onPublishOrRevokeComponent,
onNavTo,
onSourceCodeView,
onShowEdit,
onCopyHandler,
@ -69,7 +67,7 @@ const HandleButtonGroup: React.FC<HandleButtonGroupProps> = ({
// 导出组件
items.push(
<Menu.Item key="export">
<Button type="text" onClick={() => onExportComponent(row.id)}></Button>
<Button type="text" onClick={() => onExportComponent(row)}></Button>
</Menu.Item>
);

@ -1,17 +1,22 @@
import React, { useState, useRef } from 'react';
import { Modal, Button, Message, Divider, Upload } from '@arco-design/web-react';
import { IconUpload } from '@arco-design/web-react/icon';
import React, { useState, useRef, useEffect } from 'react';
import { Modal, Button, Message, Divider, Tabs, Checkbox } from '@arco-design/web-react';
import { IconUpload, IconDelete, IconFile } from '@arco-design/web-react/icon';
import styles from './style/importComponentModal.module.less';
interface ImportComponentModalProps {
visible: boolean;
onCancel: () => void;
onOk: (file: File) => void;
onOk: (files: FileItem[]) => void;
onFileSelect: (file: File) => void;
componentInfo: any;
componentInfo: any; // 单次文件解析的结果
loading: boolean;
}
interface FileItem {
file: File;
componentInfo: any[]; // 该文件包含的组件列表
}
const ImportComponentModal: React.FC<ImportComponentModalProps> = ({
visible,
onCancel,
@ -20,21 +25,56 @@ const ImportComponentModal: React.FC<ImportComponentModalProps> = ({
componentInfo,
loading
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [fileItems, setFileItems] = useState<FileItem[]>([]);
const [activeTabKey, setActiveTabKey] = useState<string>('0');
const [pendingFile, setPendingFile] = useState<File | null>(null); // 等待解析的文件
const fileInputRef = useRef<HTMLInputElement>(null);
// 当 componentInfo 更新时,添加到 fileItems
useEffect(() => {
if (pendingFile && componentInfo) {
const componentList = Array.isArray(componentInfo) ? componentInfo : [componentInfo];
// 检查文件是否已存在
const existingIndex = fileItems.findIndex(item => item.file.name === pendingFile.name);
if (existingIndex >= 0) {
// 更新现有文件
const newFileItems = [...fileItems];
newFileItems[existingIndex] = {
file: pendingFile,
componentInfo: componentList
};
setFileItems(newFileItems);
}
else {
// 添加新文件
const newFileItem: FileItem = {
file: pendingFile,
componentInfo: componentList
};
const updatedFileItems = [...fileItems, newFileItem];
setFileItems(updatedFileItems);
setActiveTabKey(String(fileItems.length)); // 切换到新添加的标签页
}
setPendingFile(null);
}
}, [componentInfo]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const files = event.target.files;
if (!files || files.length === 0) return;
// 验证文件类型 (.zip)
const file = files[0];
if (!file.name.match(/\.zip$/i)) {
Message.error('请选择ZIP压缩包文件');
event.target.value = '';
return;
}
setSelectedFile(file);
setPendingFile(file);
onFileSelect(file);
event.target.value = '';
};
@ -43,14 +83,31 @@ const ImportComponentModal: React.FC<ImportComponentModalProps> = ({
fileInputRef.current?.click();
};
// 删除文件
const handleDeleteFile = (index: number) => {
const newFileItems = fileItems.filter((_, i) => i !== index);
setFileItems(newFileItems);
// 如果删除的是当前激活的标签页,切换到第一个
if (String(index) === activeTabKey && newFileItems.length > 0) {
setActiveTabKey('0');
}
};
const handleUpload = () => {
if (selectedFile && componentInfo) {
onOk(selectedFile);
if (fileItems.length === 0) {
Message.warning('请至少选择一个组件');
return;
}
onOk(fileItems);
};
const handleClose = () => {
setSelectedFile(null);
setFileItems([]);
setActiveTabKey('0');
setPendingFile(null);
onCancel();
};
@ -59,12 +116,14 @@ const ImportComponentModal: React.FC<ImportComponentModalProps> = ({
title="导入组件"
visible={visible}
onCancel={handleClose}
style={{ width: 800 }}
style={{ width: 900 }}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Button onClick={handleSelectFile} type="outline" icon={<IconUpload />}>
{selectedFile ? '重新选择文件' : '选择文件'}
</Button>
</div>
<div>
<Button onClick={handleClose} style={{ marginRight: 12 }}>
@ -72,10 +131,10 @@ const ImportComponentModal: React.FC<ImportComponentModalProps> = ({
<Button
type="primary"
onClick={handleUpload}
disabled={!selectedFile || !componentInfo}
disabled={fileItems.length === 0}
loading={loading}
>
</Button>
</div>
</div>
@ -90,92 +149,125 @@ const ImportComponentModal: React.FC<ImportComponentModalProps> = ({
/>
<div className={styles['import-modal-content']}>
{!selectedFile && !componentInfo && (
{fileItems.length === 0 && !pendingFile && (
<div className={styles['empty-state']}>
<p className={styles['empty-text']}>&#34;&#34;</p>
<p className={styles['empty-hint']}> .zip </p>
</div>
)}
{selectedFile && !componentInfo && loading && (
<div className={styles['loading-state']}>
<div className={styles['loading-spinner']}></div>
<p>...</p>
<p className={styles['empty-text']}>&#34;&#34;</p>
<p className={styles['empty-hint']}> .zip </p>
</div>
)}
{selectedFile && componentInfo && (
<div className={styles['component-preview']}>
<div className={styles['file-info']}>
<div className={styles['file-name']}>
<span className={styles['label']}></span>
<span className={styles['value']}>{selectedFile.name}</span>
{fileItems.length > 0 && (
<Tabs
activeTab={activeTabKey}
onChange={setActiveTabKey}
type="card"
>
{fileItems.map((fileItem, fileIndex) => {
const current = fileItems[activeTabKey];
const baseInfo = current.componentInfo[0].baseInfo;
const operates = current.componentInfo[0].operates;
return (
<Tabs.TabPane
key={String(fileIndex)}
title={
<div className={styles['tab-title']}>
<IconFile style={{ marginRight: 4 }} />
<span>{fileItem.file.name}</span>
<Button
type="text"
size="mini"
icon={<IconDelete />}
status="danger"
onClick={(e) => {
e.stopPropagation();
handleDeleteFile(fileIndex);
}}
style={{ marginLeft: 8 }}
/>
</div>
}
>
<div className={styles['file-content']}>
<div className={styles['component-list']}>
<div
className={styles['component-card']}
>
<div className={styles['card-header']}>
<span className={styles['component-name']} style={{ fontWeight: 600, fontSize: 16 }}>
{baseInfo.name || baseInfo.identifier || baseInfo.projectId || '组件信息'}
</span>
</div>
<Divider style={{ margin: '16px 0' }} />
{/* 组件基本信息 */}
<div className={styles['component-info']}>
<div className={styles['info-header']}>
<h3></h3>
</div>
{baseInfo.projectId && (
<div className={styles['info-item']}>
<span className={styles['label']}></span>
<span className={styles['value']}>{componentInfo.identifier || '-'}</span>
<span className={styles['info-label']}>:</span>
<span className={styles['info-value']}>{baseInfo.projectId}</span>
</div>
)}
{baseInfo.codeLanguage && (
<div className={styles['info-item']}>
<span className={styles['label']}></span>
<span className={styles['value']}>{componentInfo.name || '-'}</span>
<span className={styles['info-label']}>:</span>
<span className={styles['info-value']}>{baseInfo.codeLanguage}</span>
</div>
)}
{baseInfo.componentClassify && (
<div className={styles['info-item']}>
<span className={styles['label']}></span>
<span className={styles['value']}>{componentInfo.description || '-'}</span>
<span className={styles['info-label']}>:</span>
<span className={styles['info-value']}>{baseInfo.componentClassify}</span>
</div>
)}
{baseInfo.desc && (
<div className={styles['component-desc']}>
{baseInfo.desc}
</div>
)}
</div>
{componentInfo.operates && componentInfo.operates.length > 0 && (
<>
<Divider style={{ margin: '16px 0' }} />
{/* 接口列表 */}
{operates.length > 0 && (
<div className={styles['operates-section']}>
<h4> ({componentInfo.operates.length})</h4>
<div className={styles['section-title']}>
({operates.length})
</div>
<div className={styles['operates-list']}>
{componentInfo.operates.map((operate: any, index: number) => (
<div key={index} className={styles['operate-item']}>
{operates.map((operate: any, opIndex: number) => (
<div key={opIndex} className={styles['operate-item']}>
<div className={styles['operate-header']}>
<span className={styles['operate-ident']}>{operate.ident}</span>
<span className={styles['operate-type']}>{operate.type}</span>
<span className={styles['operate-ident']}>
{operate.ident}
</span>
<span className={styles['operate-type']}>
{operate.type}
</span>
</div>
{operate.name && (
<div className={styles['operate-name']}>{operate.name}</div>
)}
<div className={styles['operate-params']}>
{operate.parameters && operate.parameters.length > 0 && (
<div className={styles['param-group']}>
<span className={styles['param-label']}></span>
<span className={styles['param-count']}>
{operate.parameters.length}
<span className={styles['param-info']}>
: {operate.parameters.length}
</span>
</div>
)}
{operate.responses && operate.responses.length > 0 && (
<div className={styles['param-group']}>
<span className={styles['param-label']}></span>
<span className={styles['param-count']}>
{operate.responses.length}
<span className={styles['param-info']}>
: {operate.responses.length}
</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
</>
)}
</div>
</div>
</div>
</Tabs.TabPane>
);
})}
</Tabs>
)}
</div>
</Modal>

@ -173,10 +173,6 @@ const GlobalVarContainer = () => {
Message.error(`组件${action === 'publish' ? '公开' : '撤销'}失败: ${error.message || ''}`);
}
}}
onNavTo={(id, type) => {
// TODO: 实现导航逻辑
console.log('Nav to', id, type);
}}
onSourceCodeView={(row) => {
dispatch(updateComponentCodingPath({
localProjectPath: row.localProjectPath,
@ -206,9 +202,9 @@ const GlobalVarContainer = () => {
// TODO: 实现分享协作逻辑
console.log('Share collaboration', row);
}}
onExportComponent={(id) => {
onExportComponent={(row) => {
// 实现导出组件逻辑
onExportComponent(id);
onExportComponent(row);
}}
onStopComponentShow={(row) => {
// TODO: 实现下架组件逻辑
@ -291,7 +287,6 @@ const GlobalVarContainer = () => {
setImportComponentInfo(null);
}
} catch (error) {
console.error('解析组件信息失败:', error);
Message.error('解析组件信息失败');
setImportComponentInfo(null);
} finally {
@ -300,20 +295,21 @@ const GlobalVarContainer = () => {
};
// 处理导入组件 - 确认上传
const handleImportConfirm = async (file: File) => {
const handleImportConfirm = async (files) => {
try {
setImportLoading(true);
const res: any = await importComponent({ file });
if (res.code === 200) {
Message.success('组件导入成功');
const params = {
file: files.map(file => file.file)
};
const res: any = await importComponent(params);
console.log('res:', res);
setImportModalVisible(false);
setImportComponentInfo(null);
fetchComponentData(); // 刷新列表
}
else {
Message.error(res.msg || '导入组件失败');
}
// fetchComponentData(); // 刷新列表
} catch (error) {
console.error('导入组件失败:', error);
Message.error('导入组件失败');
@ -417,9 +413,50 @@ const GlobalVarContainer = () => {
};
// 修改导出组件的回调函数
const onExportComponent = async (id) => {
const onExportComponent = async (row) => {
try {
await exportComponent(id);
const res: any = await exportComponent(row.id);
console.log('导出响应:', {
type: typeof res,
isBlob: res instanceof Blob,
size: res?.size,
blobType: res?.type
});
// 如果后端返回错误,可能会是 JSON 格式
if (res instanceof Blob && res.type === 'application/json') {
const text = await res.text();
const errorData = JSON.parse(text);
console.error('后端返回错误:', errorData);
Message.error(errorData.msg || '导出失败');
return;
}
// 确保 res 是 Blob 对象
if (!(res instanceof Blob)) {
console.error('响应不是 Blob 对象:', res);
Message.error('导出失败:响应格式错误');
return;
}
// 直接使用返回的 blob
const url = window.URL.createObjectURL(res);
const link = document.createElement('a');
link.href = url;
// 设置文件名
const fileName = `${row.name}.zip`;
link.setAttribute('download', fileName);
// 触发下载
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
Message.success('组件导出成功');
} catch (error) {
console.error('导出组件失败:', error);

@ -1,19 +1,14 @@
.import-modal-content {
min-height: 300px;
min-height: 400px;
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
padding: 80px 20px;
text-align: center;
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
color: #1d2129;
@ -31,7 +26,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
padding: 80px 20px;
text-align: center;
.loading-spinner {
@ -56,77 +51,104 @@
}
}
.component-preview {
.file-info {
padding: 12px 16px;
background: #f7f8fa;
border-radius: 4px;
.file-name {
.tab-title {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
max-width: 200px;
.label {
font-weight: 500;
color: #4e5969;
span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.value {
color: #1d2129;
word-break: break-all;
}
.file-content {
padding: 16px 0;
.file-header {
padding: 0 4px;
margin-bottom: 12px;
}
.component-list {
max-height: 500px;
overflow-y: auto;
padding-right: 4px;
.component-card {
margin-bottom: 12px;
padding: 16px;
border: 1px solid #e5e6eb;
border-radius: 4px;
background: #fff;
transition: all 0.2s;
&.selected {
border-color: #165dff;
background: #f2f8ff;
}
.component-info {
.info-header {
margin-bottom: 16px;
.card-header {
margin-bottom: 8px;
h3 {
font-size: 16px;
.component-name {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin: 0;
}
}
.component-desc {
margin: 8px 0 8px 24px;
font-size: 13px;
color: #4e5969;
line-height: 1.6;
}
.component-info {
margin: 12px 0 12px 24px;
.info-item {
display: flex;
margin-bottom: 12px;
line-height: 1.6;
margin-bottom: 8px;
font-size: 13px;
.label {
.info-label {
font-weight: 500;
color: #4e5969;
min-width: 80px;
flex-shrink: 0;
}
.value {
.info-value {
color: #1d2129;
flex: 1;
}
}
}
.operates-section {
h4 {
font-size: 14px;
font-weight: 600;
margin: 12px 0 0 24px;
.section-title {
font-size: 13px;
font-weight: 500;
color: #1d2129;
margin-bottom: 12px;
margin-bottom: 8px;
}
.operates-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
gap: 8px;
.operate-item {
padding: 12px;
background: #f7f8fa;
padding: 10px 12px;
background: #fff;
border: 1px solid #e5e6eb;
border-radius: 4px;
border-left: 3px solid #165dff;
@ -137,8 +159,8 @@
margin-bottom: 6px;
.operate-ident {
font-size: 14px;
font-weight: 600;
font-size: 13px;
font-weight: 500;
color: #1d2129;
}
@ -146,35 +168,19 @@
padding: 2px 8px;
background: #165dff;
color: #fff;
font-size: 12px;
font-size: 11px;
border-radius: 2px;
}
}
.operate-name {
font-size: 13px;
color: #4e5969;
margin-bottom: 8px;
}
.operate-params {
display: flex;
gap: 16px;
font-size: 12px;
.param-group {
display: flex;
align-items: center;
gap: 4px;
.param-label {
.param-info {
color: #86909c;
}
.param-count {
color: #4e5969;
font-weight: 500;
}
}
}
}

Loading…
Cancel
Save