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/*` |
| `@shared/*` | `src/shared/*` |
## 4. 代码风格
| 别名 | 路径 |
| ------------- | -------------------- |
| `@renderer/*` | `src/renderer/src/*` |
| `@main/*` | `src/main/*` |
| `@preload/*` | `src/preload/*` |
| `@shared/*` | `src/shared/*` |
## 4. 代码风格
### 导入顺序
@@ -104,179 +97,106 @@ import "./styles.css";
### TypeScript 规范
```typescript
// 显式类型定义,避免 any
interface DomainConfig {
id: string;
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;
}
```
- 显式类型定义,避免 `any`
- 使用字面量联合类型(如 `authType: "password" | "api_token"`
- 异步函数返回 `Promise<T>`
- 使用类型守卫处理 `unknown`
### React 组件规范
```typescript
interface DomainListProps {
domains: Domain[]
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
```
- Hooks 放在组件顶部
- 事件处理函数使用 `useCallback`
- 使用 TypeScript 显式定义 props 类型
### Zustand Store 规范
```typescript
import { create } from "zustand";
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" },
),
);
```
- 使用 `persist` 中间件持久化状态
- 定义接口明确 state 和 actions 类型
## 5. IPC 通信规范
### Result 模式(所有 IPC 返回)
```typescript
type Result<T> = { success: true; data: T } | { success: false; error: string };
```
### Preload 暴露 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;
}
}
```
- IPC 调用使用 `invoke` 返回 `Result<T>`
- Preload 通过 `contextBridge.exposeInMainWorld` 暴露 API
## 6. UI 组件规范
使用 **Ant Design 6** + **antd-style**(不用 Tailwind
**UI Kit 优先使用 LobeHub UI** (`@lobehub/ui`),其次使用 Ant Design 6 + antd-style
```typescript
import { createStyles } from 'antd-style'
import { Button } from '@lobehub/ui';
import { createStyles } from 'antd-style';
const useStyles = createStyles(({ token, css }) => ({
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>
}
// antd-style 仅用于自定义样式
export const useStyles = createStyles(({ token, css }) => ({
container: css`padding: ${token.paddingLG}px;`
}));
```
国际化:使用中文默认 `import zhCN from 'antd/locale/zh_CN'`
- 国际化:使用中文默认 `import zhCN from 'antd/locale/zh_CN'`
- 禁止使用 Tailwind
## 7. 安全规范
### 密码存储
```typescript
import { safeStorage } from "electron";
// 加密
const encrypted = safeStorage.encryptString(password);
// 解密
const decrypted = safeStorage.decryptString(encrypted);
```
### WebPreferences
必须启用:`contextIsolation: true`, `nodeIntegration: false`, `sandbox: false`
- 密码使用 `electron``safeStorage` 加密存储
- WebPreferences 必须:`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);
}
```
- 所有 IPC 返回 `Result<T>` 格式
- 渲染进程检查 `result.success` 处理错误
## 9. fnm 环境配置
所有 npm/npx 命令需加载 fnm 环境:
```bash
# 使用 fnm wrapper推荐
~/.config/opencode/node-fnm-wrapper.sh npm run dev
# 或手动加载 fnm
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
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`
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:
@@ -309,7 +229,7 @@ eval "$(fnm env --use-on-cd)" && npm run dev
4. If significant, ask user for confirmation before implementing
5. Update related documentation after implementation
## 12. 测试规范
## 13. 测试规范
### 测试框架
@@ -331,19 +251,6 @@ tests/
2. 新增 API 功能时,应在 `src/main/__tests__/` 中添加对应测试
3. 测试文件命名:`*.test.ts`
### 运行测试
```bash
# 运行所有测试
npm test
# 监听模式
npm run test:watch
# 测试覆盖率
npm run test:coverage
```
### Mock Electron API
测试中需要 Mock Electron 的 API`safeStorage``app` 等),已在 `tests/mocks/electron.ts` 中提供。
@@ -352,37 +259,3 @@ npm run test:coverage
// tests/setup.ts 中自动 Mock
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 type { SelfAPI } from "./index.d";
// Static properties that are not IPC methods
const staticProps = {
// Plain object API - contextBridge cannot serialize Proxy objects
const api: SelfAPI = {
platform: process.platform,
} as const;
// Proxy-based API that automatically routes method calls to IPC invoke
// Method name = IPC channel name, no manual mapping needed
const api: SelfAPI = new Proxy(staticProps as SelfAPI, {
get(_target, prop: string) {
// Return static property if exists
if (prop in staticProps) {
return (staticProps as Record<string, unknown>)[prop];
}
// Otherwise, auto-create IPC invoke function
// prop (method name) = IPC channel name
return (...args: unknown[]) => ipcRenderer.invoke(prop, ...args);
},
});
// Domain management
getDomains: () => ipcRenderer.invoke("getDomains"),
createDomain: (params) => ipcRenderer.invoke("createDomain", params),
updateDomain: (params) => ipcRenderer.invoke("updateDomain", params),
deleteDomain: (id) => ipcRenderer.invoke("deleteDomain", id),
testConnection: (id) => ipcRenderer.invoke("testConnection", id),
testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params),
// Browse
getApps: (params) => ipcRenderer.invoke("getApps", params),
getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params),
getFileContent: (params) => ipcRenderer.invoke("getFileContent", params),
// Deploy
deploy: (params) => ipcRenderer.invoke("deploy", params),
// 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
// renderer only if context isolation is enabled, otherwise
@@ -29,9 +39,11 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("api", api);
} catch (error) {
console.error(error);
console.error("[Preload] Failed to expose API:", error);
}
} else {
(window as Window & { electron: typeof electronAPI; api: SelfAPI }).electron = electronAPI;
(window as Window & { electron: typeof electronAPI; api: SelfAPI }).api = api;
// @ts-ignore - window is available in non-isolated context
window.electron = electronAPI;
// @ts-ignore
window.api = api;
}

View File

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