remove spaces
This commit is contained in:
@@ -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 最大 100,offset 从 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 UI、CodeMirror 6、safeStorage | - |
|
| 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,
|
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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@@ -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: (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
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
|
* 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,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user