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