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 # 渲染进程入口 │ │ ├─ main.tsx # 渲染进程入口
│ │ ├─ App.tsx │ │ ├─ App.tsx
│ │ ├─ components/ # React 组件 │ │ ├─ components/ # React 组件
│ │ │ ├─ DomainManager/ │ │ │ ├─ AppList/
│ │ │ ├─ SpaceTree/
│ │ │ ├─ AppDetail/ │ │ │ ├─ AppDetail/
│ │ │ ├─ FileUploader/ │ │ │ ├─ FileUploader/
│ │ │ ├─ CodeViewer/ │ │ │ ├─ CodeViewer/
@@ -252,8 +251,7 @@ kintone-customize-manager/
- 显示 Domain 名称和域名 - 显示 Domain 名称和域名
**FR-DOMAIN-005**: 切换 Domain **FR-DOMAIN-005**: 切换 Domain
- 用户可从 Domain 列表快速切换当前工作的 Domain - 切换后自动加载该 Domain 下的 App 列表
- 切换后自动加载该 Domain 下的 Space 和 App 列表
**FR-DOMAIN-006**: 密码加密存储 **FR-DOMAIN-006**: 密码加密存储
- 使用 keytar 将密码加密存储到系统密钥链 - 使用 keytar 将密码加密存储到系统密钥链
@@ -287,40 +285,43 @@ interface Domain {
### 3.2 资源浏览 ### 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 列表 **FR-BROWSE-003**: 搜索过滤
- 调用 Kintone API 获取当前 Domain 下的所有 Space - 支持按 App 名称搜索
- 显示 Space 名称和 ID - 搜索结果实时过滤
- 支持按 Space 名称排序
**FR-BROWSE-002**: 获取 App 列表 **FR-BROWSE-004**: 获取 App 详情
- 按 Space 分组显示 App 列表
- 或显示所有 App不分 Space
- 显示 App 名称、ID、创建时间
- 支持按 App 名称搜索和过滤
**FR-BROWSE-003**: 获取 App 详情
- 选择 App 后查看详细配置 - 选择 App 后查看详细配置
- 显示以下信息: - 显示以下信息:
- App 名称 - App 名称
- App ID - App ID
- 所属 Space - 所属 Space ID如果 App 隶属于某个 Space
- 创建时间 - 创建时间
- 最后更新时间 - 最后更新时间
**FR-BROWSE-004**: 查看自定义资源配置 **FR-BROWSE-005**: 查看自定义资源配置
- 查看 PC 端的 JavaScript 文件配置 - 查看 PC 端的 JavaScript 文件配置
- 查看 PC 端的 CSS 文件配置 - 查看 PC 端的 CSS 文件配置
- 查看移动端的 JavaScript 文件配置 - 查看移动端的 JavaScript 文件配置
- 查看移动端的 CSS 文件配置 - 查看移动端的 CSS 文件配置
- 查看已安装的 Plugin 列表(只读,后续版本支持管理) - 查看已安装的 Plugin 列表(只读,后续版本支持管理)
**FR-BROWSE-005**: 查看文件详情 **FR-BROWSE-006**: 查看文件详情
- 文件名 - 文件名
- 文件类型JS/CSS - 文件类型JS/CSS
- 部署位置PC/移动端) - 部署位置PC/移动端)
@@ -331,11 +332,12 @@ interface Domain {
#### 3.2.3 Kintone API 端点 #### 3.2.3 Kintone API 端点
``` ```
# 获取 Space 列表 # 获取 App 列表(支持分页)
GET /k/v1/space.json GET /k/v1/apps.json?limit={limit}&offset={offset}
# 限制limit 最大 100offset 从 0 开始
# 获取 App 列表(按 Space # 注意API 不支持排序,需客户端排序
GET /k/v1/apps.json?space={spaceId} # 注意API 不返回 totalCount需通过返回数量判断是否还有更多
# 注意spaceId 已包含在响应中null 表示不属于任何 Space
# 获取 App 配置 # 获取 App 配置
GET /k/v1/app.json?app={appId} 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} 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 下载已部署代码 ### 3.3 下载已部署代码
@@ -889,8 +922,7 @@ autoUpdater.on('update-downloaded', (info) => {
| 术语 | 说明 | | 术语 | 说明 |
|------|------| |------|------|
| Domain | Kintone 实例 company.kintone.com | | Space | Kintone 空间应用的容器API 不支持获取所有 Space |
| Space | Kintone 空间应用的容器 |
| App | Kintone 应用 | | App | Kintone 应用 |
| FileKey | Kintone 文件的唯一标识 | | FileKey | Kintone 文件的唯一标识 |
| IPC | Electron 进程间通信 | | 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.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, CreateDomainParams,
UpdateDomainParams, UpdateDomainParams,
TestDomainConnectionParams, TestDomainConnectionParams,
GetSpacesParams,
GetAppsParams, GetAppsParams,
GetAppDetailParams, GetAppDetailParams,
GetFileContentParams, GetFileContentParams,
@@ -237,19 +236,6 @@ function registerTestDomainConnection(): void {
// ==================== Browse IPC Handlers ==================== // ==================== 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 * Get apps
*/ */
@@ -259,10 +245,15 @@ function registerGetApps(): void {
Awaited<ReturnType<SelfKintoneClient["getApps"]>> Awaited<ReturnType<SelfKintoneClient["getApps"]>>
>("getApps", async (params) => { >("getApps", async (params) => {
const client = await getClient(params.domainId); const client = await getClient(params.domainId);
return client.getApps(params.spaceId); return client.getApps({
limit: params.limit,
offset: params.offset,
});
}); });
} }
/** /**
* Get app detail * Get app detail
*/ */
@@ -553,7 +544,6 @@ export function registerIpcHandlers(): void {
registerTestDomainConnection(); registerTestDomainConnection();
// Browse // Browse
registerGetSpaces();
registerGetApps(); registerGetApps();
registerGetAppDetail(); registerGetAppDetail();
registerGetFileContent(); registerGetFileContent();

View File

@@ -2,7 +2,6 @@ import { KintoneRestAPIClient } from "@kintone/rest-api-client";
import type { KintoneRestAPIError } from "@kintone/rest-api-client"; import type { KintoneRestAPIError } from "@kintone/rest-api-client";
import type { DomainWithPassword } from "@shared/types/domain"; import type { DomainWithPassword } from "@shared/types/domain";
import type { import type {
KintoneSpace,
KintoneApp, KintoneApp,
AppDetail, AppDetail,
FileContent, FileContent,
@@ -35,7 +34,6 @@ export class KintoneError extends Error {
// Use typeof to get SDK method return types // Use typeof to get SDK method return types
type KintoneClient = KintoneRestAPIClient; type KintoneClient = KintoneRestAPIClient;
type SpaceResponse = ReturnType<KintoneClient["space"]["getSpace"]>;
type AppResponse = ReturnType<KintoneClient["app"]["getApp"]>; type AppResponse = ReturnType<KintoneClient["app"]["getApp"]>;
type AppsResponse = ReturnType<KintoneClient["app"]["getApps"]>; type AppsResponse = ReturnType<KintoneClient["app"]["getApps"]>;
type AppCustomizeResponse = ReturnType<KintoneClient["app"]["getAppCustomize"]>; 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 { private mapApp(app: Awaited<AppResponse>): KintoneApp {
return { 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 ==================== // ==================== 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 () => { return this.withErrorHandling(async () => {
const params = spaceId ? { spaceIds: [spaceId] } : {}; // 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); const response = await this.client.app.getApps(params);
return response.apps.map((app) => this.mapApp(app)); 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 }> { async testConnection(): Promise<{ success: boolean; error?: string }> {
try { 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 }; return { success: true };
} catch (error) { } catch (error) {
return { return {

View File

@@ -4,7 +4,6 @@ import type {
CreateDomainParams, CreateDomainParams,
UpdateDomainParams, UpdateDomainParams,
TestDomainConnectionParams, TestDomainConnectionParams,
GetSpacesParams,
GetAppsParams, GetAppsParams,
GetAppDetailParams, GetAppDetailParams,
GetFileContentParams, GetFileContentParams,
@@ -17,7 +16,6 @@ import type {
} from "@shared/types/ipc"; } from "@shared/types/ipc";
import type { Domain, DomainWithStatus } from "@shared/types/domain"; import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type { import type {
KintoneSpace,
KintoneApp, KintoneApp,
AppDetail, AppDetail,
FileContent, FileContent,
@@ -44,7 +42,6 @@ export interface SelfAPI {
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>; testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
// ==================== Browse ==================== // ==================== Browse ====================
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>;
getApps: (params: GetAppsParams) => Promise<Result<KintoneApp[]>>; getApps: (params: GetAppsParams) => Promise<Result<KintoneApp[]>>;
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>; getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
getFileContent: ( getFileContent: (

View File

@@ -16,10 +16,7 @@ const api: SelfAPI = {
testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params), testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params),
// ==================== Browse ==================== // ==================== Browse ====================
getSpaces: (params) => ipcRenderer.invoke("getSpaces", params),
getApps: (params) => ipcRenderer.invoke("getApps", params), getApps: (params) => ipcRenderer.invoke("getApps", params),
getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params),
getFileContent: (params) => ipcRenderer.invoke("getFileContent", params),
// ==================== Deploy ==================== // ==================== Deploy ====================
deploy: (params) => ipcRenderer.invoke("deploy", params), 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 zhCN from "antd/locale/zh_CN";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import { DomainManager } from "@renderer/components/DomainManager"; 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 { AppDetail } from "@renderer/components/AppDetail";
import { DeployDialog } from "@renderer/components/DeployDialog"; import { DeployDialog } from "@renderer/components/DeployDialog";
@@ -110,7 +110,7 @@ const useStyles = createStyles(({ token, css }) => ({
overflow: hidden; overflow: hidden;
transition: height 0.2s ease-in-out; transition: height 0.2s ease-in-out;
`, `,
spaceSection: css` appSection: css`
overflow: hidden; overflow: hidden;
transition: height 0.2s ease-in-out; transition: height 0.2s ease-in-out;
`, `,
@@ -134,7 +134,7 @@ const App: React.FC = () => {
<ConfigProvider locale={zhCN}> <ConfigProvider locale={zhCN}>
<AntApp> <AntApp>
<Layout className={styles.layout}> <Layout className={styles.layout}>
{/* Left Sider - Domain List & Space Tree */} {/* Left Sider - Domain List & App List */}
<Sider width={280} className={styles.sider}> <Sider width={280} className={styles.sider}>
<div className={styles.logo}> <div className={styles.logo}>
<CloudServerOutlined style={{ fontSize: 24, color: "#1890ff" }} /> <CloudServerOutlined style={{ fontSize: 24, color: "#1890ff" }} />
@@ -151,10 +151,10 @@ const App: React.FC = () => {
/> />
</div> </div>
<div <div
className={styles.spaceSection} className={styles.appSection}
style={{ height: `calc(100% - ${domainSectionHeight}px)` }} style={{ height: `calc(100% - ${domainSectionHeight}px)` }}
> >
<SpaceTree /> <AppList />
</div> </div>
</div> </div>
</Sider> </Sider>

View File

@@ -272,8 +272,8 @@ const AppDetail: React.FC = () => {
<Descriptions.Item label="更新者"> <Descriptions.Item label="更新者">
{currentApp.modifier?.name} {currentApp.modifier?.name}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="所属Space" span={2}> <Descriptions.Item label="所属 Space ID" span={2}>
{currentApp.spaceName || currentApp.spaceId || "-"} {currentApp.spaceId || "-"}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </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,57 +1,72 @@
/** /**
* App Store * 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 { create } from "zustand";
import type { import { persist } from "zustand/middleware";
KintoneSpace, import type { KintoneApp, AppDetail } from "@shared/types/kintone";
KintoneApp,
AppDetail,
} from "@shared/types/kintone";
interface AppState { interface AppState {
// State // State
spaces: KintoneSpace[];
apps: KintoneApp[]; apps: KintoneApp[];
currentSpace: KintoneSpace | null;
currentApp: AppDetail | null; currentApp: AppDetail | null;
selectedAppId: string | null; selectedAppId: string | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
// Pagination state
currentPage: number;
pageSize: number;
// Search state
searchText: string;
// Load metadata
loadedAt: string | null;
// Actions // Actions
setSpaces: (spaces: KintoneSpace[]) => void;
setApps: (apps: KintoneApp[]) => void; setApps: (apps: KintoneApp[]) => void;
setCurrentSpace: (space: KintoneSpace | null) => void;
setCurrentApp: (app: AppDetail | null) => void; setCurrentApp: (app: AppDetail | null) => void;
setSelectedAppId: (id: string | null) => void; setSelectedAppId: (id: string | null) => void;
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setError: (error: string | null) => void; setError: (error: string | null) => void;
clearError: () => void; clearError: () => void;
setCurrentPage: (page: number) => void;
setPageSize: (size: number) => void;
setSearchText: (text: string) => void;
setLoadedAt: (time: string | null) => void;
clear: () => void; clear: () => void;
} }
export const useAppStore = create<AppState>()((set) => ({ const initialState = {
// Initial state
spaces: [],
apps: [], apps: [],
currentSpace: null,
currentApp: null, currentApp: null,
selectedAppId: null, selectedAppId: null,
loading: false, loading: false,
error: null, error: null,
currentPage: 1,
pageSize: 20,
searchText: "",
loadedAt: null,
};
export const useAppStore = create<AppState>()(
persist(
(set) => ({
...initialState,
// Actions // Actions
setSpaces: (spaces) => set({ spaces }), setApps: (apps) =>
set({
apps,
loadedAt: new Date().toISOString(),
}),
setApps: (apps) => set({ apps }), setCurrentApp: (currentApp) => set({ currentApp }),
setCurrentSpace: (space) => set({ currentSpace: space }), setSelectedAppId: (selectedAppId) => set({ selectedAppId }),
setCurrentApp: (app) => set({ currentApp: app }),
setSelectedAppId: (id) => set({ selectedAppId: id }),
setLoading: (loading) => set({ loading }), setLoading: (loading) => set({ loading }),
@@ -59,14 +74,31 @@ export const useAppStore = create<AppState>()((set) => ({
clearError: () => set({ error: null }), clearError: () => set({ error: null }),
clear: () => setCurrentPage: (currentPage) => set({ currentPage }),
setPageSize: (pageSize) =>
set({ set({
spaces: [], pageSize,
apps: [], currentPage: 1, // Reset to first page when page size changes
currentSpace: null,
currentApp: null,
selectedAppId: null,
loading: false,
error: null,
}), }),
}));
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,
}),
},
),
);