remove spaces

This commit is contained in:
2026-03-12 15:45:40 +08:00
parent cb6d3f7ee0
commit 51ccd265ba
12 changed files with 506 additions and 358 deletions

View File

@@ -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 最大 100offset 从 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 UICodeMirror 6safeStorage | - |
| 1.2.0 | 2026-03-12 | 移除 Space 功能改为 App 列表分页显示支持持久化存储 App 数据 | - |
---

View File

@@ -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<ReturnType<SelfKintoneClient["getSpaces"]>>
>("getSpaces", async (params) => {
const client = await getClient(params.domainId);
return client.getSpaces();
});
}
/**
* Get apps
*/
@@ -259,10 +245,15 @@ function registerGetApps(): void {
Awaited<ReturnType<SelfKintoneClient["getApps"]>>
>("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();

View File

@@ -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<KintoneClient["space"]["getSpace"]>;
type AppResponse = ReturnType<KintoneClient["app"]["getApp"]>;
type AppsResponse = ReturnType<KintoneClient["app"]["getApps"]>;
type AppCustomizeResponse = ReturnType<KintoneClient["app"]["getAppCustomize"]>;
@@ -89,15 +87,6 @@ export class SelfKintoneClient {
}
}
private mapSpace(space: Awaited<SpaceResponse>): 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<AppResponse>): 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<KintoneSpace[]> {
// 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<KintoneSpace> {
return this.withErrorHandling(async () => {
const response = await this.client.space.getSpace({ id: spaceId });
return this.mapSpace(response);
});
}
// ==================== App APIs ====================
async getApps(spaceId?: string): Promise<KintoneApp[]> {
/**
* Get all apps with pagination support
* Fetches all apps by making multiple requests if needed
*/
async getApps(options?: {
limit?: number;
offset?: number;
}): Promise<KintoneApp[]> {
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<AppsResponse>["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 {

View File

@@ -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<Result<boolean>>;
// ==================== Browse ====================
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>;
getApps: (params: GetAppsParams) => Promise<Result<KintoneApp[]>>;
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
getFileContent: (

View File

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

View File

@@ -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 = () => {
<ConfigProvider locale={zhCN}>
<AntApp>
<Layout className={styles.layout}>
{/* Left Sider - Domain List & Space Tree */}
{/* Left Sider - Domain List & App List */}
<Sider width={280} className={styles.sider}>
<div className={styles.logo}>
<CloudServerOutlined style={{ fontSize: 24, color: "#1890ff" }} />
@@ -151,10 +151,10 @@ const App: React.FC = () => {
/>
</div>
<div
className={styles.spaceSection}
className={styles.appSection}
style={{ height: `calc(100% - ${domainSectionHeight}px)` }}
>
<SpaceTree />
<AppList />
</div>
</div>
</Sider>

View File

@@ -272,8 +272,8 @@ const AppDetail: React.FC = () => {
<Descriptions.Item label="更新者">
{currentApp.modifier?.name}
</Descriptions.Item>
<Descriptions.Item label="所属Space" span={2}>
{currentApp.spaceName || currentApp.spaceId || "-"}
<Descriptions.Item label="所属 Space ID" span={2}>
{currentApp.spaceId || "-"}
</Descriptions.Item>
</Descriptions>
),

View File

@@ -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<KintoneApp> = [
{
title: "App ID",
dataIndex: "appId",
key: "appId",
width: 100,
render: (appId: string) => (
<Text code style={{ fontSize: 12 }}>
{appId}
</Text>
),
},
{
title: "应用名称",
dataIndex: "name",
key: "name",
ellipsis: true,
render: (name: string) => (
<Space>
<AppstoreOutlined style={{ color: "#1890ff" }} />
<Text strong>{name}</Text>
</Space>
),
},
{
title: "所属 Space",
dataIndex: "spaceId",
key: "spaceId",
width: 120,
render: (spaceId?: string) =>
spaceId ? (
<Tooltip title="Space ID">
<Tag color="blue" className={styles.spaceId}>
{spaceId}
</Tag>
</Tooltip>
) : (
<Text type="secondary">-</Text>
),
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 180,
render: (createdAt: string) => (
<Space size={4}>
<ClockCircleOutlined
style={{ color: (token) => token.colorTextSecondary }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(createdAt).toLocaleString("zh-CN")}
</Text>
</Space>
),
},
];
if (!currentDomain) {
return (
<div className={styles.empty}>
<Empty description="请先选择一个 Domain" />
</div>
);
}
return (
<div className={styles.container}>
{/* Header with search and load button */}
<div className={styles.header}>
<div className={styles.searchWrapper}>
<Input
placeholder="搜索应用..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
disabled={apps.length === 0 && !searchText}
/>
</div>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={handleLoadApps}
loading={loading}
>
{apps.length > 0 ? "重新加载" : "加载应用"}
</Button>
</div>
{/* Content */}
<div className={styles.content}>
{loading && apps.length === 0 ? (
<div className={styles.loading}>
<Spin size="large" tip="正在加载应用..." />
</div>
) : apps.length === 0 ? (
<div className={styles.empty}>
<Empty
description="暂无应用数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" onClick={handleLoadApps}>
</Button>
</Empty>
</div>
) : (
<Table
columns={columns}
dataSource={paginatedApps}
rowKey="appId"
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: { cursor: "pointer" },
})}
/>
)}
</div>
{/* Footer with pagination */}
{apps.length > 0 && (
<div className={styles.footer}>
<div className={styles.loadedInfo}>
{loadedAt && (
<Text type="secondary">
: {new Date(loadedAt).toLocaleString("zh-CN")}
</Text>
)}
<Text type="secondary" style={{ marginLeft: 16 }}>
{filteredApps.length}
</Text>
</div>
<Pagination
current={currentPage}
pageSize={pageSize}
total={filteredApps.length}
onChange={(page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
}
}}
showSizeChanger
showQuickJumper
size="small"
pageSizeOptions={["20", "50", "100"]}
showTotal={(total) =>
`${(currentPage - 1) * pageSize + 1}-${Math.min(currentPage * pageSize, total)}`
}
/>
</div>
)}
</div>
);
};
export default AppList;

View File

@@ -0,0 +1,6 @@
/**
* AppList Components
* Export all app list components
*/
export { default as AppList } from "./AppList";

View File

@@ -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<React.Key[]>([]);
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<string, KintoneApp[]> = {};
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: (
<span>
<FolderOutlined style={{ marginRight: 8 }} />
{space.name}
<Badge
count={appsBySpace[space.id]?.length || 0}
style={{ marginLeft: 8 }}
size="small"
/>
</span>
),
icon: <FolderOutlined />,
children: (appsBySpace[space.id] || []).map((app) => ({
key: `app-${app.appId}`,
title: (
<span>
<AppstoreOutlined style={{ marginRight: 8 }} />
{app.name}
</span>
),
icon: <AppstoreOutlined />,
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 (
<div className={styles.empty}>
<Empty description="请先选择一个 Domain" />
</div>
);
}
if (loading && spaces.length === 0) {
return (
<div className={styles.loading}>
<Spin size="large" />
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.search}>
<Input
placeholder="搜索应用..."
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
</div>
<div className={styles.tree}>
{spaces.length === 0 ? (
<Empty
description="暂无 Space"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<Tree
showIcon
treeData={buildTreeData()}
selectedKeys={[]}
expandedKeys={expandedKeys}
onSelect={handleSelect}
onExpand={handleExpand}
/>
)}
</div>
</div>
);
};
export default SpaceTree;

View File

@@ -1,6 +0,0 @@
/**
* SpaceTree Components
* Export all space tree components
*/
export { default as SpaceTree } from "./SpaceTree";

View File

@@ -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<AppState>()((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<AppState>()(
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,
}),
},
),
);