update
This commit is contained in:
259
AGENTS.md
259
AGENTS.md
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user