This commit is contained in:
2026-03-13 18:15:01 +08:00
parent 4d92085957
commit 23edd0faab
3 changed files with 109 additions and 217 deletions

259
AGENTS.md
View File

@@ -66,13 +66,6 @@ src/
| `@preload/*` | `src/preload/*` | | `@preload/*` | `src/preload/*` |
| `@shared/*` | `src/shared/*` | | `@shared/*` | `src/shared/*` |
## 4. 代码风格
| 别名 | 路径 |
| ------------- | -------------------- |
| `@renderer/*` | `src/renderer/src/*` |
| `@main/*` | `src/main/*` |
| `@preload/*` | `src/preload/*` |
| `@shared/*` | `src/shared/*` |
## 4. 代码风格 ## 4. 代码风格
### 导入顺序 ### 导入顺序
@@ -104,179 +97,106 @@ import "./styles.css";
### TypeScript 规范 ### TypeScript 规范
```typescript - 显式类型定义,避免 `any`
// 显式类型定义,避免 any - 使用字面量联合类型(如 `authType: "password" | "api_token"`
interface DomainConfig { - 异步函数返回 `Promise<T>`
id: string; - 使用类型守卫处理 `unknown`
name: string;
authType: "password" | "api_token"; // 使用字面量联合类型
}
// 异步函数返回 Promise
async function fetchDomains(): Promise<Domain[]> {}
// 使用类型守卫处理 unknown
function parseResponse(data: unknown): DomainConfig {
if (typeof data !== "object" || data === null) {
throw new Error("Invalid response");
}
return data as DomainConfig;
}
```
### React 组件规范 ### React 组件规范
```typescript - Hooks 放在组件顶部
interface DomainListProps { - 事件处理函数使用 `useCallback`
domains: Domain[] - 使用 TypeScript 显式定义 props 类型
onSelect: (domain: Domain) => void
}
function DomainList({ domains, onSelect }: DomainListProps) {
const [selectedId, setSelectedId] = useState<string | null>(null)
// Hooks 放在组件顶部
// 事件处理函数使用 useCallback
const handleClick = useCallback((domain: Domain) => {
setSelectedId(domain.id)
onSelect(domain)
}, [onSelect])
return <div>...</div>
}
export default DomainList
```
### Zustand Store 规范 ### Zustand Store 规范
```typescript - 使用 `persist` 中间件持久化状态
import { create } from "zustand"; - 定义接口明确 state 和 actions 类型
import { persist } from "zustand/middleware";
interface DomainState {
domains: Domain[];
addDomain: (domain: Domain) => void;
}
export const useDomainStore = create<DomainState>()(
persist(
(set) => ({
domains: [],
addDomain: (domain) =>
set((state) => ({
domains: [...state.domains, domain],
})),
}),
{ name: "domain-storage" },
),
);
```
## 5. IPC 通信规范 ## 5. IPC 通信规范
### Result 模式(所有 IPC 返回)
```typescript ```typescript
type Result<T> = { success: true; data: T } | { success: false; error: string }; type Result<T> = { success: true; data: T } | { success: false; error: string };
``` ```
### Preload 暴露 API - IPC 调用使用 `invoke` 返回 `Result<T>`
- Preload 通过 `contextBridge.exposeInMainWorld` 暴露 API
```typescript
const api = {
// 双向通信使用 invoke
fetchDomains: () => ipcRenderer.invoke("fetch-domains"),
// 单向通信使用 send
notify: (message: string) => ipcRenderer.send("notify", message),
};
contextBridge.exposeInMainWorld("api", api);
```
### 类型定义
```typescript
// preload/index.d.ts
declare global {
interface Window {
api: ElectronAPI;
}
}
```
## 6. UI 组件规范 ## 6. UI 组件规范
使用 **Ant Design 6** + **antd-style**(不用 Tailwind **UI Kit 优先使用 LobeHub UI** (`@lobehub/ui`),其次使用 Ant Design 6 + antd-style
```typescript ```typescript
import { createStyles } from 'antd-style' import { Button } from '@lobehub/ui';
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token, css }) => ({ // antd-style 仅用于自定义样式
container: css` export const useStyles = createStyles(({ token, css }) => ({
padding: ${token.paddingLG}px; container: css`padding: ${token.paddingLG}px;`
background: ${token.colorBgContainer}; }));
border-radius: ${token.borderRadiusLG}px;
`
}))
function MyComponent() {
const { styles } = useStyles()
return <div className={styles.container}>...</div>
}
``` ```
国际化:使用中文默认 `import zhCN from 'antd/locale/zh_CN'` - 国际化:使用中文默认 `import zhCN from 'antd/locale/zh_CN'`
- 禁止使用 Tailwind
## 7. 安全规范 ## 7. 安全规范
### 密码存储 - 密码使用 `electron``safeStorage` 加密存储
- WebPreferences 必须:`contextIsolation: true`, `nodeIntegration: false`, `sandbox: false`
```typescript
import { safeStorage } from "electron";
// 加密
const encrypted = safeStorage.encryptString(password);
// 解密
const decrypted = safeStorage.decryptString(encrypted);
```
### WebPreferences
必须启用:`contextIsolation: true`, `nodeIntegration: false`, `sandbox: false`
## 8. 错误处理 ## 8. 错误处理
```typescript - 所有 IPC 返回 `Result<T>` 格式
// 主进程 IPC - 渲染进程检查 `result.success` 处理错误
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 环境配置 ## 9. fnm 环境配置
所有 npm/npx 命令需加载 fnm 环境: 所有 npm/npx 命令需加载 fnm 环境:
```bash ```bash
# 使用 fnm wrapper推荐
~/.config/opencode/node-fnm-wrapper.sh npm run dev
# 或手动加载 fnm
eval "$(fnm env --use-on-cd)" && npm run dev eval "$(fnm env --use-on-cd)" && npm run dev
``` ```
## 10. 注意事项 ## 10. 工具使用规范
### 使用 `edit_file` 进行代码编辑
推荐使用 `edit_file` 工具进行代码修改,它支持部分代码片段编辑,无需提供完整文件内容:
```typescript
// 示例:替换单行代码
edit_file({
filePath: "src/main/index.ts",
edits: [{
pos: "42#XZ", // LINE#ID 格式
op: "replace",
lines: "new line content"
}]
})
// 示例:删除代码块
edit_file({
filePath: "src/main/index.ts",
edits: [{
pos: "40#AB",
end: "45#CD",
op: "replace",
lines: null // null 表示删除
}]
})
```
### LINE#ID 格式说明
- 格式:`{line_number}#{hash_id}`
- hash_id 是每行唯一的两位标识符
- 从 read 工具输出中获取正确的 LINE#ID
## 11. 注意事项
1. **ESM Only**: LobeHub UI 仅支持 ESM 1. **ESM Only**: LobeHub UI 仅支持 ESM
2. **React 19**: 使用 `@types/react@^19.0.0` 2. **React 19**: 使用 `@types/react@^19.0.0`
@@ -284,7 +204,7 @@ eval "$(fnm env --use-on-cd)" && npm run dev
4. **禁止 `as any`**: 使用类型守卫或 `unknown` 4. **禁止 `as any`**: 使用类型守卫或 `unknown`
5. **函数组件优先**: 禁止 class 组件 5. **函数组件优先**: 禁止 class 组件
## 11. MVP Phase - Breaking Changes ## 12. MVP Phase - Breaking Changes
**This is MVP phase - breaking changes are acceptable for better design.** However, you MUST: **This is MVP phase - breaking changes are acceptable for better design.** However, you MUST:
@@ -309,7 +229,7 @@ eval "$(fnm env --use-on-cd)" && npm run dev
4. If significant, ask user for confirmation before implementing 4. If significant, ask user for confirmation before implementing
5. Update related documentation after implementation 5. Update related documentation after implementation
## 12. 测试规范 ## 13. 测试规范
### 测试框架 ### 测试框架
@@ -331,19 +251,6 @@ tests/
2. 新增 API 功能时,应在 `src/main/__tests__/` 中添加对应测试 2. 新增 API 功能时,应在 `src/main/__tests__/` 中添加对应测试
3. 测试文件命名:`*.test.ts` 3. 测试文件命名:`*.test.ts`
### 运行测试
```bash
# 运行所有测试
npm test
# 监听模式
npm run test:watch
# 测试覆盖率
npm run test:coverage
```
### Mock Electron API ### Mock Electron API
测试中需要 Mock Electron 的 API`safeStorage``app` 等),已在 `tests/mocks/electron.ts` 中提供。 测试中需要 Mock Electron 的 API`safeStorage``app` 等),已在 `tests/mocks/electron.ts` 中提供。
@@ -352,37 +259,3 @@ npm run test:coverage
// tests/setup.ts 中自动 Mock // tests/setup.ts 中自动 Mock
vi.mock("electron", () => import("./mocks/electron")); vi.mock("electron", () => import("./mocks/electron"));
``` ```
### 测试示例
```typescript
import { describe, expect, it, beforeAll } from "vitest";
import { SelfKintoneClient, createKintoneClient } from "@main/kintone-api";
import type { DomainWithPassword } from "@shared/types/domain";
describe("SelfKintoneClient", () => {
let client: SelfKintoneClient;
beforeAll(() => {
client = createKintoneClient({
id: "test",
name: "Test",
domain: "example.cybozu.com",
username: "user",
password: "pass",
authType: "password",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
});
it("should return the correct domain", () => {
expect(client.getDomain()).toBe("example.cybozu.com");
});
it("should test connection successfully", async () => {
const result = await client.testConnection();
expect(result.success).toBe(true);
});
});
```

