update UI

This commit is contained in:
2026-03-13 22:37:02 +08:00
parent 23edd0faab
commit 43289845fc
8 changed files with 440 additions and 87 deletions

View File

@@ -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}>

View File

@@ -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];
const lowerSearch = searchText.toLowerCase(); sorted.sort((a, b) => {
return apps.filter( let comparison = 0;
(app) =>
app.name.toLowerCase().includes(lowerSearch) || switch (appSortBy) {
app.appId.includes(searchText) || case "name":
(app.code && app.code.toLowerCase().includes(lowerSearch)), comparison = a.name.localeCompare(b.name);
); break;
}, [apps, searchText]); 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 // 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 }} />
<span className={styles.appName}>{app.name}</span> <Tooltip title={app.name}>
<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>
<Button <Space>
type="primary" <Select
icon={<ReloadOutlined />} value={appSortBy}
onClick={handleLoadApps} onChange={setAppSortBy}
loading={loading} size="small"
> options={[
{apps.length > 0 ? "重新加载" : "加载应用"} { label: "应用ID", value: "appId" },
</Button> { 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> </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>

View File

@@ -218,14 +218,16 @@ 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>
<Button type="primary" onClick={handleSubmit} loading={loading}> <div style={{ display: "flex", gap: 8 }}>
{isEdit ? "更新" : "创建"} <Button onClick={onClose}></Button>
</Button> <Button type="primary" onClick={handleSubmit} loading={loading}>
{isEdit ? "更新" : "创建"}
</Button>
</div>
</div> </div>
</Form.Item> </Form.Item>
</Form> </Form>

View File

@@ -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)}

View File

@@ -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

View File

@@ -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);

View File

@@ -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";

View 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,
}),
},
),
);