This commit is contained in:
2026-03-12 17:13:13 +08:00
parent 97af24ab2b
commit 914ca64c10
6 changed files with 293 additions and 200 deletions

View File

@@ -295,12 +295,14 @@ interface Domain {
- 显示 App 名称、App ID、所属 Space ID若有、创建时间 - 显示 App 名称、App ID、所属 Space ID若有、创建时间
- App 数据持久化存储,下次打开应用时自动加载 - App 数据持久化存储,下次打开应用时自动加载
- 支持重新加载(覆盖已有数据) - 支持重新加载(覆盖已有数据)
HW|
**FR-BROWSE-002**: 分页显示 BB|**FR-BROWSE-002**: 列表显示
- App 列表支持分页,默认每页 20 条 - App 列表使用可点击列表List 组件)展示,无需分页
- 显示总数量和当前页码 - 全量显示所有 App显示 App 名称和 App ID
- 支持切换页码 - 点击列表项即可选择 App
- 显示总数量和加载时间
- 所属 Space 暂时不显示(后续版本支持)
NK|
**FR-BROWSE-003**: 搜索过滤 **FR-BROWSE-003**: 搜索过滤
- 支持按 App 名称搜索 - 支持按 App 名称搜索
- 搜索结果实时过滤 - 搜索结果实时过滤

View File