View File

@@ -2,24 +2,34 @@ import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload"; import { electronAPI } from "@electron-toolkit/preload";
import type { SelfAPI } from "./index.d"; import type { SelfAPI } from "./index.d";
// Static properties that are not IPC methods // Plain object API - contextBridge cannot serialize Proxy objects
const staticProps = { const api: SelfAPI = {
platform: process.platform, platform: process.platform,
} as const;
// Proxy-based API that automatically routes method calls to IPC invoke // Domain management
// Method name = IPC channel name, no manual mapping needed getDomains: () => ipcRenderer.invoke("getDomains"),
const api: SelfAPI = new Proxy(staticProps as SelfAPI, { createDomain: (params) => ipcRenderer.invoke("createDomain", params),
get(_target, prop: string) { updateDomain: (params) => ipcRenderer.invoke("updateDomain", params),
// Return static property if exists deleteDomain: (id) => ipcRenderer.invoke("deleteDomain", id),
if (prop in staticProps) { testConnection: (id) => ipcRenderer.invoke("testConnection", id),
return (staticProps as Record<string, unknown>)[prop]; testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params),
}
// Otherwise, auto-create IPC invoke function // Browse
// prop (method name) = IPC channel name getApps: (params) => ipcRenderer.invoke("getApps", params),
return (...args: unknown[]) => ipcRenderer.invoke(prop, ...args); getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params),
}, getFileContent: (params) => ipcRenderer.invoke("getFileContent", params),
});
// Deploy
deploy: (params) => ipcRenderer.invoke("deploy", params),
// Download
download: (params) => ipcRenderer.invoke("download", params),
// Version management
getVersions: (params) => ipcRenderer.invoke("getVersions", params),
deleteVersion: (id) => ipcRenderer.invoke("deleteVersion", id),
rollback: (params) => ipcRenderer.invoke("rollback", params),
};
// Use `contextBridge` APIs to expose Electron APIs to // Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise // renderer only if context isolation is enabled, otherwise
@@ -29,9 +39,11 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI); contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("api", api); contextBridge.exposeInMainWorld("api", api);
} catch (error) { } catch (error) {
console.error(error); console.error("[Preload] Failed to expose API:", error);
} }
} else { } else {
(window as Window & { electron: typeof electronAPI; api: SelfAPI }).electron = electronAPI; // @ts-ignore - window is available in non-isolated context
(window as Window & { electron: typeof electronAPI; api: SelfAPI }).api = api; window.electron = electronAPI;
// @ts-ignore
window.api = api;
} }

