update UI
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
|||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import zhCN from "antd/locale/zh_CN";
|
import zhCN from "antd/locale/zh_CN";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from "@renderer/stores";
|
||||||
|
import { useUIStore } from "@renderer/stores";
|
||||||
import { DomainManager } from "@renderer/components/DomainManager";
|
import { DomainManager } from "@renderer/components/DomainManager";
|
||||||
import { AppList } from "@renderer/components/AppList";
|
import { AppList } from "@renderer/components/AppList";
|
||||||
import { AppDetail } from "@renderer/components/AppDetail";
|
import { AppDetail } from "@renderer/components/AppDetail";
|
||||||
@@ -153,10 +154,15 @@ const App: React.FC = () => {
|
|||||||
} = theme.useToken();
|
} = theme.useToken();
|
||||||
|
|
||||||
const { currentDomain } = useDomainStore();
|
const { currentDomain } = useDomainStore();
|
||||||
|
const {
|
||||||
|
sidebarWidth,
|
||||||
|
siderCollapsed,
|
||||||
|
domainExpanded,
|
||||||
|
setSidebarWidth,
|
||||||
|
setSiderCollapsed,
|
||||||
|
setDomainExpanded,
|
||||||
|
} = useUIStore();
|
||||||
const [deployDialogOpen, setDeployDialogOpen] = React.useState(false);
|
const [deployDialogOpen, setDeployDialogOpen] = React.useState(false);
|
||||||
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 [isResizing, setIsResizing] = React.useState(false);
|
||||||
|
|
||||||
const domainSectionHeight = domainExpanded
|
const domainSectionHeight = domainExpanded
|
||||||
@@ -170,7 +176,7 @@ const App: React.FC = () => {
|
|||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
|
|
||||||
const startX = e.clientX;
|
const startX = e.clientX;
|
||||||
const startWidth = siderWidth;
|
const startWidth = sidebarWidth;
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
const delta = moveEvent.clientX - startX;
|
const delta = moveEvent.clientX - startX;
|
||||||
@@ -178,7 +184,7 @@ const App: React.FC = () => {
|
|||||||
MAX_SIDER_WIDTH,
|
MAX_SIDER_WIDTH,
|
||||||
Math.max(MIN_SIDER_WIDTH, startWidth + delta),
|
Math.max(MIN_SIDER_WIDTH, startWidth + delta),
|
||||||
);
|
);
|
||||||
setSiderWidth(newWidth);
|
setSidebarWidth(newWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
@@ -190,7 +196,7 @@ const App: React.FC = () => {
|
|||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
},
|
},
|
||||||
[siderWidth],
|
[sidebarWidth, setSidebarWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleSider = () => {
|
const toggleSider = () => {
|
||||||
@@ -203,9 +209,9 @@ const App: React.FC = () => {
|
|||||||
<Layout className={styles.layout}>
|
<Layout className={styles.layout}>
|
||||||
{/* Left Sider - Domain List & App List */}
|
{/* Left Sider - Domain List & App List */}
|
||||||
<Sider
|
<Sider
|
||||||
width={siderWidth}
|
width={sidebarWidth}
|
||||||
className={`${styles.sider} ${siderCollapsed ? styles.siderCollapsed : ""}`}
|
className={`${styles.sider} ${siderCollapsed ? styles.siderCollapsed : ""}`}
|
||||||
style={{ width: siderCollapsed ? 0 : siderWidth }}
|
style={{ width: siderCollapsed ? 0 : sidebarWidth }}
|
||||||
>
|
>
|
||||||
{!siderCollapsed && (
|
{!siderCollapsed && (
|
||||||
<>
|
<>
|
||||||
@@ -260,7 +266,7 @@ const App: React.FC = () => {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Layout
|
<Layout
|
||||||
className={`${styles.mainLayout} ${siderCollapsed ? styles.mainLayoutCollapsed : ""}`}
|
className={`${styles.mainLayout} ${siderCollapsed ? styles.mainLayoutCollapsed : ""}`}
|
||||||
style={{ marginLeft: siderCollapsed ? 0 : siderWidth }}
|
style={{ marginLeft: siderCollapsed ? 0 : sidebarWidth }}
|
||||||
>
|
>
|
||||||
<Header className={styles.header}>
|
<Header className={styles.header}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
/**
|
/**
|
||||||
* AppList Component
|
* AppList Component
|
||||||
* Displays apps in a clickable list (no pagination)
|
* Displays apps in a clickable list with sorting and pinning
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { List, Button, Input, Empty, Spin, Typography, Space } from "antd";
|
import {
|
||||||
|
List,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Empty,
|
||||||
|
Spin,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Tooltip,
|
||||||
|
Select,
|
||||||
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
PushpinOutlined,
|
||||||
|
PushpinFilled,
|
||||||
|
SortAscendingOutlined,
|
||||||
|
SortDescendingOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import { useAppStore } from "@renderer/stores";
|
import { useAppStore } from "@renderer/stores";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from "@renderer/stores";
|
||||||
|
import { useUIStore } from "@renderer/stores";
|
||||||
import type { AppDetail } from "@shared/types/kintone";
|
import type { AppDetail } from "@shared/types/kintone";
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -35,7 +50,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
`,
|
`,
|
||||||
searchWrapper: css`
|
searchWrapper: css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 300px;
|
max-width: 200px;
|
||||||
`,
|
`,
|
||||||
content: css`
|
content: css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -68,6 +83,9 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
background: ${token.colorPrimaryBgHover} !important;
|
background: ${token.colorPrimaryBgHover} !important;
|
||||||
border-left: 3px solid ${token.colorPrimary} !important;
|
border-left: 3px solid ${token.colorPrimary} !important;
|
||||||
`,
|
`,
|
||||||
|
listItemPinned: css`
|
||||||
|
background: ${token.colorWarningBg} !important;
|
||||||
|
`,
|
||||||
appInfo: css`
|
appInfo: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -100,6 +118,18 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
color: ${token.colorTextSecondary};
|
color: ${token.colorTextSecondary};
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
`,
|
`,
|
||||||
|
pinIcon: css`
|
||||||
|
color: ${token.colorTextTertiary};
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${token.colorWarning};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
pinIconPinned: css`
|
||||||
|
color: ${token.colorWarning};
|
||||||
|
`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const AppList: React.FC = () => {
|
const AppList: React.FC = () => {
|
||||||
@@ -118,6 +148,18 @@ const AppList: React.FC = () => {
|
|||||||
setSearchText,
|
setSearchText,
|
||||||
setSelectedAppId,
|
setSelectedAppId,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
const {
|
||||||
|
pinnedApps,
|
||||||
|
appSortBy,
|
||||||
|
appSortOrder,
|
||||||
|
togglePinnedApp,
|
||||||
|
setAppSortBy,
|
||||||
|
setAppSortOrder,
|
||||||
|
} = useUIStore();
|
||||||
|
|
||||||
|
const currentPinnedApps = currentDomain
|
||||||
|
? pinnedApps[currentDomain.id] || []
|
||||||
|
: [];
|
||||||
|
|
||||||
// Load apps from Kintone
|
// Load apps from Kintone
|
||||||
const handleLoadApps = async () => {
|
const handleLoadApps = async () => {
|
||||||
@@ -132,11 +174,7 @@ const AppList: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Sort by appId descending (newest first)
|
setApps(result.data);
|
||||||
const sortedApps = [...result.data].sort(
|
|
||||||
(a, b) => parseInt(b.appId, 10) - parseInt(a.appId, 10),
|
|
||||||
);
|
|
||||||
setApps(sortedApps);
|
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || "加载应用失败");
|
setError(result.error || "加载应用失败");
|
||||||
}
|
}
|
||||||
@@ -147,35 +185,107 @@ const AppList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter apps by search text
|
// Sort apps
|
||||||
const filteredApps = React.useMemo(() => {
|
const sortApps = (appsToSort: typeof apps) => {
|
||||||
if (!searchText) return apps;
|
const sorted = [...appsToSort];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
|
||||||
|
switch (appSortBy) {
|
||||||
|
case "name":
|
||||||
|
comparison = a.name.localeCompare(b.name);
|
||||||
|
break;
|
||||||
|
case "appId":
|
||||||
|
comparison = parseInt(a.appId, 10) - parseInt(b.appId, 10);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
comparison = parseInt(a.appId, 10) - parseInt(b.appId, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return appSortOrder === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort apps
|
||||||
|
const processedApps = React.useMemo(() => {
|
||||||
|
let filtered = apps;
|
||||||
|
|
||||||
|
// Filter by search text
|
||||||
|
if (searchText) {
|
||||||
const lowerSearch = searchText.toLowerCase();
|
const lowerSearch = searchText.toLowerCase();
|
||||||
return apps.filter(
|
filtered = apps.filter(
|
||||||
(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)),
|
||||||
);
|
);
|
||||||
}, [apps, searchText]);
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
return sortApps(filtered);
|
||||||
|
}, [apps, searchText, appSortBy, appSortOrder]);
|
||||||
|
|
||||||
|
// Separate pinned and unpinned apps
|
||||||
|
const { pinnedAppsList, unpinnedAppsList } = React.useMemo(() => {
|
||||||
|
const pinned: typeof apps = [];
|
||||||
|
const unpinned: typeof apps = [];
|
||||||
|
|
||||||
|
processedApps.forEach((app) => {
|
||||||
|
if (currentPinnedApps.includes(app.appId)) {
|
||||||
|
pinned.push(app);
|
||||||
|
} else {
|
||||||
|
unpinned.push(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pinnedAppsList: pinned, unpinnedAppsList: unpinned };
|
||||||
|
}, [processedApps, currentPinnedApps]);
|
||||||
|
|
||||||
|
// Final display list: pinned first, then unpinned
|
||||||
|
const displayApps = [...pinnedAppsList, ...unpinnedAppsList];
|
||||||
|
|
||||||
// Handle item click
|
// Handle item click
|
||||||
const handleItemClick = (app: AppDetail) => {
|
const handleItemClick = (app: AppDetail) => {
|
||||||
setSelectedAppId(app.appId);
|
setSelectedAppId(app.appId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle pin toggle
|
||||||
|
const handlePinToggle = (e: React.MouseEvent, appId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (currentDomain) {
|
||||||
|
togglePinnedApp(currentDomain.id, appId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle sort order
|
||||||
|
const toggleSortOrder = () => {
|
||||||
|
setAppSortOrder(appSortOrder === "asc" ? "desc" : "asc");
|
||||||
|
};
|
||||||
|
|
||||||
// Render list item
|
// Render list item
|
||||||
const renderItem = (app: AppDetail) => {
|
const renderItem = (app: AppDetail) => {
|
||||||
const isActive = selectedAppId === app.appId;
|
const isActive = selectedAppId === app.appId;
|
||||||
|
const isPinned = currentPinnedApps.includes(app.appId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List.Item
|
<List.Item
|
||||||
className={`${styles.listItem} ${isActive ? styles.listItemActive : ""}`}
|
className={`${styles.listItem} ${isActive ? styles.listItemActive : ""} ${isPinned ? styles.listItemPinned : ""}`}
|
||||||
onClick={() => handleItemClick(app)}
|
onClick={() => handleItemClick(app)}
|
||||||
>
|
>
|
||||||
<div className={styles.appInfo}>
|
<div className={styles.appInfo}>
|
||||||
|
<Tooltip title={isPinned ? "取消置顶" : "置顶应用"}>
|
||||||
|
<span
|
||||||
|
className={`${styles.pinIcon} ${isPinned ? styles.pinIconPinned : ""}`}
|
||||||
|
onClick={(e) => handlePinToggle(e, app.appId)}
|
||||||
|
>
|
||||||
|
{isPinned ? <PushpinFilled /> : <PushpinOutlined />}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
<AppstoreOutlined style={{ color: "#1890ff", fontSize: 16 }} />
|
<AppstoreOutlined style={{ color: "#1890ff", fontSize: 16 }} />
|
||||||
|
<Tooltip title={app.name}>
|
||||||
<span className={styles.appName}>{app.name}</span>
|
<span className={styles.appName}>{app.name}</span>
|
||||||
|
</Tooltip>
|
||||||
<Text code className={styles.appId}>
|
<Text code className={styles.appId}>
|
||||||
ID: {app.appId}
|
ID: {app.appId}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -194,7 +304,7 @@ const AppList: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Header with search and load button */}
|
{/* Header with search, sort and load button */}
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.searchWrapper}>
|
<div className={styles.searchWrapper}>
|
||||||
<Input
|
<Input
|
||||||
@@ -204,16 +314,44 @@ const AppList: React.FC = () => {
|
|||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
allowClear
|
allowClear
|
||||||
disabled={apps.length === 0 && !searchText}
|
disabled={apps.length === 0 && !searchText}
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
value={appSortBy}
|
||||||
|
onChange={setAppSortBy}
|
||||||
|
size="small"
|
||||||
|
options={[
|
||||||
|
{ label: "应用ID", value: "appId" },
|
||||||
|
{ label: "名称", value: "name" },
|
||||||
|
]}
|
||||||
|
style={{ width: 90 }}
|
||||||
|
/>
|
||||||
|
<Tooltip title={appSortOrder === "asc" ? "升序" : "降序"}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={
|
||||||
|
appSortOrder === "asc" ? (
|
||||||
|
<SortAscendingOutlined />
|
||||||
|
) : (
|
||||||
|
<SortDescendingOutlined />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={toggleSortOrder}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title={apps.length > 0 ? "重新加载" : "加载应用"}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={handleLoadApps}
|
onClick={handleLoadApps}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
size="small"
|
||||||
{apps.length > 0 ? "重新加载" : "加载应用"}
|
/>
|
||||||
</Button>
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -235,7 +373,7 @@ const AppList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<List
|
<List
|
||||||
dataSource={filteredApps}
|
dataSource={displayApps}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
locale={{ emptyText: "没有匹配的应用" }}
|
locale={{ emptyText: "没有匹配的应用" }}
|
||||||
/>
|
/>
|
||||||
@@ -252,7 +390,7 @@ const AppList: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text type="secondary" style={{ marginLeft: 16 }}>
|
<Text type="secondary" style={{ marginLeft: 16 }}>
|
||||||
共 {filteredApps.length} 个应用
|
共 {displayApps.length} 个应用
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -218,15 +218,17 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}>
|
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}>
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 8 }}>
|
||||||
<Button onClick={onClose}>取消</Button>
|
|
||||||
<Button onClick={handleTestConnection} loading={testing}>
|
<Button onClick={handleTestConnection} loading={testing}>
|
||||||
测试连接
|
测试连接
|
||||||
</Button>
|
</Button>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
||||||
{isEdit ? "更新" : "创建"}
|
{isEdit ? "更新" : "创建"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* DomainList Component
|
* DomainList Component
|
||||||
* Displays list of domains with connection status
|
* Displays list of domains with connection status and drag-to-reorder
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
QuestionCircleOutlined,
|
QuestionCircleOutlined,
|
||||||
|
HolderOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore, useUIStore } from "@renderer/stores";
|
||||||
import type { Domain } from "@shared/types/domain";
|
import type { Domain } from "@shared/types/domain";
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
@@ -25,6 +26,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
border: 1px solid ${token.colorBorderSecondary};
|
border: 1px solid ${token.colorBorderSecondary};
|
||||||
margin-bottom: ${token.marginSM}px;
|
margin-bottom: ${token.marginSM}px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: ${token.colorPrimary};
|
border-color: ${token.colorPrimary};
|
||||||
@@ -35,18 +37,30 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
border-color: ${token.colorPrimary};
|
border-color: ${token.colorPrimary};
|
||||||
background: ${token.colorPrimaryBg};
|
background: ${token.colorPrimaryBg};
|
||||||
`,
|
`,
|
||||||
|
itemDragging: css`
|
||||||
|
opacity: 0.5;
|
||||||
|
border: 2px dashed ${token.colorPrimary};
|
||||||
|
`,
|
||||||
domainInfo: css`
|
domainInfo: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: ${token.paddingSM}px;
|
gap: ${token.paddingSM}px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
`,
|
`,
|
||||||
domainName: css`
|
domainName: css`
|
||||||
font-weight: ${token.fontWeightStrong};
|
font-weight: ${token.fontWeightStrong};
|
||||||
font-size: ${token.fontSizeLG}px;
|
font-size: ${token.fontSizeLG}px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
`,
|
`,
|
||||||
domainUrl: css`
|
domainUrl: css`
|
||||||
color: ${token.colorTextSecondary};
|
color: ${token.colorTextSecondary};
|
||||||
font-size: ${token.fontSizeSM}px;
|
font-size: ${token.fontSizeSM}px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
`,
|
`,
|
||||||
actions: css`
|
actions: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -55,6 +69,31 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
statusTag: css`
|
statusTag: css`
|
||||||
margin-left: ${token.paddingSM}px;
|
margin-left: ${token.paddingSM}px;
|
||||||
`,
|
`,
|
||||||
|
dragHandle: css`
|
||||||
|
cursor: grab;
|
||||||
|
color: ${token.colorTextTertiary};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${token.colorText};
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
itemContent: css`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${token.paddingSM}px;
|
||||||
|
`,
|
||||||
|
domainText: css`
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface DomainListProps {
|
interface DomainListProps {
|
||||||
@@ -70,7 +109,10 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
|||||||
switchDomain,
|
switchDomain,
|
||||||
deleteDomain,
|
deleteDomain,
|
||||||
testConnection,
|
testConnection,
|
||||||
|
reorderDomains,
|
||||||
} = useDomainStore();
|
} = useDomainStore();
|
||||||
|
const { domainIconColors } = useUIStore();
|
||||||
|
const [draggingIndex, setDraggingIndex] = React.useState<number | null>(null);
|
||||||
|
|
||||||
const handleSelect = (domain: Domain) => {
|
const handleSelect = (domain: Domain) => {
|
||||||
switchDomain(domain);
|
switchDomain(domain);
|
||||||
@@ -104,32 +146,63 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDomainIconColor = (domainId: string, isSelected: boolean) => {
|
||||||
|
if (domainIconColors[domainId]) {
|
||||||
|
return domainIconColors[domainId];
|
||||||
|
}
|
||||||
|
return isSelected ? "#1890ff" : "#87d068";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and drop handlers
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggingIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggingIndex === null || draggingIndex === index) return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggingIndex === null || draggingIndex === index) return;
|
||||||
|
reorderDomains(draggingIndex, index);
|
||||||
|
setDraggingIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggingIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
dataSource={domains}
|
dataSource={domains}
|
||||||
renderItem={(domain) => {
|
renderItem={(domain, index) => {
|
||||||
const isSelected = currentDomain?.id === domain.id;
|
const isSelected = currentDomain?.id === domain.id;
|
||||||
|
const isDragging = draggingIndex === index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`${styles.item} ${isSelected ? styles.selectedItem : ""}`}
|
className={`${styles.item} ${isSelected ? styles.selectedItem : ""} ${isDragging ? styles.itemDragging : ""}`}
|
||||||
onClick={() => handleSelect(domain)}
|
onClick={() => handleSelect(domain)}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={(e) => handleDrop(e, index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div
|
<div className={styles.itemContent}>
|
||||||
style={{
|
<div className={styles.dragHandle}>
|
||||||
display: "flex",
|
<HolderOutlined />
|
||||||
justifyContent: "space-between",
|
</div>
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.domainInfo}>
|
<div className={styles.domainInfo}>
|
||||||
<Avatar
|
<Avatar
|
||||||
icon={<CloudServerOutlined />}
|
icon={<CloudServerOutlined />}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isSelected ? "#1890ff" : "#87d068",
|
backgroundColor: getDomainIconColor(domain.id, isSelected),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div className={styles.domainText}>
|
||||||
<div className={styles.domainName}>
|
<div className={styles.domainName}>
|
||||||
{domain.name}
|
{domain.name}
|
||||||
{getStatusTag(domain.id)}
|
{getStatusTag(domain.id)}
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import React from "react";
|
|||||||
import { Button, Empty, Spin, Tooltip, Avatar, Space } from "antd";
|
import { Button, Empty, Spin, Tooltip, Avatar, Space } from "antd";
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
|
||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
UpOutlined,
|
UpOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore, useUIStore } from "@renderer/stores";
|
||||||
import DomainList from "./DomainList";
|
import DomainList from "./DomainList";
|
||||||
import DomainForm from "./DomainForm";
|
import DomainForm from "./DomainForm";
|
||||||
|
|
||||||
@@ -104,24 +103,31 @@ 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
|
// Bottom toggle icon - minimal style
|
||||||
bottomToggleArea: css`
|
bottomToggleIcon: css`
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: ${token.paddingSM}px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-top: 1px solid ${token.colorBorderSecondary};
|
color: ${token.colorTextTertiary};
|
||||||
transition: background 0.2s;
|
|
||||||
color: ${token.colorTextSecondary};
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
gap: ${token.paddingXS}px;
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: ${token.colorBgTextHover};
|
opacity: 1;
|
||||||
color: ${token.colorPrimary};
|
background: ${token.colorFillTertiary};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
bottomToggleVisible: css`
|
||||||
|
opacity: 0.6;
|
||||||
|
`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface DomainManagerProps {
|
interface DomainManagerProps {
|
||||||
@@ -135,8 +141,10 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const { domains, loading, loadDomains, currentDomain } = useDomainStore();
|
const { domains, loading, loadDomains, currentDomain } = useDomainStore();
|
||||||
|
const { domainIconColors } = useUIStore();
|
||||||
const [formOpen, setFormOpen] = React.useState(false);
|
const [formOpen, setFormOpen] = React.useState(false);
|
||||||
const [editingDomain, setEditingDomain] = React.useState<string | null>(null);
|
const [editingDomain, setEditingDomain] = React.useState<string | null>(null);
|
||||||
|
const [isHoveringToggle, setIsHoveringToggle] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadDomains();
|
loadDomains();
|
||||||
@@ -157,9 +165,11 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
|||||||
setEditingDomain(null);
|
setEditingDomain(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const getDomainIconColor = (domainId: string | undefined) => {
|
||||||
loadDomains();
|
if (!domainId) return "#d9d9d9";
|
||||||
|
return domainIconColors[domainId] || "#1890ff";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collapsed view - show current domain only
|
// Collapsed view - show current domain only
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
@@ -170,7 +180,7 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
|||||||
icon={<CloudServerOutlined />}
|
icon={<CloudServerOutlined />}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: currentDomain ? "#1890ff" : "#d9d9d9",
|
backgroundColor: getDomainIconColor(currentDomain?.id),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
@@ -201,10 +211,14 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom toggle area - click to expand */}
|
{/* Bottom toggle icon */}
|
||||||
<div className={styles.bottomToggleArea} onClick={onToggleCollapse}>
|
<div
|
||||||
|
className={`${styles.bottomToggleIcon} ${isHoveringToggle ? styles.bottomToggleVisible : ""}`}
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
onMouseEnter={() => setIsHoveringToggle(true)}
|
||||||
|
onMouseLeave={() => setIsHoveringToggle(false)}
|
||||||
|
>
|
||||||
<DownOutlined />
|
<DownOutlined />
|
||||||
<span>点击展开</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DomainForm
|
<DomainForm
|
||||||
@@ -218,17 +232,10 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
|||||||
|
|
||||||
// Expanded view - full list
|
// Expanded view - full list
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container} style={{ position: "relative" }}>
|
||||||
<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={<ReloadOutlined />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||||
添加
|
添加
|
||||||
</Button>
|
</Button>
|
||||||
@@ -254,10 +261,14 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom toggle area - click to collapse */}
|
{/* Bottom toggle icon */}
|
||||||
<div className={styles.bottomToggleArea} onClick={onToggleCollapse}>
|
<div
|
||||||
|
className={`${styles.bottomToggleIcon} ${isHoveringToggle ? styles.bottomToggleVisible : ""}`}
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
onMouseEnter={() => setIsHoveringToggle(true)}
|
||||||
|
onMouseLeave={() => setIsHoveringToggle(false)}
|
||||||
|
>
|
||||||
<UpOutlined />
|
<UpOutlined />
|
||||||
<span>点击收起</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DomainForm
|
<DomainForm
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface DomainState {
|
|||||||
addDomain: (domain: Domain) => void;
|
addDomain: (domain: Domain) => void;
|
||||||
updateDomain: (domain: Domain) => void;
|
updateDomain: (domain: Domain) => void;
|
||||||
removeDomain: (id: string) => void;
|
removeDomain: (id: string) => void;
|
||||||
|
reorderDomains: (fromIndex: number, toIndex: number) => void;
|
||||||
|
setCurrentDomain: (domain: Domain | null) => void;
|
||||||
setCurrentDomain: (domain: Domain | null) => void;
|
setCurrentDomain: (domain: Domain | null) => void;
|
||||||
setConnectionStatus: (
|
setConnectionStatus: (
|
||||||
id: string,
|
id: string,
|
||||||
@@ -80,6 +82,14 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
state.currentDomain?.id === id ? null : state.currentDomain,
|
state.currentDomain?.id === id ? null : state.currentDomain,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
reorderDomains: (fromIndex, toIndex) =>
|
||||||
|
set((state) => {
|
||||||
|
const domains = [...state.domains];
|
||||||
|
const [removed] = domains.splice(fromIndex, 1);
|
||||||
|
domains.splice(toIndex, 0, removed);
|
||||||
|
return { domains };
|
||||||
|
}),
|
||||||
|
|
||||||
setCurrentDomain: (domain) => set({ currentDomain: domain }),
|
setCurrentDomain: (domain) => set({ currentDomain: domain }),
|
||||||
|
|
||||||
setConnectionStatus: (id, status, error) =>
|
setConnectionStatus: (id, status, error) =>
|
||||||
@@ -116,6 +126,15 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
createDomain: async (params: CreateDomainParams) => {
|
createDomain: async (params: CreateDomainParams) => {
|
||||||
|
// Check for duplicate domain
|
||||||
|
const existingDomain = get().domains.find(
|
||||||
|
(d) => d.domain === params.domain && d.username === params.username
|
||||||
|
);
|
||||||
|
if (existingDomain) {
|
||||||
|
set({ error: "该 Domain 已存在", loading: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const result = await window.api.createDomain(params);
|
const result = await window.api.createDomain(params);
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ export { useDomainStore } from "./domainStore";
|
|||||||
export { useAppStore } from "./appStore";
|
export { useAppStore } from "./appStore";
|
||||||
export { useDeployStore } from "./deployStore";
|
export { useDeployStore } from "./deployStore";
|
||||||
export { useVersionStore } from "./versionStore";
|
export { useVersionStore } from "./versionStore";
|
||||||
|
|
||||||
|
export { useUIStore } from "./uiStore";
|
||||||
102
src/renderer/src/stores/uiStore.ts
Normal file
102
src/renderer/src/stores/uiStore.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* UI Store
|
||||||
|
* Manages UI state persistence (sidebar width, collapsed states, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
// Sidebar state
|
||||||
|
sidebarWidth: number;
|
||||||
|
siderCollapsed: boolean;
|
||||||
|
domainExpanded: boolean;
|
||||||
|
|
||||||
|
// Domain customizations
|
||||||
|
domainIconColors: Record<string, string>;
|
||||||
|
|
||||||
|
// App customizations
|
||||||
|
pinnedApps: Record<string, string[]>; // domainId -> appId[]
|
||||||
|
appSortBy: "createdAt" | "modifiedAt" | "name" | "appId";
|
||||||
|
appSortOrder: "asc" | "desc";
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSidebarWidth: (width: number) => void;
|
||||||
|
setSiderCollapsed: (collapsed: boolean) => void;
|
||||||
|
setDomainExpanded: (expanded: boolean) => void;
|
||||||
|
setDomainIconColor: (domainId: string, color: string) => void;
|
||||||
|
setPinnedApps: (domainId: string, appIds: string[]) => void;
|
||||||
|
togglePinnedApp: (domainId: string, appId: string) => void;
|
||||||
|
setAppSortBy: (sortBy: "createdAt" | "modifiedAt" | "name" | "appId") => void;
|
||||||
|
setAppSortOrder: (order: "asc" | "desc") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_SIDEBAR_WIDTH = 280;
|
||||||
|
const MAX_SIDEBAR_WIDTH = 500;
|
||||||
|
const DEFAULT_SIDEBAR_WIDTH = 360;
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||||
|
siderCollapsed: false,
|
||||||
|
domainExpanded: true,
|
||||||
|
domainIconColors: {},
|
||||||
|
pinnedApps: {},
|
||||||
|
appSortBy: "appId",
|
||||||
|
appSortOrder: "desc",
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSidebarWidth: (width) =>
|
||||||
|
set({
|
||||||
|
sidebarWidth: Math.min(
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
Math.max(MIN_SIDEBAR_WIDTH, width),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSiderCollapsed: (collapsed) => set({ siderCollapsed: collapsed }),
|
||||||
|
|
||||||
|
setDomainExpanded: (expanded) => set({ domainExpanded: expanded }),
|
||||||
|
|
||||||
|
setDomainIconColor: (domainId, color) =>
|
||||||
|
set((state) => ({
|
||||||
|
domainIconColors: { ...state.domainIconColors, [domainId]: color },
|
||||||
|
})),
|
||||||
|
|
||||||
|
setPinnedApps: (domainId, appIds) =>
|
||||||
|
set((state) => ({
|
||||||
|
pinnedApps: { ...state.pinnedApps, [domainId]: appIds },
|
||||||
|
})),
|
||||||
|
|
||||||
|
togglePinnedApp: (domainId, appId) =>
|
||||||
|
set((state) => {
|
||||||
|
const currentPinned = state.pinnedApps[domainId] || [];
|
||||||
|
const isPinned = currentPinned.includes(appId);
|
||||||
|
const newPinned = isPinned
|
||||||
|
? currentPinned.filter((id) => id !== appId)
|
||||||
|
: [appId, ...currentPinned];
|
||||||
|
return {
|
||||||
|
pinnedApps: { ...state.pinnedApps, [domainId]: newPinned },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setAppSortBy: (sortBy) => set({ appSortBy: sortBy }),
|
||||||
|
|
||||||
|
setAppSortOrder: (order) => set({ appSortOrder: order }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "ui-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
sidebarWidth: state.sidebarWidth,
|
||||||
|
siderCollapsed: state.siderCollapsed,
|
||||||
|
domainExpanded: state.domainExpanded,
|
||||||
|
domainIconColors: state.domainIconColors,
|
||||||
|
pinnedApps: state.pinnedApps,
|
||||||
|
appSortBy: state.appSortBy,
|
||||||
|
appSortOrder: state.appSortOrder,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user