kra/web/src/pages/systemTools/version/index.tsx

832 lines
24 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.

/**
* KRA - Version Management Page
* 版本管理页面 - 创建发版、导入版本、下载发版包
*/
import React, { useState, useRef, useEffect } from 'react';
import {
Card,
Table,
Button,
Space,
Form,
Input,
DatePicker,
Modal,
Drawer,
Descriptions,
Tree,
Upload,
message,
Tooltip,
Popconfirm,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { DataNode } from 'antd/es/tree';
import {
SearchOutlined,
ReloadOutlined,
DeleteOutlined,
DownloadOutlined,
UploadOutlined,
InfoCircleOutlined,
QuestionCircleOutlined,
DownOutlined,
UpOutlined,
InboxOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import {
getSysVersionList,
deleteSysVersion,
deleteSysVersionByIds,
findSysVersion,
exportVersion,
importVersion,
downloadVersionJson,
} from '@/services/kratos/version';
import { getMenuList } from '@/services/kratos/menu';
import { getApiList } from '@/services/kratos/api';
import { getDictionaryList } from '@/services/kratos/dictionary';
import { formatDate } from '@/utils/date';
import styles from './index.less';
const { RangePicker } = DatePicker;
const { Dragger } = Upload;
interface SysVersion {
ID: number;
CreatedAt: string;
versionName: string;
versionCode: string;
description?: string;
}
interface ExportForm {
versionName: string;
versionCode: string;
description: string;
menuIds: number[];
apiIds: number[];
dictIds: number[];
}
const VersionPage: React.FC = () => {
const [form] = Form.useForm();
const [exportForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [dataSource, setDataSource] = useState<SysVersion[]>([]);
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [showAllQuery, setShowAllQuery] = useState(false);
// Detail drawer
const [detailVisible, setDetailVisible] = useState(false);
const [detailData, setDetailData] = useState<SysVersion | null>(null);
// Export drawer
const [exportVisible, setExportVisible] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [menuTreeData, setMenuTreeData] = useState<DataNode[]>([]);
const [apiTreeData, setApiTreeData] = useState<DataNode[]>([]);
const [dictTreeData, setDictTreeData] = useState<DataNode[]>([]);
const [checkedMenuKeys, setCheckedMenuKeys] = useState<React.Key[]>([]);
const [checkedApiKeys, setCheckedApiKeys] = useState<React.Key[]>([]);
const [checkedDictKeys, setCheckedDictKeys] = useState<React.Key[]>([]);
const [menuFilterText, setMenuFilterText] = useState('');
const [apiFilterText, setApiFilterText] = useState('');
const [dictFilterText, setDictFilterText] = useState('');
// Import drawer
const [importVisible, setImportVisible] = useState(false);
const [importLoading, setImportLoading] = useState(false);
const [importJsonContent, setImportJsonContent] = useState('');
const [importPreviewData, setImportPreviewData] = useState<any>(null);
// Fetch table data
const fetchData = async () => {
setLoading(true);
try {
const values = form.getFieldsValue();
const params: any = {
page: current,
pageSize,
...values,
};
if (values.createdAtRange) {
params.startCreatedAt = values.createdAtRange[0]?.format('YYYY-MM-DD HH:mm:ss');
params.endCreatedAt = values.createdAtRange[1]?.format('YYYY-MM-DD HH:mm:ss');
delete params.createdAtRange;
}
const res = await getSysVersionList(params);
if (res.code === 0) {
setDataSource(res.data?.list || []);
setTotal(res.data?.total || 0);
}
} catch (error) {
console.error('获取版本列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [current, pageSize]);
// Search
const handleSearch = () => {
setCurrent(1);
fetchData();
};
// Reset
const handleReset = () => {
form.resetFields();
setCurrent(1);
fetchData();
};
// Delete single row
const handleDelete = async (record: SysVersion) => {
try {
const res = await deleteSysVersion({ ID: record.ID });
if (res.code === 0) {
message.success('删除成功');
fetchData();
}
} catch (error) {
message.error('删除失败');
}
};
// Batch delete
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请选择要删除的数据');
return;
}
try {
const res = await deleteSysVersionByIds({ IDs: selectedRowKeys as number[] });
if (res.code === 0) {
message.success('删除成功');
setSelectedRowKeys([]);
fetchData();
}
} catch (error) {
message.error('删除失败');
}
};
// View details
const handleViewDetail = async (record: SysVersion) => {
try {
const res = await findSysVersion({ ID: record.ID });
if (res.code === 0) {
setDetailData(res.data);
setDetailVisible(true);
}
} catch (error) {
message.error('获取详情失败');
}
};
// Download version JSON
const handleDownload = async (record: SysVersion) => {
try {
const res = await downloadVersionJson({ ID: record.ID });
const blob = res instanceof Blob ? res : new Blob([res]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${record.versionName}_${record.versionCode}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success('下载成功');
} catch (error) {
message.error('下载失败');
}
};
// Build menu tree data
const buildMenuTree = (menus: any[]): DataNode[] => {
return menus.map((menu) => ({
key: menu.ID,
title: menu.meta?.title || menu.title || menu.name,
children: menu.children ? buildMenuTree(menu.children) : undefined,
}));
};
// Build API tree data (grouped by apiGroup)
const buildApiTree = (apis: any[]): DataNode[] => {
const groups: Record<string, any[]> = {};
apis.forEach((api) => {
const group = api.apiGroup || '未分组';
if (!groups[group]) {
groups[group] = [];
}
groups[group].push(api);
});
return Object.entries(groups).map(([group, items]) => ({
key: `group_${group}`,
title: `${group}`,
children: items.map((item) => ({
key: `p:${item.path}m:${item.method}`,
title: (
<div className={styles.apiTreeItem}>
<span>{item.description}</span>
<Tooltip title={item.path}>
<span className={styles.apiPath}>{item.path}</span>
</Tooltip>
</div>
),
data: item,
})),
}));
};
// Build dictionary tree data
const buildDictTree = (dicts: any[]): DataNode[] => {
return dicts.map((dict) => ({
key: dict.ID,
title: (
<div className={styles.dictTreeItem}>
<span>{dict.name}</span>
<span className={styles.dictType}>{dict.type}</span>
</div>
),
children: dict.sysDictionaryDetails?.map((detail: any) => ({
key: `detail_${detail.ID}`,
title: (
<div className={styles.dictTreeItem}>
<span>{detail.label}</span>
<span className={styles.dictValue}>: {detail.value}</span>
</div>
),
})),
}));
};
// Open export drawer
const handleOpenExport = async () => {
setExportVisible(true);
try {
// Fetch menu list
const menuRes = await getMenuList();
if (menuRes.code === 0) {
setMenuTreeData(buildMenuTree(menuRes.data?.list || menuRes.data || []));
}
// Fetch API list
const apiRes = await getApiList({ page: 1, pageSize: 9999 });
if (apiRes.code === 0) {
setApiTreeData(buildApiTree(apiRes.data?.list || []));
}
// Fetch dictionary list
const dictRes = await getDictionaryList({ page: 1, pageSize: 9999 });
if (dictRes.code === 0) {
setDictTreeData(buildDictTree(dictRes.data?.list || []));
}
} catch (error) {
message.error('获取数据失败');
}
};
// Close export drawer
const handleCloseExport = () => {
setExportVisible(false);
exportForm.resetFields();
setCheckedMenuKeys([]);
setCheckedApiKeys([]);
setCheckedDictKeys([]);
setMenuFilterText('');
setApiFilterText('');
setDictFilterText('');
};
// Handle export
const handleExport = async () => {
try {
const values = await exportForm.validateFields();
if (!values.versionName || !values.versionCode) {
message.warning('请填写版本名称和版本号');
return;
}
setExportLoading(true);
const menuIds = checkedMenuKeys.filter((k) => typeof k === 'number') as number[];
const apiIds = checkedApiKeys
.filter((k) => typeof k === 'string' && k.startsWith('p:'))
.map((k) => k);
const dictIds = checkedDictKeys.filter((k) => typeof k === 'number') as number[];
const res = await exportVersion({
...values,
menuIds,
apiIds,
dictIds,
});
if (res.code === 0) {
message.success('创建发版成功');
handleCloseExport();
fetchData();
} else {
message.error(res.msg || '创建发版失败');
}
} catch (error) {
message.error('创建发版失败');
} finally {
setExportLoading(false);
}
};
// Open import drawer
const handleOpenImport = () => {
setImportVisible(true);
};
// Close import drawer
const handleCloseImport = () => {
setImportVisible(false);
setImportJsonContent('');
setImportPreviewData(null);
};
// Handle JSON content change
const handleJsonContentChange = (content: string) => {
setImportJsonContent(content);
if (!content.trim()) {
setImportPreviewData(null);
return;
}
try {
const data = JSON.parse(content);
setImportPreviewData({
menus: data.menus || [],
apis: data.apis || [],
dictionaries: data.dictionaries || [],
});
} catch (error) {
setImportPreviewData(null);
}
};
// Handle file upload
const handleFileUpload = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
JSON.parse(content); // Validate JSON
handleJsonContentChange(content);
message.success('文件上传成功');
} catch (error) {
message.error('JSON文件格式错误');
}
};
reader.readAsText(file);
return false; // Prevent auto upload
};
// Handle import
const handleImport = async () => {
if (!importJsonContent.trim()) {
message.warning('请输入版本JSON');
return;
}
try {
JSON.parse(importJsonContent);
} catch (error) {
message.error('JSON格式错误请检查输入内容');
return;
}
setImportLoading(true);
try {
const data = JSON.parse(importJsonContent);
const res = await importVersion(data);
if (res.code === 0) {
message.success('导入成功');
handleCloseImport();
fetchData();
} else {
message.error(res.msg || '导入失败');
}
} catch (error) {
message.error('导入失败');
} finally {
setImportLoading(false);
}
};
// Count total menus recursively
const countMenus = (menus: any[]): number => {
let count = 0;
menus.forEach((menu) => {
count += 1;
if (menu.children?.length) {
count += countMenus(menu.children);
}
});
return count;
};
// Filter tree nodes
const filterTreeNode = (searchValue: string, node: DataNode): boolean => {
const title = typeof node.title === 'string' ? node.title : '';
return title.toLowerCase().includes(searchValue.toLowerCase());
};
const columns: ColumnsType<SysVersion> = [
{
title: '日期',
dataIndex: 'CreatedAt',
key: 'CreatedAt',
width: 180,
sorter: true,
render: (text) => formatDate(text),
},
{
title: '版本名称',
dataIndex: 'versionName',
key: 'versionName',
width: 120,
},
{
title: '版本号',
dataIndex: 'versionCode',
key: 'versionCode',
width: 120,
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 320,
render: (_, record) => (
<Space>
<Button
type="link"
icon={<InfoCircleOutlined />}
onClick={() => handleViewDetail(record)}
>
</Button>
<Button
type="link"
icon={<DownloadOutlined />}
onClick={() => handleDownload(record)}
>
</Button>
<Popconfirm
title="确定要删除吗?"
onConfirm={() => handleDelete(record)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<PageContainer>
{/* Search Form */}
<Card className={styles.searchCard}>
<Form form={form} layout="inline" onFinish={handleSearch}>
<Form.Item
name="createdAtRange"
label={
<span>
<Tooltip title="搜索范围是开始日期(包含)至结束日期(不包含)">
<QuestionCircleOutlined style={{ marginLeft: 4 }} />
</Tooltip>
</span>
}
>
<RangePicker showTime style={{ width: 380 }} />
</Form.Item>
<Form.Item name="versionName" label="版本名称">
<Input placeholder="搜索条件" />
</Form.Item>
<Form.Item name="versionCode" label="版本号">
<Input placeholder="搜索条件" />
</Form.Item>
{showAllQuery && (
<>
{/* Additional query fields can be added here */}
</>
)}
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}>
</Button>
<Button
type="link"
icon={showAllQuery ? <UpOutlined /> : <DownOutlined />}
onClick={() => setShowAllQuery(!showAllQuery)}
>
{showAllQuery ? '收起' : '展开'}
</Button>
</Space>
</Form.Item>
</Form>
</Card>
{/* Table */}
<Card>
<div className={styles.toolbar}>
<Space>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleOpenExport}
>
</Button>
<Button icon={<UploadOutlined />} onClick={handleOpenImport}>
</Button>
<Popconfirm
title="确定要删除选中的数据吗?"
onConfirm={handleBatchDelete}
okText="确定"
cancelText="取消"
disabled={selectedRowKeys.length === 0}
>
<Button
icon={<DeleteOutlined />}
disabled={selectedRowKeys.length === 0}
>
</Button>
</Popconfirm>
</Space>
</div>
<Table
rowKey="ID"
columns={columns}
dataSource={dataSource}
loading={loading}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
current,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t) => `${t}`,
onChange: (page, size) => {
setCurrent(page);
setPageSize(size);
},
}}
scroll={{ x: 800 }}
/>
</Card>
{/* Detail Drawer */}
<Drawer
title="查看"
open={detailVisible}
onClose={() => setDetailVisible(false)}
width={500}
destroyOnClose
>
{detailData && (
<Descriptions column={1} bordered>
<Descriptions.Item label="版本名称">
{detailData.versionName}
</Descriptions.Item>
<Descriptions.Item label="版本号">
{detailData.versionCode}
</Descriptions.Item>
<Descriptions.Item label="版本描述">
{detailData.description}
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
{/* Export Drawer */}
<Drawer
title="创建发版"
open={exportVisible}
onClose={handleCloseExport}
width="80%"
destroyOnClose
extra={
<Space>
<Button onClick={handleCloseExport}></Button>
<Button type="primary" loading={exportLoading} onClick={handleExport}>
</Button>
</Space>
}
>
<Form form={exportForm} layout="vertical">
<Form.Item
name="versionName"
label="版本名称"
rules={[{ required: true, message: '请输入版本名称' }]}
>
<Input placeholder="请输入版本名称" />
</Form.Item>
<Form.Item
name="versionCode"
label="版本号"
rules={[{ required: true, message: '请输入版本号' }]}
>
<Input placeholder="请输入版本号" />
</Form.Item>
<Form.Item name="description" label="版本描述">
<Input.TextArea placeholder="请输入版本描述" rows={3} />
</Form.Item>
<Form.Item label="发版信息">
<div className={styles.treeContainer}>
{/* Menu Tree */}
<div className={styles.treeCard}>
<div className={styles.treeHeader}>
<span className={styles.treeTitle}></span>
</div>
<div className={styles.treeFilter}>
<Input
placeholder="输入关键字进行过滤"
value={menuFilterText}
onChange={(e) => setMenuFilterText(e.target.value)}
allowClear
/>
</div>
<div className={styles.treeBody}>
<Tree
checkable
defaultExpandAll
checkedKeys={checkedMenuKeys}
onCheck={(keys) => setCheckedMenuKeys(keys as React.Key[])}
treeData={menuTreeData}
filterTreeNode={(node) => filterTreeNode(menuFilterText, node)}
/>
</div>
</div>
{/* API Tree */}
<div className={styles.treeCard}>
<div className={styles.treeHeader}>
<span className={styles.treeTitle}>API</span>
</div>
<div className={styles.treeFilter}>
<Input
placeholder="输入关键字进行过滤"
value={apiFilterText}
onChange={(e) => setApiFilterText(e.target.value)}
allowClear
/>
</div>
<div className={styles.treeBody}>
<Tree
checkable
defaultExpandAll
checkedKeys={checkedApiKeys}
onCheck={(keys) => setCheckedApiKeys(keys as React.Key[])}
treeData={apiTreeData}
filterTreeNode={(node) => filterTreeNode(apiFilterText, node)}
/>
</div>
</div>
{/* Dictionary Tree */}
<div className={styles.treeCard}>
<div className={styles.treeHeader}>
<span className={styles.treeTitle}></span>
</div>
<div className={styles.treeFilter}>
<Input
placeholder="输入关键字进行过滤"
value={dictFilterText}
onChange={(e) => setDictFilterText(e.target.value)}
allowClear
/>
</div>
<div className={styles.treeBody}>
<Tree
checkable
defaultExpandAll
checkedKeys={checkedDictKeys}
onCheck={(keys) => setCheckedDictKeys(keys as React.Key[])}
treeData={dictTreeData}
filterTreeNode={(node) => filterTreeNode(dictFilterText, node)}
/>
</div>
</div>
</div>
</Form.Item>
</Form>
</Drawer>
{/* Import Drawer */}
<Drawer
title="导入版本"
open={importVisible}
onClose={handleCloseImport}
width="80%"
destroyOnClose
extra={
<Space>
<Button onClick={handleCloseImport}></Button>
<Button
type="primary"
loading={importLoading}
onClick={handleImport}
disabled={!importJsonContent.trim()}
>
</Button>
</Space>
}
>
<Form layout="vertical">
<Form.Item label="上传文件">
<Dragger
accept=".json"
beforeUpload={handleFileUpload}
showUploadList={true}
maxCount={1}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">JSON文件拖到此处</p>
<p className="ant-upload-hint">JSON文件</p>
</Dragger>
</Form.Item>
<Form.Item label="版本JSON">
<Input.TextArea
value={importJsonContent}
onChange={(e) => handleJsonContentChange(e.target.value)}
rows={10}
placeholder="请粘贴版本JSON"
/>
</Form.Item>
{importPreviewData && (
<Form.Item label="预览内容">
<div className={styles.previewContainer}>
<div className={styles.previewCard}>
<div className={styles.previewHeader}>
({countMenus(importPreviewData.menus)})
</div>
<div className={styles.previewBody}>
<Tree
treeData={buildMenuTree(importPreviewData.menus)}
defaultExpandAll
/>
</div>
</div>
<div className={styles.previewCard}>
<div className={styles.previewHeader}>
API ({importPreviewData.apis?.length || 0})
</div>
<div className={styles.previewBody}>
<Tree
treeData={buildApiTree(importPreviewData.apis)}
defaultExpandAll
/>
</div>
</div>
<div className={styles.previewCard}>
<div className={styles.previewHeader}>
({importPreviewData.dictionaries?.length || 0})
</div>
<div className={styles.previewBody}>
<Tree
treeData={buildDictTree(importPreviewData.dictionaries)}
defaultExpandAll
/>
</div>
</div>
</div>
</Form.Item>
)}
</Form>
</Drawer>
</PageContainer>
);
};
export default VersionPage;