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