@@ -106,11 +106,26 @@ function registerGetDomains(): void {
/** /**
* Create a new domain * Create a new domain
* Deduplication: Check if domain+username already exists
*/ */
function registerCreateDomain(): void { function registerCreateDomain(): void {
handleWithParams<CreateDomainParams, Domain>( handleWithParams<CreateDomainParams, Domain>(
"createDomain", "createDomain",
async (params) => { async (params) => {
// Check for duplicate domain+username
const existingDomains = await listDomains();
const duplicate = existingDomains.find(
(d) =>
d.domain.toLowerCase() === params.domain.toLowerCase() &&
d.username.toLowerCase() === params.username.toLowerCase()
);
if (duplicate) {
throw new Error(
`Domain "${params.domain}" with user "${params.username}" already exists. Please edit the existing domain instead.`
);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const domain: Domain = { const domain: Domain = {
id: uuidv4(), id: uuidv4(),

View File

@@ -10,18 +10,19 @@ import {
theme, theme,
ConfigProvider, ConfigProvider,
App as AntApp, App as AntApp,
Tabs,
Button, Button,
Space, Space,
Dropdown, Dropdown,
Tooltip,
} from "antd"; } from "antd";
import { import {
SettingOutlined, SettingOutlined,
GithubOutlined, GithubOutlined,
CloudServerOutlined, CloudServerOutlined,
AppstoreOutlined,
CloudUploadOutlined, CloudUploadOutlined,
HistoryOutlined, HistoryOutlined,
LeftOutlined,
RightOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
@@ -32,11 +33,14 @@ import { AppDetail } from "@renderer/components/AppDetail";
import { DeployDialog } from "@renderer/components/DeployDialog"; import { DeployDialog } from "@renderer/components/DeployDialog";
const { Header, Content, Sider } = Layout; const { Header, Content, Sider } = Layout;
const { Title, Text } = Typography; const { Title } = Typography;
// Domain section heights // Domain section heights
const DOMAIN_SECTION_COLLAPSED = 56; // Just show current domain const DOMAIN_SECTION_COLLAPSED = 56;
const DOMAIN_SECTION_EXPANDED = 240; // Show full list const DOMAIN_SECTION_EXPANDED = 240;
const DEFAULT_SIDER_WIDTH = 320;
const MIN_SIDER_WIDTH = 280;
const MAX_SIDER_WIDTH = 500;
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
layout: css` layout: css`
@@ -52,12 +56,17 @@ const useStyles = createStyles(({ token, css }) => ({
background: ${token.colorBgContainer}; background: ${token.colorBgContainer};
border-right: 1px solid ${token.colorBorderSecondary}; border-right: 1px solid ${token.colorBorderSecondary};
`, `,
siderCollapsed: css`
width: 0 !important;
min-width: 0 !important;
overflow: hidden;
`,
logo: css` logo: css`
height: 48px; height: 48px;
margin: 8px 16px; margin: 8px 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 8px; gap: 8px;
border-bottom: 1px solid ${token.colorBorderSecondary}; border-bottom: 1px solid ${token.colorBorderSecondary};
`, `,
@@ -72,7 +81,11 @@ const useStyles = createStyles(({ token, css }) => ({
flex-direction: column; flex-direction: column;
`, `,
mainLayout: css` mainLayout: css`
margin-left: 280px; margin-left: ${DEFAULT_SIDER_WIDTH}px;
transition: margin-left 0.2s;
`,
mainLayoutCollapsed: css`
margin-left: 0;
transition: margin-left 0.2s; transition: margin-left 0.2s;
`, `,
header: css` header: css`
@@ -94,12 +107,6 @@ const useStyles = createStyles(({ token, css }) => ({
display: flex; display: flex;
height: 100%; height: 100%;
`, `,
leftPanel: css`
width: 300px;
border-right: 1px solid ${token.colorBorderSecondary};
height: 100%;
overflow: hidden;
`,
rightPanel: css` rightPanel: css`
flex: 1; flex: 1;
height: 100%; height: 100%;
@@ -114,6 +121,28 @@ const useStyles = createStyles(({ token, css }) => ({
overflow: hidden; overflow: hidden;
transition: height 0.2s ease-in-out; transition: height 0.2s ease-in-out;
`, `,
resizeHandle: css`
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
background: transparent;
transition: background 0.2s;
z-index: 10;
&:hover {
background: ${token.colorPrimary};
}
`,
collapseButton: css`
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 1000;
transition: left 0.2s;
`,
})); }));
const App: React.FC = () => { const App: React.FC = () => {
@@ -125,42 +154,122 @@ const App: React.FC = () => {
const { currentDomain } = useDomainStore(); const { currentDomain } = useDomainStore();
const [deployDialogOpen, setDeployDialogOpen] = React.useState(false); const [deployDialogOpen, setDeployDialogOpen] = React.useState(false);
const [domainExpanded, setDomainExpanded] = React.useState(true); const [domainExpanded, setDomainExpanded] = React.useState(true);
const [siderCollapsed, setSiderCollapsed] = React.useState(false);
const [siderWidth, setSiderWidth] = React.useState(DEFAULT_SIDER_WIDTH);
const [isResizing, setIsResizing] = React.useState(false);
const domainSectionHeight = domainExpanded const domainSectionHeight = domainExpanded
? DOMAIN_SECTION_EXPANDED ? DOMAIN_SECTION_EXPANDED
: DOMAIN_SECTION_COLLAPSED; : DOMAIN_SECTION_COLLAPSED;
// Handle resize start
const handleResizeStart = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = siderWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const newWidth = Math.min(
MAX_SIDER_WIDTH,
Math.max(MIN_SIDER_WIDTH, startWidth + delta),
);
setSiderWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[siderWidth],
);
const toggleSider = () => {
setSiderCollapsed(!siderCollapsed);
};
return ( return (
<ConfigProvider locale={zhCN}> <ConfigProvider locale={zhCN}>
<AntApp> <AntApp>
<Layout className={styles.layout}> <Layout className={styles.layout}>
{/* Left Sider - Domain List & App List */} {/* Left Sider - Domain List & App List */}
<Sider width={280} className={styles.sider}> <Sider
<div className={styles.logo}> width={siderWidth}
<CloudServerOutlined style={{ fontSize: 24, color: "#1890ff" }} /> className={`${styles.sider} ${siderCollapsed ? styles.siderCollapsed : ""}`}
<span className={styles.logoText}>Kintone Manager</span> style={{ width: siderCollapsed ? 0 : siderWidth }}
</div> >
<div className={styles.siderContent}> {!siderCollapsed && (
<div <>
className={styles.domainSection} <div className={styles.logo}>
style={{ height: domainSectionHeight }} <div
> style={{ display: "flex", alignItems: "center", gap: 8 }}
<DomainManager >
collapsed={!domainExpanded} <CloudServerOutlined
onToggleCollapse={() => setDomainExpanded(!domainExpanded)} style={{ fontSize: 24, color: "#1890ff" }}
/>
<span className={styles.logoText}>Kintone Manager</span>
</div>
</div>
<div className={styles.siderContent}>
<div
className={styles.domainSection}
style={{ height: domainSectionHeight }}
>
<DomainManager
collapsed={!domainExpanded}
onToggleCollapse={() =>
setDomainExpanded(!domainExpanded)
}
/>
</div>
<div
className={styles.appSection}
style={{ height: `calc(100% - ${domainSectionHeight}px)` }}
>
<AppList />
</div>
</div>
{/* Resize handle */}
<div
className={styles.resizeHandle}
onMouseDown={handleResizeStart}
style={{ background: isResizing ? "#1890ff" : undefined }}
/> />
</div> </>
<div )}
className={styles.appSection}
style={{ height: `calc(100% - ${domainSectionHeight}px)` }}
>
<AppList />
</div>
</div>
</Sider> </Sider>
{/* Collapse/Expand Button */}
<div
className={styles.collapseButton}
style={{ left: siderCollapsed ? 0 : siderWidth }}
>
<Tooltip title={siderCollapsed ? "展开侧边栏" : "收起侧边栏"}>
<Button
type="default"
icon={siderCollapsed ? <RightOutlined /> : <LeftOutlined />}
onClick={toggleSider}
style={{
borderRadius: "0 4px 4px 0",
boxShadow: "2px 0 8px rgba(0,0,0,0.15)",
}}
/>
</Tooltip>
</div>
{/* Main Content */} {/* Main Content */}
<Layout className={styles.mainLayout}> <Layout
className={`${styles.mainLayout} ${siderCollapsed ? styles.mainLayoutCollapsed : ""}`}
style={{ marginLeft: siderCollapsed ? 0 : siderWidth }}
>
<Header className={styles.header}> <Header className={styles.header}>
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
{currentDomain {currentDomain

View File

@@ -1,29 +1,16 @@
/** /**
* AppList Component * AppList Component
* Displays apps in a table with pagination, search, and load functionality * Displays apps in a clickable list (no pagination)
*/ */
import React from "react"; import React from "react";
import { import { List, Button, Input, Empty, Spin, Typography, Space } from "antd";
Table,
Button,
Input,
Empty,
Spin,
Typography,
Tag,
Space,
Tooltip,
Pagination,
} from "antd";
import { import {
ReloadOutlined, ReloadOutlined,
SearchOutlined, SearchOutlined,
AppstoreOutlined, AppstoreOutlined,
ClockCircleOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import type { ColumnsType } from "antd/es/table";
import { useAppStore } from "@renderer/stores"; import { useAppStore } from "@renderer/stores";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import type { KintoneApp } from "@shared/types/kintone"; import type { KintoneApp } from "@shared/types/kintone";
@@ -44,6 +31,7 @@ const useStyles = createStyles(({ token, css }) => ({
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: ${token.paddingSM}px; gap: ${token.paddingSM}px;
flex-shrink: 0;
`, `,
searchWrapper: css` searchWrapper: css`
flex: 1; flex: 1;
@@ -52,14 +40,6 @@ const useStyles = createStyles(({ token, css }) => ({
content: css` content: css`
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: ${token.paddingSM}px;
`,
footer: css`
padding: ${token.paddingSM}px ${token.paddingMD}px;
border-top: 1px solid ${token.colorBorderSecondary};
display: flex;
justify-content: space-between;
align-items: center;
`, `,
loading: css` loading: css`
display: flex; display: flex;
@@ -74,17 +54,52 @@ const useStyles = createStyles(({ token, css }) => ({
align-items: center; align-items: center;
height: 300px; height: 300px;
`, `,
spaceId: css` listItem: css`
cursor: pointer;
transition: background 0.2s;
padding: ${token.paddingSM}px ${token.paddingMD}px !important;
border-bottom: 1px solid ${token.colorBorderSecondary} !important;
&:hover {
background: ${token.colorBgTextHover};
}
`,
listItemActive: css`
background: ${token.colorPrimaryBgHover} !important;
border-left: 3px solid ${token.colorPrimary} !important;
`,
appInfo: css`
display: flex;
align-items: center;
gap: ${token.paddingSM}px;
flex: 1;
min-width: 0;
`,
appName: css`
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
`,
appId: css`
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 12px;
color: ${token.colorTextSecondary};
flex-shrink: 0;
`,
footer: css`
padding: ${token.paddingSM}px ${token.paddingMD}px;
border-top: 1px solid ${token.colorBorderSecondary};
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
`, `,
loadedInfo: css` loadedInfo: css`
color: ${token.colorTextSecondary}; color: ${token.colorTextSecondary};
font-size: 12px; font-size: 12px;
`, `,
clockIcon: css`
color: ${token.colorTextSecondary};
`,
})); }));
const AppList: React.FC = () => { const AppList: React.FC = () => {
@@ -94,18 +109,14 @@ const AppList: React.FC = () => {
apps, apps,
loading, loading,
error, error,
currentPage,
pageSize,
searchText, searchText,
loadedAt, loadedAt,
selectedAppId,
setApps, setApps,
setLoading, setLoading,
setError, setError,
setCurrentPage,
setPageSize,
setSearchText, setSearchText,
setSelectedAppId, setSelectedAppId,
setCurrentApp,
} = useAppStore(); } = useAppStore();
// Load apps from Kintone // Load apps from Kintone
@@ -126,7 +137,6 @@ const AppList: React.FC = () => {
(a, b) => parseInt(b.appId, 10) - parseInt(a.appId, 10), (a, b) => parseInt(b.appId, 10) - parseInt(a.appId, 10),
); );
setApps(sortedApps); setApps(sortedApps);
setCurrentPage(1);
} else { } else {
setError(result.error || "加载应用失败"); setError(result.error || "加载应用失败");
} }
@@ -145,80 +155,34 @@ const AppList: React.FC = () => {
(app) => (app) =>
app.name.toLowerCase().includes(lowerSearch) || app.name.toLowerCase().includes(lowerSearch) ||
app.appId.includes(searchText) || app.appId.includes(searchText) ||
(app.code && app.code.toLowerCase().includes(lowerSearch)) || (app.code && app.code.toLowerCase().includes(lowerSearch)),
(app.spaceId && app.spaceId.includes(searchText)),
); );
}, [apps, searchText]); }, [apps, searchText]);
// Paginated apps // Handle item click
const paginatedApps = React.useMemo(() => { const handleItemClick = (app: KintoneApp) => {
const start = (currentPage - 1) * pageSize; setSelectedAppId(app.appId);
const end = start + pageSize;
return filteredApps.slice(start, end);
}, [filteredApps, currentPage, pageSize]);
// Handle row click
const handleRowClick = (record: KintoneApp) => {
setSelectedAppId(record.appId);
// The AppDetail component will fetch the full app details
}; };
// Table columns // Render list item
const columns: ColumnsType<KintoneApp> = [ const renderItem = (app: KintoneApp) => {
{ const isActive = selectedAppId === app.appId;
title: "App ID",
dataIndex: "appId", return (
key: "appId", <List.Item
width: 100, className={`${styles.listItem} ${isActive ? styles.listItemActive : ""}`}
render: (appId: string) => ( onClick={() => handleItemClick(app)}
<Text code style={{ fontSize: 12 }}> >
{appId} <div className={styles.appInfo}>
</Text> <AppstoreOutlined style={{ color: "#1890ff", fontSize: 16 }} />
), <span className={styles.appName}>{app.name}</span>
}, <Text code className={styles.appId}>
{ ID: {app.appId}
title: "应用名称",
dataIndex: "name",
key: "name",
ellipsis: true,
render: (name: string) => (
<Space>
<AppstoreOutlined style={{ color: "#1890ff" }} />
<Text strong>{name}</Text>
</Space>
),
},
{
title: "所属 Space",
dataIndex: "spaceId",
key: "spaceId",
width: 120,
render: (spaceId?: string) =>
spaceId ? (
<Tooltip title="Space ID">
<Tag color="blue" className={styles.spaceId}>
{spaceId}
</Tag>
</Tooltip>
) : (
<Text type="secondary">-</Text>
),
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (createdAt: string) => (
<Space size={4}>
<ClockCircleOutlined className={styles.clockIcon} />
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(createdAt).toLocaleString("zh-CN")}
</Text> </Text>
</Space> </div>
), </List.Item>
}, );
]; };
if (!currentDomain) { if (!currentDomain) {
return ( return (
@@ -270,21 +234,15 @@ const AppList: React.FC = () => {
</Empty> </Empty>
</div> </div>
) : ( ) : (
<Table <List
columns={columns} dataSource={filteredApps}
dataSource={paginatedApps} renderItem={renderItem}
rowKey="appId" locale={{ emptyText: "没有匹配的应用" }}
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: "pointer" },
})}
/> />
)} )}
</div> </div>
{/* Footer with pagination */} {/* Footer with info */}
{apps.length > 0 && ( {apps.length > 0 && (
<div className={styles.footer}> <div className={styles.footer}>
<div className={styles.loadedInfo}> <div className={styles.loadedInfo}>
@@ -297,24 +255,6 @@ const AppList: React.FC = () => {
{filteredApps.length} {filteredApps.length}
</Text> </Text>
</div> </div>
<Pagination
current={currentPage}
pageSize={pageSize}
total={filteredApps.length}
onChange={(page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
}
}}
showSizeChanger
showQuickJumper
size="small"
pageSizeOptions={["20", "50", "100"]}
showTotal={(total) =>
`${(currentPage - 1) * pageSize + 1}-${Math.min(currentPage * pageSize, total)}`
}
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -91,8 +91,16 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
}); });
if (result.success) { if (result.success) {
// Decode base64 content // Decode base64 content properly for UTF-8 (including Japanese characters)
const decoded = atob(result.data.content || ""); const base64 = result.data.content || "";
const binaryString = atob(base64);
// Decode as UTF-8 to properly handle Japanese and other multi-byte characters
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const decoder = new TextDecoder("utf-8");
const decoded = decoder.decode(bytes);
setContent(decoded); setContent(decoded);
// Detect language from file name // Detect language from file name

View File

@@ -2,6 +2,7 @@
* DomainManager Component * DomainManager Component
* Main container for domain management * Main container for domain management
* Supports collapsed/expanded view * Supports collapsed/expanded view
* Expand/collapse triggered by clicking bottom area
*/ */
import React from "react"; import React from "react";
@@ -23,7 +24,6 @@ const useStyles = createStyles(({ token, css }) => ({
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: ${token.paddingLG}px;
background: ${token.colorBgContainer}; background: ${token.colorBgContainer};
`, `,
collapsedContainer: css` collapsedContainer: css`
@@ -37,7 +37,8 @@ const useStyles = createStyles(({ token, css }) => ({
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: ${token.marginLG}px; margin-bottom: ${token.marginLG}px;
padding-bottom: ${token.paddingMD}px; padding: 0 ${token.paddingLG}px;
padding-top: ${token.paddingLG}px;
border-bottom: 1px solid ${token.colorBorderSecondary}; border-bottom: 1px solid ${token.colorBorderSecondary};
`, `,
title: css` title: css`
@@ -53,6 +54,7 @@ const useStyles = createStyles(({ token, css }) => ({
content: css` content: css`
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: 0 ${token.paddingLG}px;
`, `,
loading: css` loading: css`
display: flex; display: flex;
@@ -102,6 +104,24 @@ const useStyles = createStyles(({ token, css }) => ({
font-size: ${token.fontSizeSM}px; font-size: ${token.fontSizeSM}px;
padding: ${token.paddingSM}px ${token.paddingMD}px; padding: ${token.paddingSM}px ${token.paddingMD}px;
`, `,
// Bottom toggle area - click to expand/collapse
bottomToggleArea: css`
display: flex;
align-items: center;
justify-content: center;
padding: ${token.paddingSM}px;
cursor: pointer;
border-top: 1px solid ${token.colorBorderSecondary};
transition: background 0.2s;
color: ${token.colorTextSecondary};
font-size: 12px;
gap: ${token.paddingXS}px;
&:hover {
background: ${token.colorBgTextHover};
color: ${token.colorPrimary};
}
`,
})); }));
interface DomainManagerProps { interface DomainManagerProps {
@@ -140,12 +160,11 @@ const DomainManager: React.FC<DomainManagerProps> = ({
const handleRefresh = () => { const handleRefresh = () => {
loadDomains(); loadDomains();
}; };
// Collapsed view - show current domain only // Collapsed view - show current domain only
if (collapsed) { if (collapsed) {
return ( return (
<div className={styles.collapsedContainer}> <div className={styles.collapsedContainer}>
<div className={styles.collapsedHeader} onClick={onToggleCollapse}> <div className={styles.collapsedHeader}>
<div className={styles.collapsedInfo}> <div className={styles.collapsedInfo}>
<Avatar <Avatar
icon={<CloudServerOutlined />} icon={<CloudServerOutlined />}
@@ -169,26 +188,23 @@ const DomainManager: React.FC<DomainManagerProps> = ({
)} )}
</div> </div>
</div> </div>
<Space> <Tooltip title="添加 Domain">
<Tooltip title="添加 Domain"> <Button
<Button type="text"
type="text" size="small"
size="small" icon={<PlusOutlined />}
icon={<PlusOutlined />} onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); handleAdd();
handleAdd(); }}
}} />
/> </Tooltip>
</Tooltip> </div>
<Tooltip title="展开">
<Button {/* Bottom toggle area - click to expand */}
type="text" <div className={styles.bottomToggleArea} onClick={onToggleCollapse}>
size="small" <DownOutlined />
icon={<DownOutlined />} <span></span>
/>
</Tooltip>
</Space>
</div> </div>
<DomainForm <DomainForm
@@ -206,9 +222,6 @@ const DomainManager: React.FC<DomainManagerProps> = ({
<div className={styles.header}> <div className={styles.header}>
<h2 className={styles.title}>Domain </h2> <h2 className={styles.title}>Domain </h2>
<div className={styles.actions}> <div className={styles.actions}>
<Tooltip title="收起">
<Button icon={<UpOutlined />} onClick={onToggleCollapse} />
</Tooltip>
<Tooltip title="刷新"> <Tooltip title="刷新">
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
@@ -241,6 +254,12 @@ const DomainManager: React.FC<DomainManagerProps> = ({
)} )}
</div> </div>
{/* Bottom toggle area - click to collapse */}
<div className={styles.bottomToggleArea} onClick={onToggleCollapse}>
<UpOutlined />
<span></span>
</div>
<DomainForm <DomainForm
open={formOpen} open={formOpen}
onClose={handleCloseForm} onClose={handleCloseForm}