From 51ccd265ba31f817adcf10546abdf415d0d36a0a Mon Sep 17 00:00:00 2001 From: xue jiahao Date: Thu, 12 Mar 2026 15:45:40 +0800 Subject: [PATCH] remove spaces --- REQUIREMENTS.md | 90 +++-- src/main/ipc-handlers.ts | 22 +- src/main/kintone-api.ts | 71 ++-- src/preload/index.d.ts | 3 - src/preload/index.ts | 3 - src/renderer/src/App.tsx | 10 +- .../src/components/AppDetail/AppDetail.tsx | 4 +- .../src/components/AppList/AppList.tsx | 323 ++++++++++++++++++ src/renderer/src/components/AppList/index.ts | 6 + .../src/components/SpaceTree/SpaceTree.tsx | 228 ------------- .../src/components/SpaceTree/index.ts | 6 - src/renderer/src/stores/appStore.ts | 98 ++++-- 12 files changed, 506 insertions(+), 358 deletions(-) create mode 100644 src/renderer/src/components/AppList/AppList.tsx create mode 100644 src/renderer/src/components/AppList/index.ts delete mode 100644 src/renderer/src/components/SpaceTree/SpaceTree.tsx delete mode 100644 src/renderer/src/components/SpaceTree/index.ts diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c66b412..7ef9279 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -178,8 +178,7 @@ kintone-customize-manager/ │ │ ├─ main.tsx # 渲染进程入口 │ │ ├─ App.tsx │ │ ├─ components/ # React 组件 -│ │ │ ├─ DomainManager/ -│ │ │ ├─ SpaceTree/ +│ │ │ ├─ AppList/ │ │ │ ├─ AppDetail/ │ │ │ ├─ FileUploader/ │ │ │ ├─ CodeViewer/ @@ -252,8 +251,7 @@ kintone-customize-manager/ - 显示 Domain 名称和域名 **FR-DOMAIN-005**: 切换 Domain -- 用户可从 Domain 列表快速切换当前工作的 Domain -- 切换后自动加载该 Domain 下的 Space 和 App 列表 +- 切换后自动加载该 Domain 下的 App 列表 **FR-DOMAIN-006**: 密码加密存储 - 使用 keytar 将密码加密存储到系统密钥链 @@ -287,40 +285,43 @@ interface Domain { ### 3.2 资源浏览 -#### 3.2.1 功能描述 +浏览当前 Domain 下的所有 App,查看应用的自定义资源配置。 -浏览当前 Domain 下的所有 Space 和 App,查看应用的自定义资源配置。 +**FR-BROWSE-001**: 获取 App 列表 +- 选择 Domain 后显示「加载应用」按钮 +- 点击按钮后全量获取该 Domain 下的所有 App +- 获取过程中显示加载状态 +- App 列表按 App ID 降序排列 +- 显示 App 名称、App ID、所属 Space ID(若有)、创建时间 +- App 数据持久化存储,下次打开应用时自动加载 +- 支持重新加载(覆盖已有数据) -#### 3.2.2 功能需求 +**FR-BROWSE-002**: 分页显示 +- App 列表支持分页,默认每页 20 条 +- 显示总数量和当前页码 +- 支持切换页码 -**FR-BROWSE-001**: 获取 Space 列表 -- 调用 Kintone API 获取当前 Domain 下的所有 Space -- 显示 Space 名称和 ID -- 支持按 Space 名称排序 +**FR-BROWSE-003**: 搜索过滤 +- 支持按 App 名称搜索 +- 搜索结果实时过滤 -**FR-BROWSE-002**: 获取 App 列表 -- 按 Space 分组显示 App 列表 -- 或显示所有 App(不分 Space) -- 显示 App 名称、ID、创建时间 -- 支持按 App 名称搜索和过滤 - -**FR-BROWSE-003**: 获取 App 详情 +**FR-BROWSE-004**: 获取 App 详情 - 选择 App 后查看详细配置 - 显示以下信息: - App 名称 - App ID - - 所属 Space + - 所属 Space ID(如果 App 隶属于某个 Space) - 创建时间 - 最后更新时间 -**FR-BROWSE-004**: 查看自定义资源配置 +**FR-BROWSE-005**: 查看自定义资源配置 - 查看 PC 端的 JavaScript 文件配置 - 查看 PC 端的 CSS 文件配置 - 查看移动端的 JavaScript 文件配置 - 查看移动端的 CSS 文件配置 - 查看已安装的 Plugin 列表(只读,后续版本支持管理) -**FR-BROWSE-005**: 查看文件详情 +**FR-BROWSE-006**: 查看文件详情 - 文件名 - 文件类型(JS/CSS) - 部署位置(PC/移动端) @@ -331,11 +332,12 @@ interface Domain { #### 3.2.3 Kintone API 端点 ``` -# 获取 Space 列表 -GET /k/v1/space.json - -# 获取 App 列表(按 Space) -GET /k/v1/apps.json?space={spaceId} +# 获取 App 列表(支持分页) +GET /k/v1/apps.json?limit={limit}&offset={offset} +# 限制:limit 最大 100,offset 从 0 开始 +# 注意:API 不支持排序,需客户端排序 +# 注意:API 不返回 totalCount,需通过返回数量判断是否还有更多 +# 注意:spaceId 已包含在响应中(null 表示不属于任何 Space) # 获取 App 配置 GET /k/v1/app.json?app={appId} @@ -344,6 +346,37 @@ GET /k/v1/app.json?app={appId} GET /k/v1/file.json?fileKey={fileKey} ``` +#### 3.2.4 数据结构 + +```typescript +// App 列表项 +interface KintoneApp { + appId: string; + name: string; + code?: string; + spaceId?: string; // 如果 App 隶属于某个 Space,显示此 ID + createdAt: string; + creator?: { + code: string; + name: string; + }; + modifiedAt?: string; + modifier?: { + code: string; + name: string; + }; +} + +// App 列表状态(持久化) +interface AppListState { + apps: KintoneApp[]; + loadedAt: string; // 加载时间 + totalCount: number; + currentPage: number; + pageSize: number; +} +``` + --- ### 3.3 下载已部署代码 @@ -889,8 +922,7 @@ autoUpdater.on('update-downloaded', (info) => { | 术语 | 说明 | |------|------| -| Domain | Kintone 实例,如 company.kintone.com | -| Space | Kintone 空间,应用的容器 | +| Space | Kintone 空间,应用的容器(注:API 不支持获取所有 Space) | | App | Kintone 应用 | | FileKey | Kintone 文件的唯一标识 | | IPC | Electron 进程间通信 | @@ -901,8 +933,8 @@ autoUpdater.on('update-downloaded', (info) => { | 版本 | 日期 | 变更内容 | 作者 | |------|------|----------|------| -| 1.0.0 | 2026-03-11 | 初始版本 | - | | 1.1.0 | 2026-03-11 | 更新技术栈:LobeHub UI、CodeMirror 6、safeStorage | - | +| 1.2.0 | 2026-03-12 | 移除 Space 功能,改为 App 列表分页显示;支持持久化存储 App 数据 | - | --- diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index d055d1b..42f0885 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -22,7 +22,6 @@ import type { CreateDomainParams, UpdateDomainParams, TestDomainConnectionParams, - GetSpacesParams, GetAppsParams, GetAppDetailParams, GetFileContentParams, @@ -237,19 +236,6 @@ function registerTestDomainConnection(): void { // ==================== Browse IPC Handlers ==================== -/** - * Get spaces - */ -function registerGetSpaces(): void { - handleWithParams< - GetSpacesParams, - Awaited> - >("getSpaces", async (params) => { - const client = await getClient(params.domainId); - return client.getSpaces(); - }); -} - /** * Get apps */ @@ -259,10 +245,15 @@ function registerGetApps(): void { Awaited> >("getApps", async (params) => { const client = await getClient(params.domainId); - return client.getApps(params.spaceId); + return client.getApps({ + limit: params.limit, + offset: params.offset, + }); }); } + + /** * Get app detail */ @@ -553,7 +544,6 @@ export function registerIpcHandlers(): void { registerTestDomainConnection(); // Browse - registerGetSpaces(); registerGetApps(); registerGetAppDetail(); registerGetFileContent(); diff --git a/src/main/kintone-api.ts b/src/main/kintone-api.ts index f835b1c..5011839 100644 --- a/src/main/kintone-api.ts +++ b/src/main/kintone-api.ts @@ -2,7 +2,6 @@ import { KintoneRestAPIClient } from "@kintone/rest-api-client"; import type { KintoneRestAPIError } from "@kintone/rest-api-client"; import type { DomainWithPassword } from "@shared/types/domain"; import type { - KintoneSpace, KintoneApp, AppDetail, FileContent, @@ -35,7 +34,6 @@ export class KintoneError extends Error { // Use typeof to get SDK method return types type KintoneClient = KintoneRestAPIClient; -type SpaceResponse = ReturnType; type AppResponse = ReturnType; type AppsResponse = ReturnType; type AppCustomizeResponse = ReturnType; @@ -89,15 +87,6 @@ export class SelfKintoneClient { } } - private mapSpace(space: Awaited): KintoneSpace { - return { - id: space.id, - name: space.name, - code: "", // Space API doesn't return code - createdAt: undefined, // Space API doesn't return createdAt - creator: { code: space.creator.code, name: space.creator.name }, - }; - } private mapApp(app: Awaited): KintoneApp { return { @@ -150,30 +139,45 @@ export class SelfKintoneClient { }; } - // ==================== Space APIs ==================== - - // Note: Kintone REST API does not provide an endpoint to list all spaces. - // Use getSpace(spaceId) to retrieve individual spaces. - async getSpaces(): Promise { - // Since SDK doesn't have getSpaces and REST API doesn't support listing spaces, - // we return empty array. Users should use getSpace() for individual spaces. - return []; - } - - async getSpace(spaceId: string): Promise { - return this.withErrorHandling(async () => { - const response = await this.client.space.getSpace({ id: spaceId }); - return this.mapSpace(response); - }); - } - // ==================== App APIs ==================== - async getApps(spaceId?: string): Promise { + /** + * Get all apps with pagination support + * Fetches all apps by making multiple requests if needed + */ + async getApps(options?: { + limit?: number; + offset?: number; + }): Promise { return this.withErrorHandling(async () => { - const params = spaceId ? { spaceIds: [spaceId] } : {}; - const response = await this.client.app.getApps(params); - return response.apps.map((app) => this.mapApp(app)); + // If pagination options provided, use them directly + if (options?.limit !== undefined || options?.offset !== undefined) { + const params: { limit?: number; offset?: number } = {}; + if (options.limit) params.limit = options.limit; + if (options.offset) params.offset = options.offset; + const response = await this.client.app.getApps(params); + return response.apps.map((app) => this.mapApp(app)); + } + + // Otherwise, fetch all apps (pagination handled internally) + const allApps: Awaited["apps"] = []; + const limit = 100; // Max allowed by Kintone API + let offset = 0; + let hasMore = true; + + while (hasMore) { + const response = await this.client.app.getApps({ limit, offset }); + allApps.push(...response.apps); + + // If we got fewer than limit, we've reached the end + if (response.apps.length < limit) { + hasMore = false; + } else { + offset += limit; + } + } + + return allApps.map((app) => this.mapApp(app)); }); } @@ -283,7 +287,8 @@ export class SelfKintoneClient { async testConnection(): Promise<{ success: boolean; error?: string }> { try { - await this.getApps(); + // Use limit=1 to minimize data transfer for faster connection testing + await this.client.app.getApps({ limit: 1 }); return { success: true }; } catch (error) { return { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 0dcf011..fde3def 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -4,7 +4,6 @@ import type { CreateDomainParams, UpdateDomainParams, TestDomainConnectionParams, - GetSpacesParams, GetAppsParams, GetAppDetailParams, GetFileContentParams, @@ -17,7 +16,6 @@ import type { } from "@shared/types/ipc"; import type { Domain, DomainWithStatus } from "@shared/types/domain"; import type { - KintoneSpace, KintoneApp, AppDetail, FileContent, @@ -44,7 +42,6 @@ export interface SelfAPI { testDomainConnection: (params: TestDomainConnectionParams) => Promise>; // ==================== Browse ==================== - getSpaces: (params: GetSpacesParams) => Promise>; getApps: (params: GetAppsParams) => Promise>; getAppDetail: (params: GetAppDetailParams) => Promise>; getFileContent: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 2cc2c17..a3841f4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -16,10 +16,7 @@ const api: SelfAPI = { testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params), // ==================== Browse ==================== - getSpaces: (params) => ipcRenderer.invoke("getSpaces", params), getApps: (params) => ipcRenderer.invoke("getApps", params), - getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params), - getFileContent: (params) => ipcRenderer.invoke("getFileContent", params), // ==================== Deploy ==================== deploy: (params) => ipcRenderer.invoke("deploy", params), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 43905b2..9d3857a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -27,7 +27,7 @@ import { createStyles } from "antd-style"; import zhCN from "antd/locale/zh_CN"; import { useDomainStore } from "@renderer/stores"; import { DomainManager } from "@renderer/components/DomainManager"; -import { SpaceTree } from "@renderer/components/SpaceTree"; +import { AppList } from "@renderer/components/AppList"; import { AppDetail } from "@renderer/components/AppDetail"; import { DeployDialog } from "@renderer/components/DeployDialog"; @@ -110,7 +110,7 @@ const useStyles = createStyles(({ token, css }) => ({ overflow: hidden; transition: height 0.2s ease-in-out; `, - spaceSection: css` + appSection: css` overflow: hidden; transition: height 0.2s ease-in-out; `, @@ -134,7 +134,7 @@ const App: React.FC = () => { - {/* Left Sider - Domain List & Space Tree */} + {/* Left Sider - Domain List & App List */}
@@ -151,10 +151,10 @@ const App: React.FC = () => { />
- +
diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index c9b7816..8e6d7c4 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -272,8 +272,8 @@ const AppDetail: React.FC = () => { {currentApp.modifier?.name} - - {currentApp.spaceName || currentApp.spaceId || "-"} + + {currentApp.spaceId || "-"} ), diff --git a/src/renderer/src/components/AppList/AppList.tsx b/src/renderer/src/components/AppList/AppList.tsx new file mode 100644 index 0000000..403bcef --- /dev/null +++ b/src/renderer/src/components/AppList/AppList.tsx @@ -0,0 +1,323 @@ +/** + * AppList Component + * Displays apps in a table with pagination, search, and load functionality + */ + +import React from "react"; +import { + Table, + Button, + Input, + Empty, + Spin, + Typography, + Tag, + Space, + Tooltip, + Pagination, +} from "antd"; +import { + ReloadOutlined, + SearchOutlined, + AppstoreOutlined, + ClockCircleOutlined, +} from "@ant-design/icons"; +import { createStyles } from "antd-style"; +import type { ColumnsType } from "antd/es/table"; +import { useAppStore } from "@renderer/stores"; +import { useDomainStore } from "@renderer/stores"; +import type { KintoneApp } from "@shared/types/kintone"; + +const { Text } = Typography; + +const useStyles = createStyles(({ token, css }) => ({ + container: css` + height: 100%; + display: flex; + flex-direction: column; + background: ${token.colorBgContainer}; + `, + header: css` + padding: ${token.paddingSM}px ${token.paddingMD}px; + border-bottom: 1px solid ${token.colorBorderSecondary}; + display: flex; + justify-content: space-between; + align-items: center; + gap: ${token.paddingSM}px; + `, + searchWrapper: css` + flex: 1; + max-width: 300px; + `, + content: css` + flex: 1; + overflow: auto; + padding: ${token.paddingSM}px; + `, + footer: css` + padding: ${token.paddingSM}px ${token.paddingMD}px; + border-top: 1px solid ${token.colorBorderSecondary}; + display: flex; + justify-content: space-between; + align-items: center; + `, + loading: css` + display: flex; + justify-content: center; + align-items: center; + height: 200px; + `, + empty: css` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 300px; + `, + spaceId: css` + font-family: monospace; + font-size: 12px; + `, + loadedInfo: css` + color: ${token.colorTextSecondary}; + font-size: 12px; + `, +})); + +const AppList: React.FC = () => { + const { styles } = useStyles(); + const { currentDomain } = useDomainStore(); + const { + apps, + loading, + error, + currentPage, + pageSize, + searchText, + loadedAt, + setApps, + setLoading, + setError, + setCurrentPage, + setPageSize, + setSearchText, + setSelectedAppId, + setCurrentApp, + } = useAppStore(); + + // Load apps from Kintone + const handleLoadApps = async () => { + if (!currentDomain) return; + + setLoading(true); + setError(null); + + try { + const result = await window.api.getApps({ + domainId: currentDomain.id, + }); + + 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); + setCurrentPage(1); + } else { + setError(result.error || "加载应用失败"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "加载应用失败"); + } finally { + setLoading(false); + } + }; + + // 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)) || + (app.spaceId && app.spaceId.includes(searchText)), + ); + }, [apps, searchText]); + + // Paginated apps + const paginatedApps = React.useMemo(() => { + const start = (currentPage - 1) * pageSize; + const end = start + pageSize; + return filteredApps.slice(start, end); + }, [filteredApps, currentPage, pageSize]); + + // Handle row click + const handleRowClick = (record: KintoneApp) => { + setSelectedAppId(record.appId); + // The AppDetail component will fetch the full app details + }; + + // Table columns + const columns: ColumnsType = [ + { + title: "App ID", + dataIndex: "appId", + key: "appId", + width: 100, + render: (appId: string) => ( + + {appId} + + ), + }, + { + title: "应用名称", + dataIndex: "name", + key: "name", + ellipsis: true, + render: (name: string) => ( + + + {name} + + ), + }, + { + title: "所属 Space", + dataIndex: "spaceId", + key: "spaceId", + width: 120, + render: (spaceId?: string) => + spaceId ? ( + + + {spaceId} + + + ) : ( + - + ), + }, + { + title: "创建时间", + dataIndex: "createdAt", + key: "createdAt", + width: 180, + render: (createdAt: string) => ( + + token.colorTextSecondary }} + /> + + {new Date(createdAt).toLocaleString("zh-CN")} + + + ), + }, + ]; + + if (!currentDomain) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header with search and load button */} +
+
+ } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + disabled={apps.length === 0 && !searchText} + /> +
+ +
+ + {/* Content */} +
+ {loading && apps.length === 0 ? ( +
+ +
+ ) : apps.length === 0 ? ( +
+ + + +
+ ) : ( + ({ + onClick: () => handleRowClick(record), + style: { cursor: "pointer" }, + })} + /> + )} + + + {/* Footer with pagination */} + {apps.length > 0 && ( +
+
+ {loadedAt && ( + + 上次加载: {new Date(loadedAt).toLocaleString("zh-CN")} + + )} + + 共 {filteredApps.length} 个应用 + +
+ { + setCurrentPage(page); + if (size !== pageSize) { + setPageSize(size); + } + }} + showSizeChanger + showQuickJumper + size="small" + pageSizeOptions={["20", "50", "100"]} + showTotal={(total) => + `第 ${(currentPage - 1) * pageSize + 1}-${Math.min(currentPage * pageSize, total)} 条` + } + /> +
+ )} + + ); +}; + +export default AppList; diff --git a/src/renderer/src/components/AppList/index.ts b/src/renderer/src/components/AppList/index.ts new file mode 100644 index 0000000..57ef005 --- /dev/null +++ b/src/renderer/src/components/AppList/index.ts @@ -0,0 +1,6 @@ +/** + * AppList Components + * Export all app list components + */ + +export { default as AppList } from "./AppList"; diff --git a/src/renderer/src/components/SpaceTree/SpaceTree.tsx b/src/renderer/src/components/SpaceTree/SpaceTree.tsx deleted file mode 100644 index b673499..0000000 --- a/src/renderer/src/components/SpaceTree/SpaceTree.tsx +++ /dev/null @@ -1,228 +0,0 @@ -/** - * SpaceTree Component - * Tree view for browsing Spaces and Apps - */ - -import React from "react"; -import { Tree, Input, Empty, Spin, Badge } from "antd"; -import { - SearchOutlined, - AppstoreOutlined, - FolderOutlined, -} from "@ant-design/icons"; -import { createStyles } from "antd-style"; -import type { TreeDataNode, TreeProps } from "antd"; -import { useAppStore } from "@renderer/stores"; -import { useDomainStore } from "@renderer/stores"; -import type { KintoneSpace, KintoneApp } from "@shared/types/kintone"; - -const useStyles = createStyles(({ token, css }) => ({ - container: css` - height: 100%; - display: flex; - flex-direction: column; - background: ${token.colorBgContainer}; - `, - search: css` - padding: ${token.paddingSM}px ${token.paddingMD}px; - border-bottom: 1px solid ${token.colorBorderSecondary}; - `, - tree: css` - flex: 1; - overflow: auto; - padding: ${token.paddingSM}px; - `, - loading: css` - display: flex; - justify-content: center; - align-items: center; - height: 200px; - `, - empty: css` - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 300px; - `, -})); - -const SpaceTree: React.FC = () => { - const { styles } = useStyles(); - const [searchText, setSearchText] = React.useState(""); - const [expandedKeys, setExpandedKeys] = React.useState([]); - - const { currentDomain } = useDomainStore(); - const { - spaces, - apps, - loading, - setSpaces, - setApps, - setCurrentSpace, - setSelectedAppId, - clear, - } = useAppStore(); - - // Load spaces and apps when domain changes - React.useEffect(() => { - if (currentDomain) { - loadSpacesAndApps(); - } else { - clear(); - } - }, [currentDomain]); - - const loadSpacesAndApps = async () => { - if (!currentDomain) return; - - try { - // Load spaces - const spacesResult = await window.api.getSpaces({ - domainId: currentDomain.id, - }); - if (spacesResult.success) { - setSpaces(spacesResult.data); - - // Load apps for each space - const allApps: KintoneApp[] = []; - for (const space of spacesResult.data) { - const appsResult = await window.api.getApps({ - domainId: currentDomain.id, - spaceId: space.id, - }); - if (appsResult.success) { - allApps.push(...appsResult.data); - } - } - setApps(allApps); - - // Expand all spaces by default - setExpandedKeys(spacesResult.data.map((s) => `space-${s.id}`)); - } - } catch (error) { - console.error("Failed to load spaces and apps:", error); - } - }; - - // Build tree data - const buildTreeData = (): TreeDataNode[] => { - if (!currentDomain) { - return []; - } - - const filteredApps = searchText - ? apps.filter((app) => - app.name.toLowerCase().includes(searchText.toLowerCase()), - ) - : apps; - - // Group apps by space - const appsBySpace: Record = {}; - filteredApps.forEach((app) => { - const spaceId = app.spaceId || "no-space"; - if (!appsBySpace[spaceId]) { - appsBySpace[spaceId] = []; - } - appsBySpace[spaceId].push(app); - }); - - // Build tree nodes - return spaces.map((space) => ({ - key: `space-${space.id}`, - title: ( - - - {space.name} - - - ), - icon: , - children: (appsBySpace[space.id] || []).map((app) => ({ - key: `app-${app.appId}`, - title: ( - - - {app.name} - - ), - icon: , - isLeaf: true, - })), - })); - }; - - const handleSelect: TreeProps["onSelect"] = (selectedKeys, info) => { - if (selectedKeys.length === 0) return; - - const key = selectedKeys[0] as string; - if (key.startsWith("space-")) { - const spaceId = key.replace("space-", ""); - const space = spaces.find((s) => s.id === spaceId); - if (space) { - setCurrentSpace(space); - } - } else if (key.startsWith("app-")) { - const appId = key.replace("app-", ""); - setSelectedAppId(appId); - } - }; - - const handleExpand: TreeProps["onExpand"] = (expandedKeys) => { - setExpandedKeys(expandedKeys); - }; - - if (!currentDomain) { - return ( -
- -
- ); - } - - if (loading && spaces.length === 0) { - return ( -
- -
- ); - } - - return ( -
-
- } - value={searchText} - onChange={(e) => setSearchText(e.target.value)} - allowClear - /> -
- -
- {spaces.length === 0 ? ( - - ) : ( - - )} -
-
- ); -}; - -export default SpaceTree; diff --git a/src/renderer/src/components/SpaceTree/index.ts b/src/renderer/src/components/SpaceTree/index.ts deleted file mode 100644 index 80f7cea..0000000 --- a/src/renderer/src/components/SpaceTree/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * SpaceTree Components - * Export all space tree components - */ - -export { default as SpaceTree } from "./SpaceTree"; diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts index 6f998ef..90df3e0 100644 --- a/src/renderer/src/stores/appStore.ts +++ b/src/renderer/src/stores/appStore.ts @@ -1,72 +1,104 @@ /** * App Store - * Manages app browsing state (spaces, apps, current selection) + * Manages app browsing state (apps, current selection, pagination) + * Persisted to localStorage for offline access */ import { create } from "zustand"; -import type { - KintoneSpace, - KintoneApp, - AppDetail, -} from "@shared/types/kintone"; +import { persist } from "zustand/middleware"; +import type { KintoneApp, AppDetail } from "@shared/types/kintone"; interface AppState { // State - spaces: KintoneSpace[]; apps: KintoneApp[]; - currentSpace: KintoneSpace | null; currentApp: AppDetail | null; selectedAppId: string | null; loading: boolean; error: string | null; + // Pagination state + currentPage: number; + pageSize: number; + + // Search state + searchText: string; + + // Load metadata + loadedAt: string | null; + // Actions - setSpaces: (spaces: KintoneSpace[]) => void; setApps: (apps: KintoneApp[]) => void; - setCurrentSpace: (space: KintoneSpace | null) => void; setCurrentApp: (app: AppDetail | null) => void; setSelectedAppId: (id: string | null) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; clearError: () => void; + setCurrentPage: (page: number) => void; + setPageSize: (size: number) => void; + setSearchText: (text: string) => void; + setLoadedAt: (time: string | null) => void; clear: () => void; } -export const useAppStore = create()((set) => ({ - // Initial state - spaces: [], +const initialState = { apps: [], - currentSpace: null, currentApp: null, selectedAppId: null, loading: false, error: null, + currentPage: 1, + pageSize: 20, + searchText: "", + loadedAt: null, +}; - // Actions - setSpaces: (spaces) => set({ spaces }), +export const useAppStore = create()( + persist( + (set) => ({ + ...initialState, - setApps: (apps) => set({ apps }), + // Actions + setApps: (apps) => + set({ + apps, + loadedAt: new Date().toISOString(), + }), - setCurrentSpace: (space) => set({ currentSpace: space }), + setCurrentApp: (currentApp) => set({ currentApp }), - setCurrentApp: (app) => set({ currentApp: app }), + setSelectedAppId: (selectedAppId) => set({ selectedAppId }), - setSelectedAppId: (id) => set({ selectedAppId: id }), + setLoading: (loading) => set({ loading }), - setLoading: (loading) => set({ loading }), + setError: (error) => set({ error }), - setError: (error) => set({ error }), + clearError: () => set({ error: null }), - clearError: () => set({ error: null }), + setCurrentPage: (currentPage) => set({ currentPage }), - clear: () => - set({ - spaces: [], - apps: [], - currentSpace: null, - currentApp: null, - selectedAppId: null, - loading: false, - error: null, + setPageSize: (pageSize) => + set({ + pageSize, + currentPage: 1, // Reset to first page when page size changes + }), + + setSearchText: (searchText) => + set({ + searchText, + currentPage: 1, // Reset to first page when search changes + }), + + setLoadedAt: (loadedAt) => set({ loadedAt }), + + clear: () => set(initialState), }), -})); + { + name: "app-storage", + // Only persist apps and loadedAt, not transient UI state + partialize: (state) => ({ + apps: state.apps, + loadedAt: state.loadedAt, + }), + }, + ), +);