View File

@@ -4,7 +4,8 @@
*/ */
import React from "react"; import React from "react";
import { Modal, Form, Input, Button, Space, message } from "antd"; import { Modal, Input, Button, InputPassword } from "@lobehub/ui";
import { Form, message } from "antd";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc"; import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
@@ -48,7 +49,12 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
username: editingDomain.username, username: editingDomain.username,
}); });
} else { } else {
form.resetFields(); form.setFieldsValue({
name: "",
domain: "https://alicorn.cybozu.com",
username: "maxz",
password: "7ld7i8vd",
});
} }
} }
}, [open, editingDomain, form]); }, [open, editingDomain, form]);
@@ -157,6 +163,7 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
footer={null} footer={null}
width={520} width={520}
destroyOnClose destroyOnClose
maskClosable={false}
> >
<Form form={form} layout="vertical" className={styles.form}> <Form form={form} layout="vertical" className={styles.form}>
<Form.Item name="name" label="名称" style={{ marginTop: 8 }}> <Form.Item name="name" label="名称" style={{ marginTop: 8 }}>
@@ -205,13 +212,13 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
label="密码" label="密码"
rules={isEdit ? [] : [{ required: true, message: "请输入密码" }]} rules={isEdit ? [] : [{ required: true, message: "请输入密码" }]}
> >
<Input.Password <InputPassword
placeholder={isEdit ? "留空则保持原密码" : "请输入密码"} placeholder={isEdit ? "留空则保持原密码" : "请输入密码"}
/> />
</Form.Item> </Form.Item>
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}> <Form.Item style={{ marginTop: 24, marginBottom: 0 }}>
<Space style={{ width: "100%", justifyContent: "flex-end" }}> <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<Button onClick={onClose}></Button> <Button onClick={onClose}></Button>
<Button onClick={handleTestConnection} loading={testing}> <Button onClick={handleTestConnection} loading={testing}>
@@ -219,7 +226,7 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
<Button type="primary" onClick={handleSubmit} loading={loading}> <Button type="primary" onClick={handleSubmit} loading={loading}>
{isEdit ? "更新" : "创建"} {isEdit ? "更新" : "创建"}
</Button> </Button>
</Space> </div>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>