remove spaces
This commit is contained in:
@@ -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 数据 | - |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] } : {};
|
||||
// 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 {
|
||||
|
||||
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@@ -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: (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
323
src/renderer/src/components/AppList/AppList.tsx
Normal file
323
src/renderer/src/components/AppList/AppList.tsx
Normal 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;
|
||||
6
src/renderer/src/components/AppList/index.ts
Normal file
6
src/renderer/src/components/AppList/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* AppList Components
|
||||
* Export all app list components
|
||||
*/
|
||||
|
||||
export { default as AppList } from "./AppList";
|
||||
@@ -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;
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* SpaceTree Components
|
||||
* Export all space tree components
|
||||
*/
|
||||
|
||||
export { default as SpaceTree } from "./SpaceTree";
|
||||
@@ -1,57 +1,72 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
// 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 }),
|
||||
|
||||
setCurrentApp: (app) => set({ currentApp: app }),
|
||||
|
||||
setSelectedAppId: (id) => set({ selectedAppId: id }),
|
||||
setSelectedAppId: (selectedAppId) => set({ selectedAppId }),
|
||||
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
@@ -59,14 +74,31 @@ export const useAppStore = create<AppState>()((set) => ({
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
clear: () =>
|
||||
setCurrentPage: (currentPage) => set({ currentPage }),
|
||||
|
||||
setPageSize: (pageSize) =>
|
||||
set({
|
||||
spaces: [],
|
||||
apps: [],
|
||||
currentSpace: null,
|
||||
currentApp: null,
|
||||
selectedAppId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user