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

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

@ -47,7 +47,15 @@ export const getImportComponentInfo = (params) => {
// 组件导入 // 组件导入
export const importComponent = (params) => { export const importComponent = (params) => {
const formData = new FormData(); const formData = new FormData();
formData.append('files', params.file); // 支持多个文件上传
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, { return axios.post(`${urlPrefix}/componentBase/import`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
@ -57,7 +65,7 @@ export const importComponent = (params) => {
// 组件导出 // 组件导出
export const exportComponent = (id) => { 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) { if (!config.headers) {
config.headers = {}; config.headers = {};
} }
// config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
return config; return config;
}, },
@ -33,6 +33,11 @@ axios.interceptors.request.use(
// add response interceptors // add response interceptors
axios.interceptors.response.use( axios.interceptors.response.use(
(response: AxiosResponse<HttpResponse>) => { (response: AxiosResponse<HttpResponse>) => {
// 如果是 blob 类型响应(文件下载),直接返回 response.data
if (response.config.responseType === 'blob') {
return response.data;
}
const res = response.data; const res = response.data;
if (res.code && res.code !== 200) { if (res.code && res.code !== 200) {
Message.error({ Message.error({

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

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

@ -173,10 +173,6 @@ const GlobalVarContainer = () => {
Message.error(`组件${action === 'publish' ? '公开' : '撤销'}失败: ${error.message || ''}`); Message.error(`组件${action === 'publish' ? '公开' : '撤销'}失败: ${error.message || ''}`);
} }
}} }}
onNavTo={(id, type) => {
// TODO: 实现导航逻辑
console.log('Nav to', id, type);
}}
onSourceCodeView={(row) => { onSourceCodeView={(row) => {
dispatch(updateComponentCodingPath({ dispatch(updateComponentCodingPath({
localProjectPath: row.localProjectPath, localProjectPath: row.localProjectPath,
@ -206,9 +202,9 @@ const GlobalVarContainer = () => {
// TODO: 实现分享协作逻辑 // TODO: 实现分享协作逻辑
console.log('Share collaboration', row); console.log('Share collaboration', row);
}} }}
onExportComponent={(id) => { onExportComponent={(row) => {
// 实现导出组件逻辑 // 实现导出组件逻辑
onExportComponent(id); onExportComponent(row);
}} }}
onStopComponentShow={(row) => { onStopComponentShow={(row) => {
// TODO: 实现下架组件逻辑 // TODO: 实现下架组件逻辑
@ -291,7 +287,6 @@ const GlobalVarContainer = () => {
setImportComponentInfo(null); setImportComponentInfo(null);
} }
} catch (error) { } catch (error) {
console.error('解析组件信息失败:', error);
Message.error('解析组件信息失败'); Message.error('解析组件信息失败');
setImportComponentInfo(null); setImportComponentInfo(null);
} finally { } finally {
@ -300,20 +295,21 @@ const GlobalVarContainer = () => {
}; };
// 处理导入组件 - 确认上传 // 处理导入组件 - 确认上传
const handleImportConfirm = async (file: File) => { const handleImportConfirm = async (files) => {
try { try {
setImportLoading(true); setImportLoading(true);
const res: any = await importComponent({ file });
if (res.code === 200) { const params = {
Message.success('组件导入成功'); file: files.map(file => file.file)
setImportModalVisible(false); };
setImportComponentInfo(null);
fetchComponentData(); // 刷新列表 const res: any = await importComponent(params);
}
else { console.log('res:', res);
Message.error(res.msg || '导入组件失败');
} setImportModalVisible(false);
setImportComponentInfo(null);
// fetchComponentData(); // 刷新列表
} catch (error) { } catch (error) {
console.error('导入组件失败:', error); console.error('导入组件失败:', error);
Message.error('导入组件失败'); Message.error('导入组件失败');
@ -417,9 +413,50 @@ const GlobalVarContainer = () => {
}; };
// 修改导出组件的回调函数 // 修改导出组件的回调函数
const onExportComponent = async (id) => { const onExportComponent = async (row) => {
try { 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('组件导出成功'); Message.success('组件导出成功');
} catch (error) { } catch (error) {
console.error('导出组件失败:', error); console.error('导出组件失败:', error);

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

Loading…
Cancel
Save