use rest-api-client

This commit is contained in:
2026-03-12 12:27:16 +08:00
parent d66dae74e8
commit 1e9a01b6c1
4 changed files with 430 additions and 619 deletions

337
AGENTS.md
View File

@@ -1,149 +1,114 @@
# AGENTS.md
Kintone Customize Manager 项目开发指南
Kintone Customize Manager - Electron + React 应用,用于管理 Kintone 自定义资源
## 1. 构建命令
### 开发
```bash
# 启动开发服务器HMR + 热重载)
# 开发HMR + 热重载)
npm run dev
# 类型检查
npx tsc --noEmit
```
### 构建
```bash
# 构建生产版本
# 构建
npm run build
# 打包应用
# 打包
npm run package:win # Windows
npm run package:mac # macOS
npm run package:linux # Linux
# 代码质量
npm run lint # ESLint 检查
npm run format # Prettier 格式化
```
### 代码质量
**注意**: 无测试框架,项目暂无测试文件。
```bash
# ESLint 检查
npm run lint
# 格式化代码
npm run format
```
## 2. 项目结构
## 2. 项目架构
```
kintone-customize-manager/
├── src/
│ ├── main/ # Electron 主进程
│ ├── index.ts # 主进程入口
│ ├── ipc-handlers.ts # IPC 通信处理
│ ├── storage.ts # 文件系统操作
├── kintone-api.ts # Kintone API 封装
│ ├── updater.ts # 自动更新逻辑
│ └── config.ts # 配置管理
│ ├── preload/ # Preload 脚本
│ │ └── index.ts # 暴露 API 到渲染进程
└── renderer/ # React 渲染进程
── src/
├── main.tsx # React 入口
├── App.tsx # 根组件
── components/ # React 组件
│ ├── hooks/ # 自定义 Hooks
│ ├── stores/ # Zustand Stores
│ ├── utils/ # 工具函数
│ └── types/ # TypeScript 类型
├── resources/ # 应用资源(图标等)
└── build/ # 构建配置
src/
├── main/ # Electron 主进程
│ ├── index.ts # 入口,创建窗口
│ ├── ipc-handlers.ts # IPC 处理器(所有通信入口
│ ├── storage.ts # 文件存储 + 密码加密
└── kintone-api.ts # Kintone REST API 封装
├── preload/ # Preload 脚本
│ ├── index.ts # 暴露 API 到渲染进程
│ └── index.d.ts # 类型声明
── renderer/ # React 渲染进程
└── src/
├── main.tsx # React 入口
── App.tsx # 根组件
├── components/ # React 组件
├── stores/ # Zustand Stores
── types/ # TypeScript 类型
```
## 3. 路径别名
| 别名 | 路径 |
|------|------|
| ------------- | -------------------- |
| `@renderer/*` | `src/renderer/src/*` |
| `@main/*` | `src/main/*` |
| `@preload/*` | `src/preload/*` |
使用示例:
```typescript
import { useStore } from '@renderer/stores'
import { ipcHandler } from '@main/ipc-handlers'
```
## 4. 代码风格
### 导入顺序
```typescript
// 1. Node.js 内置模块
import { join } from 'path'
import { app, BrowserWindow } from 'electron'
import { join } from "path";
import { app, BrowserWindow } from "electron";
// 2. 第三方库
import React, { useState, useEffect } from 'react'
import { Button, Layout } from 'antd'
import React, { useState, useEffect } from "react";
import { Button, Layout } from "antd";
// 3. 项目内部模块(使用别名)
import { useDomainStore } from '@renderer/stores'
import { formatDate } from '@renderer/utils'
// 3. 项目内部模块(别名)
import { useDomainStore } from "@renderer/stores";
// 4. 相对导入
import './styles.css'
import "./styles.css";
```
### 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 组件文件 | PascalCase | `DomainManager.tsx` |
| 工具函数文件 | camelCase | `formatDate.ts` |
| Store 文件 | camelCase + Store | `domainStore.ts` |
| 类型文件 | camelCase | `types.ts` |
| 组件名 | PascalCase | `DomainManager` |
| 函数/变量 | camelCase | `handleSubmit` |
| 常量 | UPPER_SNAKE_CASE | `MAX_FILE_SIZE` |
| 类型/接口 | PascalCase | `DomainConfig` |
- 组件文件/名: `PascalCase` (e.g., `DomainManager.tsx`)
- 工具函数文件: `camelCase` (e.g., `formatDate.ts`)
- Store 文件: `camelCase + Store` (e.g., `domainStore.ts`)
- 函数/变量: `camelCase` (e.g., `handleSubmit`)
- 常量: `UPPER_SNAKE_CASE` (e.g., `MAX_FILE_SIZE`)
- 类型/接口: `PascalCase` (e.g., `DomainConfig`)
### TypeScript 规范
```typescript
// 显式类型定义
// 显式类型定义,避免 any
interface DomainConfig {
id: string
name: string
domain: string
username: string
authType: 'password' | 'api_token'
createdAt: string
id: string;
name: string;
authType: "password" | "api_token"; // 使用字面量联合类型
}
// 函数返回类型
function createWindow(): void { }
// 异步函数
// 异步函数返回 Promise
async function fetchDomains(): Promise<Domain[]> {}
// 避免使用 any使用 unknown 或具体类型
// 使用类型守卫处理 unknown
function parseResponse(data: unknown): DomainConfig {
// 类型守卫
if (typeof data !== 'object' || data === null) {
throw new Error('Invalid response')
if (typeof data !== "object" || data === null) {
throw new Error("Invalid response");
}
return data as DomainConfig
return data as DomainConfig;
}
```
### React 组件规范
```typescript
// 函数组件优先
interface DomainListProps {
domains: Domain[]
onSelect: (domain: Domain) => void
@@ -153,19 +118,13 @@ function DomainList({ domains, onSelect }: DomainListProps) {
const [selectedId, setSelectedId] = useState<string | null>(null)
// Hooks 放在组件顶部
const { token } = theme.useToken()
// 事件处理函数使用 useCallback
const handleClick = useCallback((domain: Domain) => {
setSelectedId(domain.id)
onSelect(domain)
}, [onSelect])
return (
<div>
{/* JSX */}
</div>
)
return <div>...</div>
}
export default DomainList
@@ -174,123 +133,65 @@ export default DomainList
### Zustand Store 规范
```typescript
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface DomainState {
domains: Domain[]
currentDomain: Domain | null
addDomain: (domain: Domain) => void
removeDomain: (id: string) => void
setCurrentDomain: (domain: Domain | null) => void
domains: Domain[];
addDomain: (domain: Domain) => void;
}
export const useDomainStore = create<DomainState>()(
persist(
(set) => ({
domains: [],
currentDomain: null,
addDomain: (domain) => set((state) => ({
domains: [...state.domains, domain]
addDomain: (domain) =>
set((state) => ({
domains: [...state.domains, domain],
})),
removeDomain: (id) => set((state) => ({
domains: state.domains.filter(d => d.id !== id)
})),
setCurrentDomain: (domain) => set({ currentDomain: domain })
}),
{ name: 'domain-storage' }
)
)
{ name: "domain-storage" },
),
);
```
## 5. 错误处理
## 5. IPC 通信规范
### 主进程错误处理
### Result 模式(所有 IPC 返回)
```typescript
// IPC 处理错误
ipcMain.handle('fetch-domains', async () => {
try {
const domains = await fetchDomainsFromApi()
return { success: true, data: domains }
} catch (error) {
console.error('Failed to fetch domains:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
})
type Result<T> = { success: true; data: T } | { success: false; error: string };
```
### 渲染进程错误处理
```typescript
// 使用 Result 模式
type Result<T> =
| { success: true; data: T }
| { success: false; error: string }
async function handleFetch(): Promise<Result<Domain[]>> {
try {
const result = await window.api.fetchDomains()
if (!result.success) {
message.error(result.error)
}
return result
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
message.error(errorMsg)
return { success: false, error: errorMsg }
}
}
```
## 6. IPC 通信规范
### Preload 暴露 API
```typescript
// preload/index.ts
const api = {
// 使用 invoke 进行双向通信
fetchDomains: () => ipcRenderer.invoke('fetch-domains'),
// 双向通信使用 invoke
fetchDomains: () => ipcRenderer.invoke("fetch-domains"),
// 单向通信使用 send
notify: (message: string) => ipcRenderer.send("notify", message),
};
// 使用 send 进行单向通信
notify: (message: string) => ipcRenderer.send('notify', message),
// 监听事件
onUpdate: (callback: (info: UpdateInfo) => void) =>
ipcRenderer.on('update-available', (_, info) => callback(info))
}
contextBridge.exposeInMainWorld('api', api)
contextBridge.exposeInMainWorld("api", api);
```
### 类型定义
```typescript
// preload/index.d.ts
interface ElectronAPI {
fetchDomains: () => Promise<Result<Domain[]>>
notify: (message: string) => void
onUpdate: (callback: (info: UpdateInfo) => void) => void
}
declare global {
interface Window {
electron: ElectronAPI
api: ElectronAPI
api: ElectronAPI;
}
}
```
## 7. UI 组件规范
## 6. UI 组件规范
### 使用 LobeHub UI + Ant Design
使用 **Ant Design 6** + **antd-style**(不用 Tailwind
```typescript
// 使用 antd-style 进行样式
import { createStyles } from 'antd-style'
const useStyles = createStyles(({ token, css }) => ({
@@ -307,59 +208,85 @@ function MyComponent() {
}
```
### 国际化
国际化:使用中文默认 `import zhCN from 'antd/locale/zh_CN'`
```typescript
// 使用中文默认
import zhCN from 'antd/locale/zh_CN'
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
```
## 8. 安全规范
## 7. 安全规范
### 密码存储
```typescript
// 使用 safeStorage 加密存储
import { safeStorage } from 'electron'
import { safeStorage } from "electron";
// 加密
const encrypted = safeStorage.encryptString(password)
const encrypted = safeStorage.encryptString(password);
// 解密
const decrypted = safeStorage.decryptString(encrypted)
const decrypted = safeStorage.decryptString(encrypted);
```
### CSP 配置
### WebPreferences
```html
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;" />
必须启用:`contextIsolation: true`, `nodeIntegration: false`, `sandbox: false`
## 8. 错误处理
```typescript
// 主进程 IPC
ipcMain.handle("fetch-domains", async () => {
try {
const domains = await fetchDomainsFromApi();
return { success: true, data: domains };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
});
// 渲染进程调用
const result = await window.api.fetchDomains();
if (!result.success) {
message.error(result.error);
}
```
## 9. fnm 环境配置
所有 npm/npx 命令需要先加载 fnm 环境:
所有 npm/npx 命令需加载 fnm 环境:
```bash
# 方式一:使用 wrapper 脚本
~/.config/opencode/node-fnm-wrapper.sh npm run dev
# 方式二:手动加载
eval "$(fnm env --use-on-cd)" && npm run dev
```
## 10. 注意事项
1. **ESM Only**: LobeHub UI 仅支持 ESM,确保 `tsconfig.json``"module": "ESNext"`
2. **React 19**: 必须使用 `@types/react@^19.0.0``@types/react-dom@^19.0.0`
3. **CSS 方案**: 使用 `antd-style`不使用 Tailwind CSS
4. **Context Isolation**: 必须启用 `contextIsolation: true`
5. **禁止类型断言**: 避免使用 `as any`,优先使用类型守卫
1. **ESM Only**: LobeHub UI 仅支持 ESM
2. **React 19**: 使用 `@types/react@^19.0.0`
3. **CSS 方案**: 使用 `antd-style`禁止 Tailwind
4. **禁止 `as any`**: 使用类型守卫或 `unknown`
5. **函数组件优先**: 禁止 class 组件
## 11. MVP Phase - Breaking Changes
**This is MVP phase - breaking changes are acceptable for better design.** However, you MUST:
- **Explain what will break and why**: Document which components/APIs/workflows will be affected
- **Compare old vs new approach**: Show the differences and improvements
- **Document the tradeoffs**: What are the pros and cons of this change
- **Ask for confirmation**: If the change is significant (affects multiple modules or core architecture)
**Examples of acceptable breaking changes during MVP**:
- Refactoring data structures for better type safety
- Changing IPC communication patterns
- Restructuring component hierarchy
- Modifying store architecture
- Updating API interfaces
**Process for breaking changes**:
1. Identify the change and its impact scope
2. Document the breaking change in code comments
3. Explain the reasoning and benefits
4. If significant, ask user for confirmation before implementing
5. Update related documentation after implementation

123
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@codemirror/view": "^6.36.0",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@kintone/rest-api-client": "^6.1.2",
"@lobehub/ui": "^5.5.0",
"@uiw/react-codemirror": "^4.23.0",
"antd": "^6.1.0",
@@ -2383,6 +2384,35 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kintone/rest-api-client": {
"version": "6.1.2",
"resolved": "https://registry.npmmirror.com/@kintone/rest-api-client/-/rest-api-client-6.1.2.tgz",
"integrity": "sha512-/6RMKD/cNg8KOfQDXLOTzKkfG+EAKuU6czgVF5b5c4Na9uFB9BENmdg7e1eV3qNwszfIt7UpONfT3apCiw2R3Q==",
"license": "MIT",
"dependencies": {
"axios": "^1.12.2",
"core-js": "^3.46.0",
"form-data": "^4.0.5",
"js-base64": "^3.7.8",
"mime": "^3.0.0",
"qs": "^6.14.2"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@kintone/rest-api-client/node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz",
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz",
@@ -5909,7 +5939,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/at-least-node": {
@@ -5957,6 +5986,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
@@ -6355,7 +6395,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -6369,7 +6408,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -6707,7 +6745,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -6841,6 +6878,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.48.0",
"resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.48.0.tgz",
"integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -7671,7 +7719,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -7919,7 +7966,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -8409,7 +8455,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -8419,7 +8464,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -8458,7 +8502,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -8471,7 +8514,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -9253,6 +9295,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz",
@@ -9312,7 +9374,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -9487,7 +9548,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -9512,7 +9572,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -9704,7 +9763,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -9806,7 +9864,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -9819,7 +9876,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -10991,6 +11047,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmmirror.com/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmmirror.com/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -11480,7 +11542,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -12684,7 +12745,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -12694,7 +12754,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -13206,7 +13265,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -13890,6 +13948,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
@@ -13910,6 +13974,21 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": {
"version": "9.3.1",
"resolved": "https://registry.npmmirror.com/query-string/-/query-string-9.3.1.tgz",
@@ -15314,7 +15393,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -15334,7 +15412,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -15351,7 +15428,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -15370,7 +15446,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",

View File

@@ -24,6 +24,7 @@
"@codemirror/view": "^6.36.0",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
"@kintone/rest-api-client": "^6.1.2",
"@lobehub/ui": "^5.5.0",
"@uiw/react-codemirror": "^4.23.0",
"antd": "^6.1.0",

View File

@@ -1,9 +1,5 @@
/**
* Kintone REST API Client
* Handles authentication and API calls to Kintone
* Based on REQUIREMENTS.md:331-345, 502-522
*/
import { KintoneRestAPIClient } from "@kintone/rest-api-client";
import type { KintoneRestAPIError } from "@kintone/rest-api-client";
import type { DomainWithPassword } from "@renderer/types/domain";
import type {
KintoneSpace,
@@ -12,10 +8,10 @@ import type {
FileContent,
AppCustomizationConfig,
KintoneApiError,
JSFileConfig,
CSSFileConfig,
} from "@renderer/types/kintone";
const REQUEST_TIMEOUT = 30000; // 30 seconds
/**
* Custom error class for Kintone API errors
*/
@@ -41,446 +37,256 @@ export class KintoneError extends Error {
* Kintone REST API Client
*/
export class KintoneClient {
private baseUrl: string;
private headers: Headers;
private client: KintoneRestAPIClient;
private domain: string;
constructor(domainConfig: DomainWithPassword) {
this.domain = domainConfig.domain;
this.baseUrl = `https://${domainConfig.domain}/k/v1/`;
this.headers = new Headers({
"Content-Type": "application/json",
});
if (domainConfig.authType === "api_token") {
// API Token authentication
this.headers.set("X-Cybozu-API-Token", domainConfig.apiToken || "");
} else {
// Password authentication (Basic Auth)
const credentials = Buffer.from(
`${domainConfig.username}:${domainConfig.password}`,
).toString("base64");
this.headers.set("X-Cybozu-Authorization", credentials);
}
const auth =
domainConfig.authType === "api_token"
? { apiToken: domainConfig.apiToken || "" }
: {
username: domainConfig.username,
password: domainConfig.password,
};
this.client = new KintoneRestAPIClient({
baseUrl: `https://${domainConfig.domain}`,
auth,
});
}
/**
* Make an API request with timeout
*/
private async request<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
const url = this.baseUrl + endpoint;
const response = await fetch(url, {
...options,
headers: this.headers,
signal: controller.signal,
});
const data = await response.json();
if (!response.ok) {
const apiError = data as KintoneApiError;
throw new KintoneError(
apiError.message || `API request failed: ${response.status}`,
apiError,
response.status,
private convertError(error: unknown): KintoneError {
if (error && typeof error === "object" && "code" in error) {
const apiError = error as KintoneRestAPIError;
return new KintoneError(
apiError.message,
{ code: apiError.code, message: apiError.message, id: apiError.id },
apiError.status,
);
}
return data as T;
} catch (error) {
if (error instanceof KintoneError) {
throw error;
}
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new KintoneError("Request timeout");
return new KintoneError(error.message);
}
throw new KintoneError(`Network error: ${error.message}`);
return new KintoneError("Unknown error occurred");
}
throw new KintoneError("Unknown error occurred");
} finally {
clearTimeout(timeoutId);
private async withErrorHandling<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
} catch (error) {
throw this.convertError(error);
}
}
// ==================== Space APIs ====================
/**
* Get list of spaces
* GET /k/v1/space.json
*/
async getSpaces(): Promise<KintoneSpace[]> {
interface SpacesResponse {
spaces: Array<{
id: string;
private mapSpace(space: {
id: string | number;
name: string;
code: string;
createdAt?: string;
creator?: { code: string; name: string };
}>;
}
const response = await this.request<SpacesResponse>("space.json");
return response.spaces.map((space) => ({
id: space.id,
}): KintoneSpace {
return {
id: String(space.id),
name: space.name,
code: space.code,
createdAt: space.createdAt,
creator: space.creator,
}));
creator: space.creator
? { code: space.creator.code, name: space.creator.name }
: undefined,
};
}
// ==================== App APIs ====================
/**
* Get list of apps, optionally filtered by space
* GET /k/v1/apps.json?space={spaceId}
*/
async getApps(spaceId?: string): Promise<KintoneApp[]> {
interface AppsResponse {
apps: Array<{
appId: string;
private mapApp(app: {
appId: string | number;
name: string;
code?: string;
spaceId?: string;
spaceId?: string | number;
createdAt: string;
creator?: { code: string; name: string };
modifiedAt?: string;
modifier?: { code: string; name: string };
}>;
}
let endpoint = "apps.json";
if (spaceId) {
endpoint += `?space=${spaceId}`;
}
const response = await this.request<AppsResponse>(endpoint);
return response.apps.map((app) => ({
appId: app.appId,
}): KintoneApp {
return {
appId: String(app.appId),
name: app.name,
code: app.code,
spaceId: app.spaceId,
spaceId: app.spaceId ? String(app.spaceId) : undefined,
createdAt: app.createdAt,
creator: app.creator,
modifiedAt: app.modifiedAt,
modifier: app.modifier,
}));
};
}
private mapResource(resource: {
type: string;
file?: { fileKey: string; name: string };
url?: string;
}): JSFileConfig | CSSFileConfig {
return {
type: resource.type as "FILE" | "URL",
file: resource.file
? { fileKey: resource.file.fileKey, name: resource.file.name, size: 0 }
: undefined,
url: resource.url,
};
}
private buildCustomizeSection(
js?: JSFileConfig[],
css?: CSSFileConfig[],
):
| { js?: CustomizeResourceItem[]; css?: CustomizeResourceItem[] }
| undefined {
if (!js && !css) return undefined;
return {
js: js?.map((item) => ({
type: item.type,
file: item.file ? { fileKey: item.file.fileKey } : undefined,
url: item.url,
})),
css: css?.map((item) => ({
type: item.type,
file: item.file ? { fileKey: item.file.fileKey } : undefined,
url: item.url,
})),
};
}
// ==================== Space APIs ====================
async getSpaces(): Promise<KintoneSpace[]> {
return this.withErrorHandling(async () => {
const response = await this.client.space.getSpaces();
return response.spaces.map((space) => this.mapSpace(space));
});
}
// ==================== App APIs ====================
async getApps(spaceId?: string): Promise<KintoneApp[]> {
return this.withErrorHandling(async () => {
const params = spaceId ? { spaceId } : {};
const response = await this.client.app.getApps(params);
return response.apps.map((app) => this.mapApp(app));
});
}
/**
* Get app details including customization config
* GET /k/v1/app.json?app={appId}
*/
async getAppDetail(appId: string): Promise<AppDetail> {
interface AppResponse {
appId: string;
name: string;
code?: string;
description?: string;
spaceId?: string;
createdAt: string;
creator: { code: string; name: string };
modifiedAt: string;
modifier: { code: string; name: string };
}
return this.withErrorHandling(async () => {
const [appInfo, customizeInfo] = await Promise.all([
this.client.app.getApp({ id: appId }),
this.client.app.getAppCustomize({ app: appId }),
]);
interface AppCustomizeResponse {
appId: string;
scope: string;
desktop: {
js?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
css?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
};
mobile: {
js?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
css?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
};
}
// Get basic app info
const appInfo = await this.request<AppResponse>(`app.json?app=${appId}`);
// Get customization config
const customizeInfo = await this.request<AppCustomizeResponse>(
`app/customize.json?app=${appId}`,
);
// Transform customization config
const customization: AppCustomizationConfig = {
javascript: {
pc:
customizeInfo.desktop?.js?.map((js) => ({
type: js.type as "FILE" | "URL",
file: js.file
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
: undefined,
url: js.url,
})) || [],
customizeInfo.desktop?.js?.map((js) => this.mapResource(js)) || [],
mobile:
customizeInfo.mobile?.js?.map((js) => ({
type: js.type as "FILE" | "URL",
file: js.file
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
: undefined,
url: js.url,
})) || [],
customizeInfo.mobile?.js?.map((js) => this.mapResource(js)) || [],
},
stylesheet: {
pc:
customizeInfo.desktop?.css?.map((css) => ({
type: css.type as "FILE" | "URL",
file: css.file
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
: undefined,
url: css.url,
})) || [],
customizeInfo.desktop?.css?.map((css) => this.mapResource(css)) ||
[],
mobile:
customizeInfo.mobile?.css?.map((css) => ({
type: css.type as "FILE" | "URL",
file: css.file
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
: undefined,
url: css.url,
})) || [],
customizeInfo.mobile?.css?.map((css) => this.mapResource(css)) ||
[],
},
plugins: [],
};
return {
appId: appInfo.appId,
appId: String(appInfo.appId),
name: appInfo.name,
code: appInfo.code,
description: appInfo.description,
spaceId: appInfo.spaceId,
spaceId: appInfo.spaceId ? String(appInfo.spaceId) : undefined,
createdAt: appInfo.createdAt,
creator: appInfo.creator,
modifiedAt: appInfo.modifiedAt,
modifier: appInfo.modifier,
customization,
};
});
}
// ==================== File APIs ====================
/**
* Get file content from Kintone
* GET /k/v1/file.json?fileKey={fileKey}
*/
async getFileContent(fileKey: string): Promise<FileContent> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
const url = `${this.baseUrl}file.json?fileKey=${fileKey}`;
const response = await fetch(url, {
headers: this.headers,
signal: controller.signal,
});
if (!response.ok) {
const error = await response.json();
throw new KintoneError(
error.message || "Failed to get file",
error,
response.status,
);
}
const contentType =
response.headers.get("content-type") || "application/octet-stream";
const arrayBuffer = await response.arrayBuffer();
const content = Buffer.from(arrayBuffer).toString("base64");
return this.withErrorHandling(async () => {
const data = await this.client.file.downloadFile({ fileKey });
const buffer = Buffer.from(data);
const content = buffer.toString("base64");
return {
fileKey,
name: fileKey, // Kintone doesn't return filename in file API
size: arrayBuffer.byteLength,
mimeType: contentType,
name: fileKey,
size: buffer.byteLength,
mimeType: "application/octet-stream",
content,
};
} finally {
clearTimeout(timeoutId);
}
});
}
/**
* Upload a file to Kintone
* POST /k/v1/file.json (multipart/form-data)
*/
async uploadFile(
content: string | Buffer,
fileName: string,
mimeType: string = "application/javascript",
_mimeType?: string,
): Promise<{ fileKey: string }> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
const url = `${this.baseUrl}file.json`;
// Create form data
const formData = new FormData();
const blob = new Blob(
[typeof content === "string" ? content : Buffer.from(content)],
{ type: mimeType },
);
formData.append("file", blob, fileName);
// Remove Content-Type header to let browser set it with boundary
const uploadHeaders = new Headers(this.headers);
uploadHeaders.delete("Content-Type");
const response = await fetch(url, {
method: "POST",
headers: uploadHeaders,
body: formData,
signal: controller.signal,
return this.withErrorHandling(async () => {
const response = await this.client.file.uploadFile({
file: { name: fileName, data: content },
});
return { fileKey: response.fileKey };
});
if (!response.ok) {
const error = await response.json();
throw new KintoneError(
error.message || "Failed to upload file",
error,
response.status,
);
}
const data = await response.json();
return { fileKey: data.fileKey };
} finally {
clearTimeout(timeoutId);
}
}
// ==================== Deploy APIs ====================
/**
* Update app customization config
* PUT /k/v1/app/customize.json
*/
async updateAppCustomize(
appId: string,
config: AppCustomizationConfig,
): Promise<void> {
interface CustomizeUpdateRequest {
app: string;
desktop?: {
js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
};
mobile?: {
js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
};
}
const requestBody: CustomizeUpdateRequest = {
return this.withErrorHandling(async () => {
await this.client.app.updateAppCustomize({
app: appId,
desktop: {
js: config.javascript?.pc
?.map((js) => ({
type: js.type,
file: js.file ? { fileKey: js.file.fileKey } : undefined,
url: js.url,
}))
.filter(Boolean),
css: config.stylesheet?.pc
?.map((css) => ({
type: css.type,
file: css.file ? { fileKey: css.file.fileKey } : undefined,
url: css.url,
}))
.filter(Boolean),
},
mobile: {
js: config.javascript?.mobile
?.map((js) => ({
type: js.type,
file: js.file ? { fileKey: js.file.fileKey } : undefined,
url: js.url,
}))
.filter(Boolean),
css: config.stylesheet?.mobile
?.map((css) => ({
type: css.type,
file: css.file ? { fileKey: css.file.fileKey } : undefined,
url: css.url,
}))
.filter(Boolean),
},
};
await this.request("app/customize.json", {
method: "PUT",
body: JSON.stringify(requestBody),
desktop: this.buildCustomizeSection(
config.javascript?.pc,
config.stylesheet?.pc,
),
mobile: this.buildCustomizeSection(
config.javascript?.mobile,
config.stylesheet?.mobile,
),
scope: "ALL",
});
});
}
/**
* Deploy app changes
* POST /k/v1/preview/app/deploy.json
*/
async deployApp(appId: string): Promise<void> {
await this.request("preview/app/deploy.json", {
method: "POST",
body: JSON.stringify({ apps: [{ app: appId }] }),
return this.withErrorHandling(async () => {
await this.client.app.deployApp({ apps: [{ app: appId }] });
});
}
/**
* Get deploy status
* GET /k/v1/preview/app/deploy.json?app={appId}
*/
async getDeployStatus(
appId: string,
): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
interface DeployStatusResponse {
apps: Array<{
app: string;
status: "PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL";
}>;
}
const response = await this.request<DeployStatusResponse>(
`preview/app/deploy.json?app=${appId}`,
);
const appStatus = response.apps.find((a) => a.app === appId);
return appStatus?.status || "FAIL";
return this.withErrorHandling(async () => {
const response = await this.client.app.getDeployStatus({ app: appId });
return response.apps[0]?.status || "FAIL";
});
}
// ==================== Utility Methods ====================
/**
* Test connection to Kintone
*/
async testConnection(): Promise<{ success: boolean; error?: string }> {
try {
await this.getApps();
@@ -494,15 +300,17 @@ export class KintoneClient {
}
}
/**
* Get the domain name
*/
getDomain(): string {
return this.domain;
}
}
// Export factory function for convenience
type CustomizeResourceItem = {
type: string;
file?: { fileKey: string };
url?: string;
};
export function createKintoneClient(domain: DomainWithPassword): KintoneClient {
return new KintoneClient(domain);
}