Compare commits
1 Commits
master
...
a5da34e1eb
| Author | SHA1 | Date | |
|---|---|---|---|
| a5da34e1eb |
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -1,6 +0,0 @@
|
||||
out/
|
||||
dist/
|
||||
release/
|
||||
node_modules/
|
||||
*.min.js
|
||||
*.min.css
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 160,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
}
|
||||
19
.sisyphus/boulder.json
Normal file
19
.sisyphus/boulder.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"active_plan": "C:\\dev\\workspace\\kintone\\kintone-customize-manager\\.sisyphus\\plans\\core-features.md",
|
||||
"started_at": "2026-03-11T17:02:21.927Z",
|
||||
"session_ids": ["ses_322268b6dffeAXa5zf6vxOGLAh"],
|
||||
"plan_name": "core-features",
|
||||
"agent": "atlas",
|
||||
"worktree_path": "C:\\dev\\workspace\\kintone\\kintone-customize-manager",
|
||||
"progress": {
|
||||
"completed_waves": 6,
|
||||
"total_waves": 6,
|
||||
"status": "completed",
|
||||
"last_updated": "2026-03-12T02:00:00.000Z"
|
||||
}
|
||||
"completed_waves": 4,
|
||||
"total_waves": 6,
|
||||
"status": "in_progress",
|
||||
"last_updated": "2026-03-12T01:45:00.000Z"
|
||||
}
|
||||
}
|
||||
57
.sisyphus/notepads/core-features/learnings.md
Normal file
57
.sisyphus/notepads/core-features/learnings.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Learnings
|
||||
|
||||
## [2026-03-12] Kintone Customize Manager Core Features
|
||||
|
||||
### Technical Decisions
|
||||
|
||||
1. **Type System Organization**
|
||||
- Created separate type files for domain, kintone, version, and ipc types
|
||||
- Used `Result<T>` pattern for unified IPC response handling
|
||||
- Added path alias `@renderer/*` to tsconfig.node.json for main process imports
|
||||
|
||||
2. **SafeStorage for Password Encryption**
|
||||
- Used Electron's built-in `safeStorage` API instead of deprecated `keytar`
|
||||
- Implemented `isSecureStorageAvailable()` check for Linux compatibility
|
||||
- Store encrypted passwords as base64 strings in separate secure store
|
||||
|
||||
3. **Kintone API Client**
|
||||
- Used native `fetch` API (Node.js 18+) instead of axios
|
||||
- Implemented 30-second timeout with AbortController
|
||||
- Support both password authentication and API token authentication
|
||||
|
||||
4. **IPC Architecture**
|
||||
- All handlers return `{ success: boolean, data?: T, error?: string }`
|
||||
- Used `contextBridge` to expose safe API to renderer
|
||||
- Created typed `ElectronAPI` interface for window.api
|
||||
|
||||
5. **State Management**
|
||||
- Zustand with persist middleware for domain persistence
|
||||
- Separate stores for domain, app, deploy, and version state
|
||||
- IPC calls in store actions, not in components
|
||||
|
||||
6. **UI Components**
|
||||
- LobeHub UI + Ant Design 6 + antd-style for styling
|
||||
- CodeMirror 6 for code viewing with syntax highlighting
|
||||
- Step-by-step deploy dialog with confirmation
|
||||
|
||||
### Gotchas
|
||||
|
||||
1. **tsconfig.node.json needs @renderer/\* path alias**
|
||||
- Main process files import from `@renderer/types`
|
||||
- Without the alias, build fails
|
||||
|
||||
2. **JSON import in preload requires named exports**
|
||||
- Use `{ Component }` syntax, not `import Component from ...`
|
||||
- Default exports don't work with contextBridge
|
||||
|
||||
3. **CodeMirror extensions must match file type**
|
||||
- Use `javascript()` for JS, `css()` for CSS files
|
||||
- Extensions are loaded dynamically based on file type
|
||||
|
||||
### Files Created
|
||||
|
||||
- **Types**: domain.ts, kintone.ts, version.ts, ipc.ts
|
||||
- **Main**: storage.ts, kintone-api.ts, ipc-handlers.ts
|
||||
- **Preload**: index.ts, index.d.ts (updated)
|
||||
- **Stores**: domainStore.ts, appStore.ts, deployStore.ts, versionStore.ts
|
||||
- **Components**: DomainManager/, SpaceTree/, AppDetail/, CodeViewer/, FileUploader/, DeployDialog/, VersionHistory/
|
||||
857
.sisyphus/plans/core-features.md
Normal file
857
.sisyphus/plans/core-features.md
Normal file
@@ -0,0 +1,857 @@
|
||||
# Kintone Customize Manager - 核心功能实现计划
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: 实现 Kintone Customize Manager 的核心功能,包括多 Domain 管理、资源浏览、拖拽部署和版本管理。
|
||||
>
|
||||
> **Deliverables**:
|
||||
> - 多 Domain 配置管理(创建/编辑/删除/切换)
|
||||
> - Space 和 App 浏览树
|
||||
> - 拖拽文件部署到 Kintone
|
||||
> - 版本历史管理
|
||||
> - Kintone API 封装
|
||||
>
|
||||
> **Estimated Effort**: Large
|
||||
> **Parallel Execution**: YES - 6 waves
|
||||
> **Critical Path**: 类型定义 → Store → API 封装 → Domain 管理 → App 详情 → 部署功能
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
按照 REQUIREMENTS.md 创建任务计划,实现 Kintone Customize Manager 桌面应用的核心功能。
|
||||
|
||||
### Interview Summary
|
||||
**Key Discussions**:
|
||||
- **项目状态**: 全新项目,已使用 electron-vite 脚手架初始化并引入依赖库
|
||||
- **优先级**: 核心功能优先(Domain 管理 + 部署)
|
||||
- **测试策略**: 仅 QA 验证(不需要单元测试)
|
||||
|
||||
**Research Findings**:
|
||||
- 项目已配置 electron-vite + electron-builder
|
||||
- 已安装 LobeHub UI + Ant Design 6 + CodeMirror 6
|
||||
- 主进程入口已创建(src/main/index.ts)
|
||||
- 基础项目结构已建立
|
||||
|
||||
---
|
||||
|
||||
## Work Objectives
|
||||
|
||||
### Core Objective
|
||||
实现 Kintone Customize Manager 的核心功能,让用户能够管理多个 Kintone 实例、浏览应用资源、拖拽部署自定义代码。
|
||||
|
||||
### Concrete Deliverables
|
||||
- `src/main/kintone-api.ts` - Kintone REST API 封装
|
||||
- `src/main/storage.ts` - 文件系统和密码存储
|
||||
- `src/renderer/src/stores/domainStore.ts` - Domain 状态管理
|
||||
- `src/renderer/src/components/DomainManager/` - Domain 管理 UI
|
||||
- `src/renderer/src/components/SpaceTree/` - Space/App 浏览树
|
||||
- `src/renderer/src/components/AppDetail/` - App 详情页
|
||||
- `src/renderer/src/components/FileUploader/` - 拖拽部署组件
|
||||
- `src/renderer/src/components/VersionHistory/` - 版本历史
|
||||
|
||||
### Definition of Done
|
||||
- [ ] 可创建/编辑/删除 Domain 配置
|
||||
- [ ] 可浏览 Space 和 App 列表
|
||||
- [ ] 可拖拽文件部署到 Kintone
|
||||
- [ ] 部署前自动备份当前版本
|
||||
- [ ] 可查看版本历史
|
||||
|
||||
### Must Have
|
||||
- 密码使用 safeStorage 加密存储
|
||||
- 所有 Kintone API 调用有错误处理
|
||||
- 部署前有确认 Dialog 和代码对比
|
||||
|
||||
### Must NOT Have (Guardrails)
|
||||
- 不包含 Plugin 管理功能(P2 需求)
|
||||
- 不包含批量操作功能(P1 需求)
|
||||
- 不包含国际化(初始版本仅中文)
|
||||
- 不实现单元测试(仅 QA 验证)
|
||||
|
||||
### Commit Policy (IMPORTANT)
|
||||
**一个任务 = 一次提交**
|
||||
|
||||
- ✅ 每个任务完成后必须单独提交
|
||||
- ✅ 提交信息格式:`type(scope): description`(约定式提交)
|
||||
- ❌ 不要将多个任务合并到一次提交中
|
||||
- ⚠️ 如果计划中标注 "groups with N",同 Wave 任务可合并提交
|
||||
|
||||
**提交类型**:`feat` | `fix` | `refactor` | `docs` | `style` | `test` | `chore`
|
||||
|
||||
**提交前检查清单**:
|
||||
1. 完成该任务的所有 "What to do" 内容
|
||||
2. 通过 `npx tsc --noEmit` 类型检查
|
||||
3. 执行该任务的 QA Scenarios 并保存证据到 `.sisyphus/evidence/`
|
||||
4. 代码格式化(`npm run format`)
|
||||
- 不包含 Plugin 管理功能(P2 需求)
|
||||
- 不包含批量操作功能(P1 需求)
|
||||
- 不包含国际化(初始版本仅中文)
|
||||
- 不实现单元测试(仅 QA 验证)
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
|
||||
|
||||
### Test Decision
|
||||
- **Infrastructure exists**: NO
|
||||
- **Automated tests**: None
|
||||
- **Framework**: None
|
||||
- **Agent-Executed QA**: ALWAYS (mandatory for all tasks)
|
||||
|
||||
### QA Policy
|
||||
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`.
|
||||
|
||||
- **Frontend/UI**: Use Playwright — Navigate, interact, assert DOM, screenshot
|
||||
- **API/Backend**: Use Bash (curl) — Send requests, assert status + response fields
|
||||
- **Main Process**: Use Bash (ts-node) — Run IPC handlers, verify responses
|
||||
|
||||
---
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
```
|
||||
Wave 1 (Start Immediately — foundation + types):
|
||||
├── Task 1: 共享类型定义 [quick]
|
||||
├── Task 2: 主进程存储模块 [quick]
|
||||
├── Task 3: Kintone API 封装 [deep]
|
||||
├── Task 4: IPC 通信处理 [quick]
|
||||
├── Task 5: Preload API 暴露 [quick]
|
||||
└── Task 6: Zustand Store 基础 [quick]
|
||||
|
||||
Wave 2 (After Wave 1 — Domain 管理):
|
||||
├── Task 7: Domain Store 完整实现 [quick]
|
||||
├── Task 8: DomainManager UI 组件 [visual-engineering]
|
||||
├── Task 9: Domain 表单 Dialog [visual-engineering]
|
||||
└── Task 10: Domain 列表连接 Store [quick]
|
||||
|
||||
Wave 3 (After Wave 2 — 资源浏览):
|
||||
├── Task 11: SpaceTree 组件 [visual-engineering]
|
||||
├── Task 12: App 列表渲染 [visual-engineering]
|
||||
├── Task 13: App 详情页布局 [visual-engineering]
|
||||
└── Task 14: 代码查看器组件 [visual-engineering]
|
||||
|
||||
Wave 4 (After Wave 3 — 部署功能):
|
||||
├── Task 15: FileUploader 拖拽组件 [visual-engineering]
|
||||
├── Task 16: 部署位置选择器 [visual-engineering]
|
||||
├── Task 17: 代码 Diff 对比组件 [visual-engineering]
|
||||
├── Task 18: 部署确认 Dialog [visual-engineering]
|
||||
└── Task 19: 部署执行 IPC 处理 [deep]
|
||||
|
||||
Wave 5 (After Wave 4 — 版本管理):
|
||||
├── Task 20: 版本历史存储逻辑 [quick]
|
||||
├── Task 21: VersionHistory UI 组件 [visual-engineering]
|
||||
├── Task 22: 版本对比功能 [visual-engineering]
|
||||
└── Task 23: 版本回滚部署 [deep]
|
||||
|
||||
Wave 6 (After Wave 5 — 集成 + QA):
|
||||
├── Task 24: App 页面集成 [deep]
|
||||
├── Task 25: 端到端 Playwright QA [unspecified-high]
|
||||
└── Task 26: Git 清理 + 标签 [git]
|
||||
|
||||
Critical Path: Task 1 → Task 6 → Task 7 → Task 10 → Task 11 → Task 13 → Task 15 → Task 19 → Task 24 → Task 25
|
||||
Parallel Speedup: ~65% faster than sequential
|
||||
Max Concurrent: 5-6 (Waves 1, 3, 4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
- [x] 1. 共享类型定义
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/renderer/src/types/domain.ts` - Domain 配置类型
|
||||
- 创建 `src/renderer/src/types/kintone.ts` - Kintone API 响应类型
|
||||
- 创建 `src/renderer/src/types/version.ts` - 版本历史类型
|
||||
- 创建 `src/renderer/src/types/ipc.ts` - IPC 通信类型
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含业务逻辑(仅类型定义)
|
||||
- 不要使用 `any` 类型
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `quick`
|
||||
- **Skills**: []
|
||||
- **Reason**: 简单的类型定义文件,无复杂逻辑
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 1 (with Tasks 2-6)
|
||||
- **Blocks**: Tasks 3, 7, 11, 20
|
||||
- **Blocked By**: None
|
||||
|
||||
**References**:
|
||||
- `REQUIREMENTS.md:272-284` - Domain 数据结构
|
||||
- `REQUIREMENTS.md:405-437` - Download/Version 数据结构
|
||||
- `REQUIREMENTS.md:527-545` - Deploy 数据结构
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] `src/renderer/src/types/domain.ts` 包含 Domain 接口
|
||||
- [ ] `src/renderer/src/types/kintone.ts` 包含 Kintone API 响应类型
|
||||
- [ ] `src/renderer/src/types/version.ts` 包含 Version 接口
|
||||
- [ ] `src/renderer/src/types/ipc.ts` 包含 IPC 请求/响应类型
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: 类型定义编译检查
|
||||
Tool: Bash (tsc)
|
||||
Preconditions: 项目根目录
|
||||
Steps:
|
||||
1. 运行 `npx tsc --noEmit`
|
||||
2. 验证输出无错误
|
||||
Expected Result: 编译通过,无类型错误
|
||||
Evidence: .sisyphus/evidence/task-1-types-check.txt
|
||||
```
|
||||
|
||||
**Commit**: YES (groups with 2-6)
|
||||
- Message: `feat(types): add shared TypeScript type definitions`
|
||||
- Files: `src/renderer/src/types/*.ts`
|
||||
|
||||
---
|
||||
|
||||
- [x] 2. 主进程存储模块
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/main/storage.ts` - 文件系统操作
|
||||
- 实现 safeStorage 加密存储密码
|
||||
- 实现 Domain 配置的读取/保存
|
||||
- 实现版本历史的存储逻辑
|
||||
- 实现下载文件的存储逻辑
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要硬编码路径(使用 electron-store)
|
||||
- 不要明文存储密码
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `quick`
|
||||
- **Skills**: []
|
||||
- **Reason**: 主进程工具模块,无 UI 交互
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 1 (with Tasks 1, 3-6)
|
||||
- **Blocks**: Tasks 3, 7, 20
|
||||
- **Blocked By**: None
|
||||
|
||||
**References**:
|
||||
- `REQUIREMENTS.md:125-145` - safeStorage 使用示例
|
||||
- `REQUIREMENTS.md:197-214` - 本地存储结构
|
||||
- `package.json:31` - electron-store 依赖
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] `src/main/storage.ts` 包含 saveDomain/getDomain/deleteDomain 函数
|
||||
- [ ] 密码使用 safeStorage 加密
|
||||
- [ ] 配置文件保存在 `~/.kintone-manager/config.json`
|
||||
- [ ] Linux 环境检测 safeStorage 后端
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: 存储模块功能验证
|
||||
Tool: Bash (ts-node)
|
||||
Preconditions: 主进程环境
|
||||
Steps:
|
||||
1. 编写测试脚本调用 saveDomain
|
||||
2. 调用 getDomain 验证读取
|
||||
3. 调用 deleteDomain 验证删除
|
||||
Expected Result: CRUD 操作均成功,返回预期结果
|
||||
Evidence: .sisyphus/evidence/task-2-storage-test.txt
|
||||
```
|
||||
|
||||
**Commit**: YES (groups with 1, 3-6)
|
||||
- Message: `feat(main): implement storage module with safeStorage`
|
||||
- Files: `src/main/storage.ts`
|
||||
|
||||
---
|
||||
|
||||
- [x] 3. Kintone API 封装
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/main/kintone-api.ts` - Kintone REST API 客户端
|
||||
- 实现认证(密码认证 + API Token)
|
||||
- 实现 Space 列表获取
|
||||
- 实现 App 列表获取
|
||||
- 实现 App 配置获取
|
||||
- 实现文件上传/下载
|
||||
- 实现应用配置更新(部署)
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含 UI 逻辑
|
||||
- 不要硬编码域名和凭证
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `deep`
|
||||
- **Skills**: []
|
||||
- **Reason**: 核心业务逻辑,需要处理认证、错误重试、文件流
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 1 (with Tasks 1, 2, 4-6)
|
||||
- **Blocks**: Tasks 11, 12, 19
|
||||
- **Blocked By**: Task 1 (types), Task 2 (storage)
|
||||
|
||||
**References**:
|
||||
- `REQUIREMENTS.md:331-345` - Kintone API 端点
|
||||
- `REQUIREMENTS.md:502-522` - 部署 API 端点
|
||||
- `REQUIREMENTS.md:77-88` - 密码认证 vs API Token
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] `src/main/kintone-api.ts` 包含 KintoneClient 类
|
||||
- [ ] 支持密码认证和 API Token 认证
|
||||
- [ ] 实现 getSpaces/getApps/getAppConfig 方法
|
||||
- [ ] 实现 uploadFile/downloadFile方法
|
||||
- [ ] 实现 updateAppConfig 方法(部署)
|
||||
- [ ] 所有方法有错误处理和超时控制(30 秒)
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Kintone API 连接测试
|
||||
Tool: Bash (ts-node)
|
||||
Preconditions: 有效的 Kintone 凭证
|
||||
Steps:
|
||||
1. 创建 KintoneClient 实例
|
||||
2. 调用 getSpaces() 获取 Space 列表
|
||||
3. 调用 getApps(spaceId) 获取 App 列表
|
||||
Expected Result: 成功返回 Space 和 App 列表,无错误
|
||||
Evidence: .sisyphus/evidence/task-3-api-test.txt
|
||||
|
||||
Scenario: 错误处理验证
|
||||
Tool: Bash (ts-node)
|
||||
Preconditions: 无效的 Kintone 凭证
|
||||
Steps:
|
||||
1. 创建 KintoneClient 实例(错误凭证)
|
||||
2. 调用 getSpaces()
|
||||
Expected Result: 抛出认证错误,错误信息清晰
|
||||
Evidence: .sisyphus/evidence/task-3-api-error.txt
|
||||
```
|
||||
|
||||
**Commit**: YES (groups with 1, 2, 4-6)
|
||||
- Message: `feat(main): implement Kintone REST API client`
|
||||
- Files: `src/main/kintone-api.ts`
|
||||
|
||||
---
|
||||
|
||||
- [x] 4. IPC 通信处理
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/main/ipc-handlers.ts` - IPC 请求处理
|
||||
- 实现 Domain 管理的 IPC 处理(create/update/delete/list)
|
||||
- 实现资源浏览的 IPC 处理(getSpaces/getApps/getAppConfig)
|
||||
- 实现部署的 IPC 处理(deploy/download)
|
||||
- 实现版本管理的 IPC 处理(getVersions/rollback)
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含业务逻辑(调用 kintone-api 和 storage)
|
||||
- 不要直接操作 DOM 或 UI
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `quick`
|
||||
- **Skills**: []
|
||||
- **Reason**: 简单的 IPC 路由和错误处理
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 1 (with Tasks 1-3, 5-6)
|
||||
- **Blocks**: Tasks 7-10, 19
|
||||
- **Blocked By**: Task 2 (storage), Task 3 (kintone-api)
|
||||
|
||||
**References**:
|
||||
- `src/main/index.ts:58` - 现有 IPC 示例
|
||||
- `REQUIREMENTS.md:228-268` - Domain 管理功能需求
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] `src/main/ipc-handlers.ts` 包含所有 Domain 管理 handler
|
||||
- [ ] 包含所有资源浏览 handler
|
||||
- [ ] 包含部署和下载 handler
|
||||
- [ ] 包含版本管理 handler
|
||||
- [ ] 所有 handler 返回统一格式:`{ success: boolean, data?: T, error?: string }`
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: IPC handler 调用测试
|
||||
Tool: Bash (ts-node)
|
||||
Preconditions: 主进程运行中
|
||||
Steps:
|
||||
1. 使用 ipcMain.invoke 调用 handler
|
||||
2. 验证返回格式符合约定
|
||||
Expected Result: 所有 handler 返回成功,格式正确
|
||||
Evidence: .sisyphus/evidence/task-4-ipc-test.txt
|
||||
```
|
||||
|
||||
**Commit**: YES (groups with 1-3, 5-6)
|
||||
- Message: `feat(main): implement IPC handlers for all features`
|
||||
- Files: `src/main/ipc-handlers.ts`
|
||||
|
||||
---
|
||||
|
||||
- [x] 5. Preload API 暴露
|
||||
|
||||
**What to do**:
|
||||
- 更新 `src/preload/index.ts` - 暴露 API 到渲染进程
|
||||
- 定义 `src/preload/index.d.ts` - TypeScript 类型
|
||||
- 使用 contextBridge 暴露安全的 API
|
||||
- 包含所有 IPC 调用的封装
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要暴露完整的 ipcRenderer(不安全)
|
||||
- 不要包含业务逻辑
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `quick`
|
||||
- **Skills**: []
|
||||
- **Reason**: 标准的 Electron preload 模式
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 1 (with Tasks 1-4, 6)
|
||||
- **Blocks**: Tasks 7-26 (all renderer tasks)
|
||||
- **Blocked By**: Task 4 (ipc-handlers)
|
||||
|
||||
**References**:
|
||||
- `src/preload/index.ts` - 现有 preload 脚本
|
||||
- `src/preload/index.d.ts` - 现有类型定义
|
||||
- `AGENTS.md:IPC 通信规范` - IPC 通信规范
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] `src/preload/index.ts` 暴露 window.api 对象
|
||||
- [ ] `src/preload/index.d.ts` 定义 ElectronAPI 接口
|
||||
- [ ] 包含所有 Domain 管理 API
|
||||
- [ ] 包含所有资源浏览 API
|
||||
- [ ] 包含所有部署和版本管理 API
|
||||
- [ ] contextIsolation 启用
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Preload API 可用性检查
|
||||
Tool: Bash (Playwright)
|
||||
Preconditions: 应用启动
|
||||
Steps:
|
||||
1. 打开开发者工具
|
||||
2. 在 Console 中执行 `typeof window.api`
|
||||
Expected Result: 返回 'object',API 对象存在
|
||||
Evidence: .sisyphus/evidence/task-5-preload-check.png
|
||||
```
|
||||
|
||||
**Commit**: YES (groups with 1-4, 6)
|
||||
- Message: `feat(preload): expose IPC APIs via contextBridge`
|
||||
- Files: `src/preload/index.ts`, `src/preload/index.d.ts`
|
||||
|
||||
---
|
||||
|
||||
- [x] 6. Zustand Store 基础架构
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/renderer/src/stores/domainStore.ts` - Domain 状态
|
||||
- 创建 `src/renderer/src/stores/appStore.ts` - App 浏览状态
|
||||
- 创建 `src/renderer/src/stores/deployStore.ts` - 部署状态
|
||||
- 创建 `src/renderer/src/stores/versionStore.ts` - 版本历史状态
|
||||
- 使用 persist 中间件持久化
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含 UI 逻辑
|
||||
- 不要直接调用 IPC(在 actions 中调用)
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `quick`
|
||||
- **Skills**: []
|
||||
- **Reason**: 标准 Zustand 模式
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 1 (with Tasks 1-5)
|
||||
- **Blocks**: Tasks 7-26 (all UI components)
|
||||
- **Blocked By**: Task 1 (types), Task 5 (preload API)
|
||||
|
||||
**References**:
|
||||
- `AGENTS.md:Zustand Store 规范` - Zustand 规范
|
||||
- `package.json:37` - zustand 依赖
|
||||
- `REQUIREMENTS.md:269-284` - Domain 数据结构
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] `domainStore.ts` 包含 domains/currentDomain/actions
|
||||
- [ ] `appStore.ts` 包含 spaces/apps/currentApp
|
||||
- [ ] `deployStore.ts` 包含 deployParams/status
|
||||
- [ ] `versionStore.ts` 包含 versions/currentVersion
|
||||
- [ ] 所有 store 使用 persist 中间件
|
||||
- [ ] TypeScript 类型完整
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Store 基础功能验证
|
||||
Tool: Bash (Playwright)
|
||||
Preconditions: 应用启动
|
||||
Steps:
|
||||
1. 打开开发者工具 Console
|
||||
2. 验证 store 存在并可访问
|
||||
Expected Result: 所有 store 可访问,无错误
|
||||
Evidence: .sisyphus/evidence/task-6-store-check.png
|
||||
```
|
||||
|
||||
**Commit**: YES (groups with 1-5)
|
||||
- Message: `feat(renderer): setup Zustand stores for all features`
|
||||
- Files: `src/renderer/src/stores/*.ts`
|
||||
|
||||
---
|
||||
|
||||
- [x] 7. Domain Store 完整实现
|
||||
|
||||
**What to do**:
|
||||
- 完善 `domainStore.ts` - 添加 IPC 调用
|
||||
- 实现 loadDomains - 加载所有 Domain
|
||||
- 实现 addDomain - 添加新 Domain
|
||||
- 实现 updateDomain - 更新 Domain
|
||||
- 实现 deleteDomain - 删除 Domain
|
||||
- 实现 setCurrentDomain - 切换 Domain
|
||||
- 实现 testConnection - 测试连接
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含 UI 逻辑
|
||||
- 不要硬编码错误处理
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `quick`
|
||||
- **Skills**: []
|
||||
- **Reason**: Store 逻辑扩展
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 2 (with Tasks 8-10)
|
||||
- **Blocks**: Tasks 8-10 (Domain UI)
|
||||
- **Blocked By**: Task 6 (store基础), Task 5 (preload API)
|
||||
|
||||
**References**:
|
||||
- `src/renderer/src/stores/domainStore.ts` - 基础 store
|
||||
- `REQUIREMENTS.md:228-268` - Domain 管理功能
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] 所有 actions 调用 window.api
|
||||
- [ ] 错误处理完善
|
||||
- [ ] 状态更新正确
|
||||
- [ ] 连接状态检测实现
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Domain CRUD 操作
|
||||
Tool: Bash (Playwright)
|
||||
Preconditions: 应用启动
|
||||
Steps:
|
||||
1. 调用 addDomain 添加测试 Domain
|
||||
2. 调用 loadDomains 验证已添加
|
||||
3. 调用 updateDomain 更新信息
|
||||
4. 调用 deleteDomain 删除
|
||||
Expected Result: 所有操作成功,状态正确更新
|
||||
Evidence: .sisyphus/evidence/task-7-domain-crud.txt
|
||||
```
|
||||
|
||||
**Commit**: YES
|
||||
- Message: `feat(store): implement domain store actions with IPC`
|
||||
- Files: `src/renderer/src/stores/domainStore.ts`
|
||||
|
||||
---
|
||||
|
||||
- [x] 8. DomainManager UI 组件
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/renderer/src/components/DomainManager/DomainManager.tsx`
|
||||
- 创建 `src/renderer/src/components/DomainManager/DomainList.tsx`
|
||||
- 创建 `src/renderer/src/components/DomainManager/DomainItem.tsx`
|
||||
- 使用 LobeHub UI + Ant Design 组件
|
||||
- 显示 Domain 列表和连接状态
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含业务逻辑(使用 store)
|
||||
- 不要直接调用 IPC
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `visual-engineering`
|
||||
- **Skills**: []
|
||||
- **Reason**: UI 组件开发,需要设计布局
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 2 (with Tasks 7, 9-10)
|
||||
- **Blocks**: Tasks 10 (连接 Store)
|
||||
- **Blocked By**: Task 7 (domain store)
|
||||
|
||||
**References**:
|
||||
- `AGENTS.md:UI 组件规范` - LobeHub UI + antd-style
|
||||
- `src/renderer/src/App.tsx` - 现有组件示例
|
||||
- `package.json:27-30` - LobeHub UI + Ant Design
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] DomainManager 组件包含 Domain 列表
|
||||
- [ ] DomainItem 显示名称、域名、连接状态
|
||||
- [ ] 使用 antd-style 样式
|
||||
- [ ] 支持暗黑模式
|
||||
- [ ] 响应式布局
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: DomainManager 渲染检查
|
||||
Tool: Playwright
|
||||
Preconditions: 应用启动,有测试 Domain
|
||||
Steps:
|
||||
1. 导航到 DomainManager 页面
|
||||
2. 验证 Domain 列表渲染
|
||||
3. 验证连接状态图标显示
|
||||
Expected Result: Domain 列表正确显示,样式正确
|
||||
Evidence: .sisyphus/evidence/task-8-domain-manager-render.png
|
||||
```
|
||||
|
||||
**Commit**: YES
|
||||
- Message: `feat(ui): create DomainManager components`
|
||||
- Files: `src/renderer/src/components/DomainManager/*.tsx`
|
||||
|
||||
---
|
||||
|
||||
- [x] 9. Domain 表单 Dialog
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/renderer/src/components/DomainManager/DomainForm.tsx`
|
||||
- 实现创建 Domain 表单
|
||||
- 实现编辑 Domain 表单
|
||||
- 使用 Ant Design Form + Modal
|
||||
- 表单验证(必填字段、格式检查)
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含业务逻辑(提交调用 store)
|
||||
- 不要硬编码验证规则
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `visual-engineering`
|
||||
- **Skills**: []
|
||||
- **Reason**: 表单 UI 组件
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 2 (with Tasks 7-8, 10)
|
||||
- **Blocks**: Tasks 10 (表单使用)
|
||||
- **Blocked By**: Task 7 (domain store)
|
||||
|
||||
**References**:
|
||||
- `REQUIREMENTS.md:228-247` - Domain 创建/编辑需求
|
||||
- `AGENTS.md:UI 组件规范` - Ant Design 使用
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] DomainForm 组件支持创建和编辑模式
|
||||
- [ ] 必填字段验证(名称、域名、用户名、密码)
|
||||
- [ ] 认证类型切换(密码/API Token)
|
||||
- [ ] API Token 字段条件显示
|
||||
- [ ] 提交成功关闭 Dialog
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: 创建 Domain 表单
|
||||
Tool: Playwright
|
||||
Preconditions: 应用启动
|
||||
Steps:
|
||||
1. 点击"添加 Domain"按钮
|
||||
2. 填写表单所有字段
|
||||
3. 提交表单
|
||||
4. 验证成功提示和 Dialog 关闭
|
||||
Expected Result: 表单提交成功,Domain 添加到列表
|
||||
Evidence: .sisyphus/evidence/task-9-domain-form-create.png
|
||||
|
||||
Scenario: 表单验证
|
||||
Tool: Playwright
|
||||
Preconditions: 打开创建 Domain Dialog
|
||||
Steps:
|
||||
1. 不填写任何字段,点击提交
|
||||
2. 验证错误提示显示
|
||||
Expected Result: 显示必填字段错误提示
|
||||
Evidence: .sisyphus/evidence/task-9-domain-form-validation.png
|
||||
```
|
||||
|
||||
**Commit**: YES
|
||||
- Message: `feat(ui): create DomainForm dialog component`
|
||||
- Files: `src/renderer/src/components/DomainManager/DomainForm.tsx`
|
||||
|
||||
---
|
||||
|
||||
- [x] 10. Domain 列表连接 Store
|
||||
|
||||
**What to do**:
|
||||
- 更新 `DomainManager.tsx` - 连接 Domain Store
|
||||
- 使用 useDomainStore 获取状态
|
||||
- 实现添加/编辑/删除按钮事件
|
||||
- 实现 Domain 切换功能
|
||||
- 实现连接状态检测
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要创建新的 store
|
||||
- 不要直接调用 IPC
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `quick`
|
||||
- **Skills**: []
|
||||
- **Reason**: 连接现有 store 和组件
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: NO
|
||||
- **Parallel Group**: Sequential (after Tasks 7-9)
|
||||
- **Blocks**: Tasks 11-26 (需要 Domain 切换)
|
||||
- **Blocked By**: Tasks 7, 8, 9
|
||||
|
||||
**References**:
|
||||
- `src/renderer/src/stores/domainStore.ts` - Domain Store
|
||||
- `src/renderer/src/components/DomainManager/` - 现有组件
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] DomainManager 显示所有 Domain
|
||||
- [ ] 点击 Domain 切换当前 Domain
|
||||
- [ ] 添加按钮打开创建 Dialog
|
||||
- [ ] 编辑按钮打开编辑 Dialog
|
||||
- [ ] 删除按钮显示确认 Dialog
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: Domain 切换
|
||||
Tool: Playwright
|
||||
Preconditions: 有多个 Domain
|
||||
Steps:
|
||||
1. 点击 Domain A
|
||||
2. 验证当前 Domain 状态更新
|
||||
3. 验证连接状态检测触发
|
||||
Expected Result: Domain 切换成功,状态更新
|
||||
Evidence: .sisyphus/evidence/task-10-domain-switch.txt
|
||||
```
|
||||
|
||||
**Commit**: YES
|
||||
- Message: `feat(ui): connect DomainManager to store`
|
||||
- Files: `src/renderer/src/components/DomainManager/DomainManager.tsx`
|
||||
|
||||
---
|
||||
|
||||
- [x] 11. SpaceTree 组件
|
||||
|
||||
**What to do**:
|
||||
- 创建 `src/renderer/src/components/SpaceTree/SpaceTree.tsx`
|
||||
- 创建 `src/renderer/src/components/SpaceTree/SpaceNode.tsx`
|
||||
- 创建 `src/renderer/src/components/SpaceTree/AppNode.tsx`
|
||||
- 使用 Ant Design Tree 组件
|
||||
- 显示 Space 分组和 App 列表
|
||||
- 支持搜索和过滤
|
||||
|
||||
**Must NOT do**:
|
||||
- 不要包含业务逻辑(使用 appStore)
|
||||
- 不要直接调用 IPC
|
||||
|
||||
**Recommended Agent Profile**:
|
||||
- **Category**: `visual-engineering`
|
||||
- **Skills**: []
|
||||
- **Reason**: UI 树形组件开发
|
||||
|
||||
**Parallelization**:
|
||||
- **Can Run In Parallel**: YES
|
||||
- **Parallel Group**: Wave 3 (with Tasks 12-14)
|
||||
- **Blocks**: Tasks 13-14 (App 详情)
|
||||
- **Blocked By**: Task 10 (Domain 切换)
|
||||
|
||||
**References**:
|
||||
- `REQUIREMENTS.md:296-330` - 资源浏览功能
|
||||
- `AGENTS.md:UI 组件规范` - Ant Design Tree
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] SpaceTree 显示所有 Space
|
||||
- [ ] 每个 Space 下显示 App 列表
|
||||
- [ ] 支持展开/折叠 Space
|
||||
- [ ] 点击 App 节点触发选择事件
|
||||
- [ ] 支持按名称搜索 App
|
||||
|
||||
**QA Scenarios**:
|
||||
```
|
||||
Scenario: SpaceTree 渲染
|
||||
Tool: Playwright
|
||||
Preconditions: 已选择 Domain,有 Space 和 App
|
||||
Steps:
|
||||
1. 导航到资源浏览页面
|
||||
2. 验证 Space 列表渲染
|
||||
3. 展开 Space,验证 App 列表显示
|
||||
Expected Result: Space 和 App 正确显示,树形结构完整
|
||||
Evidence: .sisyphus/evidence/task-11-spacetree-render.png
|
||||
|
||||
Scenario: App 搜索
|
||||
Tool: Playwright
|
||||
Preconditions: SpaceTree 已加载
|
||||
Steps:
|
||||
1. 在搜索框输入 App 名称关键词
|
||||
2. 验证过滤后的 App 列表
|
||||
Expected Result: 只显示匹配的 App
|
||||
Evidence: .sisyphus/evidence/task-11-spacetree-search.png
|
||||
```
|
||||
|
||||
**Commit**: YES
|
||||
- Message: `feat(ui): create SpaceTree component for browsing resources`
|
||||
- Files: `src/renderer/src/components/SpaceTree/*.tsx`
|
||||
|
||||
---
|
||||
|
||||
- [ ] 12-26. [待补充 - 由于输出限制,任务 12-26 将在后续会话中补充]
|
||||
|
||||
> **Note**: 受模型输出 token 限制,任务 12-26 的完整详情需要在执行过程中补充。
|
||||
> 关键任务包括:
|
||||
> - Task 12: App 列表渲染
|
||||
> - Task 13: App 详情页布局
|
||||
> - Task 14: 代码查看器组件
|
||||
> - Task 15-19: 部署功能(FileUploader、位置选择、Diff 对比、确认 Dialog、部署执行)
|
||||
> - Task 20-23: 版本管理(存储、UI、对比、回滚)
|
||||
> - Task 24: App 页面集成
|
||||
> - Task 25: 端到端 Playwright QA
|
||||
> - Task 26: Git 清理 + 标签
|
||||
|
||||
**执行策略**: 按照 Execution Strategy 中的 Wave 3-6 逐步执行,每个任务的详细规格参考 REQUIREMENTS.md 对应章节。
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave
|
||||
|
||||
> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
|
||||
|
||||
- [ ] F1. **Plan Compliance Audit** — `oracle`
|
||||
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
|
||||
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
|
||||
|
||||
- [ ] F2. **Code Quality Review** — `unspecified-high`
|
||||
Run `tsc --noEmit` + linter + `bun test`. Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp).
|
||||
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT`
|
||||
|
||||
- [ ] F3. **Real Manual QA** — `unspecified-high` (+ `playwright` skill if UI)
|
||||
Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to `.sisyphus/evidence/final-qa/`.
|
||||
Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT`
|
||||
|
||||
- [ ] F4. **Scope Fidelity Check** — `deep`
|
||||
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes.
|
||||
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
- **Wave 1**: `feat(core): foundation modules` — types, storage, kintone-api, ipc, preload, stores
|
||||
- **Wave 2**: `feat(domain): domain management` — domain store, DomainManager UI, DomainForm
|
||||
- **Wave 3**: `feat(browse): resource browsing` — SpaceTree, AppList, AppDetail, CodeViewer
|
||||
- **Wave 4**: `feat(deploy): drag-and-drop deployment` — FileUploader, position selector, diff view, deploy dialog
|
||||
- **Wave 5**: `feat(version): version management` — version history, diff comparison, rollback
|
||||
- **Wave 6**: `feat(integration): app integration + QA` — full app page, Playwright tests
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Verification Commands
|
||||
```bash
|
||||
npx tsc --noEmit # Expected: no errors
|
||||
npm run dev # Expected: dev server starts successfully
|
||||
npm run build # Expected: production build completes
|
||||
```
|
||||
|
||||
### Final Checklist
|
||||
- [ ] 所有"Must Have"功能实现
|
||||
- [ ] 所有"Must NOT Have"功能未实现
|
||||
- [ ] 所有编译通过(tsc --noEmit)
|
||||
- [ ] 所有 QA 场景证据文件存在
|
||||
- [ ] 应用可启动并运行
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "editorconfig.editorconfig", "lokalise.i18n-ally"]
|
||||
}
|
||||
14
.vscode/settings.json
vendored
14
.vscode/settings.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"i18n-ally.namespace": true,
|
||||
"i18n-ally.sourceLanguage": "zh-CN",
|
||||
"i18n-ally.enabledParsers": ["json"],
|
||||
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"]
|
||||
}
|
||||
411
AGENTS.md
411
AGENTS.md
@@ -1,68 +1,81 @@
|
||||
# AGENTS.md
|
||||
|
||||
Kintone Customize Manager - Electron + React 应用,用于管理 Kintone 自定义资源。
|
||||
Kintone Customize Manager 项目开发指南。
|
||||
|
||||
## 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 格式化
|
||||
```
|
||||
|
||||
## 2. 项目架构
|
||||
### 代码质量
|
||||
|
||||
```
|
||||
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 # 类型声明
|
||||
├── shared/ # 跨进程共享代码
|
||||
│ └── types/ # 共享类型定义
|
||||
└── renderer/ # React 渲染进程
|
||||
└── src/
|
||||
├── main.tsx # React 入口
|
||||
├── App.tsx # 根组件
|
||||
├── components/ # React 组件
|
||||
├── stores/ # Zustand Stores
|
||||
└── locales/ # i18n 翻译文件
|
||||
```bash
|
||||
# ESLint 检查
|
||||
npm run lint
|
||||
|
||||
# 格式化代码
|
||||
npm run format
|
||||
```
|
||||
|
||||
### 数据流
|
||||
## 2. 项目结构
|
||||
|
||||
```
|
||||
Renderer (React) → Preload API → IPC → Main Process → Storage/Kintone API
|
||||
↓
|
||||
Result<T> 返回
|
||||
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/ # 构建配置
|
||||
```
|
||||
|
||||
## 3. 路径别名
|
||||
|
||||
| 别名 | 路径 |
|
||||
| ------------- | -------------------- |
|
||||
| 别名 | 路径 |
|
||||
|------|------|
|
||||
| `@renderer/*` | `src/renderer/src/*` |
|
||||
| `@main/*` | `src/main/*` |
|
||||
| `@preload/*` | `src/preload/*` |
|
||||
| `@shared/*` | `src/shared/*` |
|
||||
| `@main/*` | `src/main/*` |
|
||||
| `@preload/*` | `src/preload/*` |
|
||||
|
||||
使用示例:
|
||||
```typescript
|
||||
import { useStore } from '@renderer/stores'
|
||||
import { ipcHandler } from '@main/ipc-handlers'
|
||||
```
|
||||
|
||||
## 4. 代码风格
|
||||
|
||||
@@ -70,139 +83,283 @@ Renderer (React) → Preload API → IPC → Main Process → Storage/Kintone AP
|
||||
|
||||
```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";
|
||||
// 3. 项目内部模块(使用别名)
|
||||
import { useDomainStore } from '@renderer/stores'
|
||||
import { formatDate } from '@renderer/utils'
|
||||
|
||||
// 4. 相对导入
|
||||
import "./styles.css";
|
||||
import './styles.css'
|
||||
```
|
||||
|
||||
### 命名规范
|
||||
|
||||
- 组件文件/名: `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`)
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 组件文件 | 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` |
|
||||
|
||||
### TypeScript 规范
|
||||
|
||||
- 显式类型定义,避免 `any`
|
||||
- 使用字面量联合类型(如 `authType: "password" | "api_token"`)
|
||||
- 异步函数返回 `Promise<T>`
|
||||
- 使用类型守卫处理 `unknown`
|
||||
```typescript
|
||||
// 显式类型定义
|
||||
interface DomainConfig {
|
||||
id: string
|
||||
name: string
|
||||
domain: string
|
||||
username: string
|
||||
authType: 'password' | 'api_token'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 函数返回类型
|
||||
function createWindow(): void { }
|
||||
|
||||
// 异步函数
|
||||
async function fetchDomains(): Promise<Domain[]> { }
|
||||
|
||||
// 避免使用 any,使用 unknown 或具体类型
|
||||
function parseResponse(data: unknown): DomainConfig {
|
||||
// 类型守卫
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
throw new Error('Invalid response')
|
||||
}
|
||||
return data as DomainConfig
|
||||
}
|
||||
```
|
||||
|
||||
### React 组件规范
|
||||
|
||||
- Hooks 放在组件顶部
|
||||
- 事件处理函数使用 `useCallback`
|
||||
- 使用 TypeScript 显式定义 props 类型
|
||||
```typescript
|
||||
// 函数组件优先
|
||||
interface DomainListProps {
|
||||
domains: Domain[]
|
||||
onSelect: (domain: Domain) => void
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default DomainList
|
||||
```
|
||||
|
||||
### Zustand Store 规范
|
||||
|
||||
- 使用 `persist` 中间件持久化状态
|
||||
- 定义接口明确 state 和 actions 类型
|
||||
|
||||
## 5. IPC 通信规范
|
||||
|
||||
```typescript
|
||||
type Result<T> = { success: true; data: T } | { success: false; error: string };
|
||||
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
|
||||
}
|
||||
|
||||
export const useDomainStore = create<DomainState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
domains: [],
|
||||
currentDomain: null,
|
||||
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' }
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
- IPC 调用使用 `invoke` 返回 `Result<T>`
|
||||
- Preload 通过 `contextBridge.exposeInMainWorld` 暴露 API
|
||||
- 所有 IPC handlers 集中在 `src/main/ipc-handlers.ts`
|
||||
## 5. 错误处理
|
||||
|
||||
## 6. UI 组件规范
|
||||
|
||||
**UI Kit 优先使用 LobeHub UI** (`@lobehub/ui`),其次使用 Ant Design 6 + antd-style:
|
||||
### 主进程错误处理
|
||||
|
||||
```typescript
|
||||
import { Button } from "@lobehub/ui";
|
||||
import { createStyles } from "antd-style";
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
// antd-style 仅用于自定义样式
|
||||
export const useStyles = createStyles(({ token, css }) => ({
|
||||
### 渲染进程错误处理
|
||||
|
||||
```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'),
|
||||
|
||||
// 使用 send 进行单向通信
|
||||
notify: (message: string) => ipcRenderer.send('notify', message),
|
||||
|
||||
// 监听事件
|
||||
onUpdate: (callback: (info: UpdateInfo) => void) =>
|
||||
ipcRenderer.on('update-available', (_, info) => callback(info))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. UI 组件规范
|
||||
|
||||
### 使用 LobeHub UI + Ant Design
|
||||
|
||||
```typescript
|
||||
// 使用 antd-style 进行样式
|
||||
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>
|
||||
}
|
||||
```
|
||||
|
||||
- 禁止使用 Tailwind
|
||||
- **ESM Only**: LobeHub UI 仅支持 ESM
|
||||
### 国际化
|
||||
|
||||
## 7. 国际化 (i18n)
|
||||
```typescript
|
||||
// 使用中文默认
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
|
||||
- 支持语言: `en-US`, `ja-JP`, `zh-CN`
|
||||
- 翻译文件位置: `src/renderer/src/locales/{locale}/{namespace}.json`
|
||||
- 使用 `react-i18next` 进行翻译
|
||||
- Ant Design 默认使用日文: `import jaJP from 'antd/locale/ja_JP'`
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
```
|
||||
|
||||
## 8. 安全规范
|
||||
|
||||
- 密码使用 `electron` 的 `safeStorage` 加密存储
|
||||
- WebPreferences 必须:`contextIsolation: true`, `nodeIntegration: false`, `sandbox: false`
|
||||
### 密码存储
|
||||
|
||||
## 9. 错误处理
|
||||
```typescript
|
||||
// 使用 safeStorage 加密存储
|
||||
import { safeStorage } from 'electron'
|
||||
|
||||
- 所有 IPC 返回 `Result<T>` 格式
|
||||
- 渲染进程检查 `result.success` 处理错误
|
||||
// 加密
|
||||
const encrypted = safeStorage.encryptString(password)
|
||||
|
||||
## 10. fnm 环境配置
|
||||
// 解密
|
||||
const decrypted = safeStorage.decryptString(encrypted)
|
||||
```
|
||||
|
||||
所有 npm/npx 命令需加载 fnm 环境:
|
||||
### CSP 配置
|
||||
|
||||
```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:;" />
|
||||
```
|
||||
|
||||
## 9. fnm 环境配置
|
||||
|
||||
所有 npm/npx 命令需要先加载 fnm 环境:
|
||||
|
||||
```bash
|
||||
# 使用 fnm wrapper(推荐)
|
||||
# 方式一:使用 wrapper 脚本
|
||||
~/.config/opencode/node-fnm-wrapper.sh npm run dev
|
||||
|
||||
# 或手动加载 fnm
|
||||
# 方式二:手动加载
|
||||
eval "$(fnm env --use-on-cd)" && npm run dev
|
||||
```
|
||||
|
||||
## 11. 技术栈约束
|
||||
## 10. 注意事项
|
||||
|
||||
1. **React 19**: 使用 `@types/react@^19.0.0`
|
||||
2. **CSS 方案**: 使用 `antd-style`,禁止 Tailwind
|
||||
3. **禁止 `as any`**: 使用类型守卫或 `unknown`
|
||||
4. **函数组件优先**: 禁止 class 组件
|
||||
|
||||
## 12. 沟通规范
|
||||
|
||||
1. **人设**: 在回答的末尾加上「🦐」,用于确认上下文是否被正确保留
|
||||
2. **语言**: 使用中文进行回答
|
||||
|
||||
## 13. 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
|
||||
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`,优先使用类型守卫
|
||||
136
MEMORY.md
136
MEMORY.md
@@ -1,136 +0,0 @@
|
||||
# MEMORY.md
|
||||
|
||||
## 2026-03-17 - GAIA_BL01 部署错误修复(更新)
|
||||
|
||||
### 遇到什么问题
|
||||
|
||||
- 用户点击部署时,如果存在未修改的文件(status: "unchanged"),会报错:`[404] [GAIA_BL01] 指定したファイル(id: XXXXX)が見つかりません。`
|
||||
- **之前分析不够准确**:原匹配逻辑只用文件名匹配,如果存在同名文件会匹配到错误的 fileKey
|
||||
|
||||
### 根本原因(更正)
|
||||
|
||||
1. **匹配逻辑缺陷**:只用文件名匹配,同名文件会返回第一个匹配的错误 fileKey
|
||||
2. **类型守卫过于严格**:`isFileResource()` 要求 `fileKey` 存在才返回 true,可能导致有效文件被跳过
|
||||
3. Kintone Customization API 的正确用法:
|
||||
- 从 `Get Customization` 获取的 fileKey 是实时有效的
|
||||
- 只要文件附加到 Customization,fileKey 就永久有效
|
||||
- 参考:https://docs-customine.gusuku.io/en/error/gaia_bl01/
|
||||
|
||||
### 如何解决的
|
||||
|
||||
修改 `src/main/ipc-handlers.ts` 中的匹配逻辑:
|
||||
|
||||
1. **新增 `isFileType()` 类型守卫**:只检查 `type === "FILE"`,不要求 `fileKey` 存在
|
||||
2. **实现三级匹配策略**:
|
||||
- 优先级 1:用前端传来的 `fileKey` 精确匹配(最可靠)
|
||||
- 优先级 2:用 URL 精确匹配(针对 URL 类型)
|
||||
- 优先级 3:用文件名匹配(fallback,同名文件可能出错)
|
||||
3. 添加详细调试日志和验证检查
|
||||
|
||||
```typescript
|
||||
// 新增类型守卫
|
||||
function isFileType(resource): boolean {
|
||||
return resource.type === "FILE" && !!resource.file;
|
||||
}
|
||||
|
||||
// 三级匹配策略
|
||||
if (file.fileKey) {
|
||||
matchingFile = currentFiles?.find(
|
||||
(f) => isFileType(f) && f.file.fileKey === file.fileKey,
|
||||
);
|
||||
}
|
||||
if (!matchingFile && file.url) {
|
||||
matchingFile = currentFiles?.find(
|
||||
(f) => isUrlResource(f) && f.url === file.url,
|
||||
);
|
||||
}
|
||||
if (!matchingFile) {
|
||||
matchingFile = currentFiles?.find(
|
||||
(f) => isFileType(f) && f.file.name === file.fileName,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 以后如何避免
|
||||
|
||||
1. **匹配逻辑优先使用唯一标识符**:不要只用名称匹配,优先使用 ID、key 等唯一标识
|
||||
2. **类型守卫要区分"类型检查"和"有效性验证"**:
|
||||
- 类型检查:`type === "FILE"`
|
||||
- 有效性验证:`fileKey` 是否存在
|
||||
- 这两个应该分开处理
|
||||
3. **Kintone fileKey 的生命周期**:
|
||||
- 用于 Customization:永久有效(只要附加到 App)
|
||||
- 用于记录附件:3 天内必须使用
|
||||
4. **添加调试日志**:在复杂的匹配逻辑中添加调试日志,便于排查问题
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-17 - GAIA_BL01 部署错误修复(初始记录 - 已过时)
|
||||
|
||||
### 遇到什么问题
|
||||
|
||||
- 用户点击部署时,如果存在未修改的文件(status: "unchanged"),会报错:`[404] [GAIA_BL01] 指定したファイル(id: XXXXX)が見つかりません。`
|
||||
- 根本原因:Kintone 的 `fileKey` 有两种类型:
|
||||
- **临时 fileKey**:Upload File API 生成,3天有效,**使用一次后失效**
|
||||
- **永久 fileKey**:文件附加到记录时,永久有效
|
||||
- 部署时 `getAppCustomize` 返回的 fileKey 是临时的,部署后就被消费
|
||||
- 再次部署时使用已失效的 fileKey 就会报 GAIA_BL01 错误
|
||||
|
||||
### 如何解决的
|
||||
|
||||
修改 `src/main/ipc-handlers.ts` 中的 `registerDeploy` 函数:
|
||||
|
||||
1. 对于 "unchanged" 文件,不再使用前端传递的 `file.fileKey`
|
||||
2. 改为从当前 Kintone 配置(`appDetail.customization`)中根据文件名匹配获取最新的 fileKey
|
||||
3. 如果在当前配置中找不到该文件,抛出明确的错误提示用户刷新
|
||||
|
||||
### 以后如何避免
|
||||
|
||||
- Kintone API 返回的 fileKey 是临时的,每次部署后都会失效
|
||||
- 部署时必须从当前 Kintone 配置获取最新的 fileKey,而不是使用缓存的值
|
||||
- 参考:https://docs-customine.gusuku.io/en/error/gaia_bl01/
|
||||
|
||||
---
|
||||
|
||||
# MEMORY.md
|
||||
|
||||
## 2026-03-15 - CSS 模板字符串语法错误
|
||||
|
||||
### 遇到什么问题
|
||||
|
||||
- 在使用 `edit` 工具修改 `DomainForm.tsx` 中的 CSS 样式时,只替换了部分内容,导致 CSS 模板字符串语法错误
|
||||
- 错误信息:`Unexpected token, expected ","` 在 `passwordHint` 定义处
|
||||
- 原因:`.ant-form-item` 的 CSS 块没有正确关闭,缺少 `}` 和模板字符串结束符 `` ` ``
|
||||
|
||||
### 如何解决的
|
||||
|
||||
- 使用 `edit` 工具完整替换整个 `useStyles` 定义块,确保所有 CSS 模板字符串正确关闭
|
||||
|
||||
### 以后如何避免
|
||||
|
||||
- 修改 CSS-in-JS 样式时,尽量替换完整的样式块而非单行
|
||||
- 修改后立即运行 `npx tsc --noEmit` 验证语法
|
||||
- 注意模板字符串的开始 `` ` `` 和结束 `` ` `` 必须成对出现
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-15 - UI 重构经验
|
||||
|
||||
### 变更内容
|
||||
|
||||
1. **DomainForm 表单间距**:`marginMD` → `marginSM`
|
||||
2. **AppDetail 头部布局**:标题和按钮同一行(flex 布局)
|
||||
3. **AppDetail Tabs 重构**:
|
||||
- 移除 Tabs 组件
|
||||
- 移除 "基本信息" tab
|
||||
- 合并 4 个 JS/CSS tab 为单页面(选项 A:单列滚动列表 + 分区标题)
|
||||
- 新增 `viewMode` 状态管理列表/代码视图切换
|
||||
- 点击文件进入代码视图,带返回按钮
|
||||
|
||||
### 文件修改
|
||||
|
||||
- `src/renderer/src/components/DomainManager/DomainForm.tsx`
|
||||
- `src/renderer/src/components/AppDetail/AppDetail.tsx`
|
||||
- `src/renderer/src/locales/zh-CN/app.json` - 添加 `backToList`
|
||||
- `src/renderer/src/locales/en-US/app.json` - 添加 `backToList`
|
||||
- `src/renderer/src/locales/ja-JP/app.json` - 添加 `backToList`
|
||||
@@ -178,7 +178,8 @@ kintone-customize-manager/
|
||||
│ │ ├─ main.tsx # 渲染进程入口
|
||||
│ │ ├─ App.tsx
|
||||
│ │ ├─ components/ # React 组件
|
||||
│ │ │ ├─ AppList/
|
||||
│ │ │ ├─ DomainManager/
|
||||
│ │ │ ├─ SpaceTree/
|
||||
│ │ │ ├─ AppDetail/
|
||||
│ │ │ ├─ FileUploader/
|
||||
│ │ │ ├─ CodeViewer/
|
||||
@@ -251,7 +252,8 @@ kintone-customize-manager/
|
||||
- 显示 Domain 名称和域名
|
||||
|
||||
**FR-DOMAIN-005**: 切换 Domain
|
||||
- 切换后自动加载该 Domain 下的 App 列表
|
||||
- 用户可从 Domain 列表快速切换当前工作的 Domain
|
||||
- 切换后自动加载该 Domain 下的 Space 和 App 列表
|
||||
|
||||
**FR-DOMAIN-006**: 密码加密存储
|
||||
- 使用 keytar 将密码加密存储到系统密钥链
|
||||
@@ -285,45 +287,40 @@ interface Domain {
|
||||
|
||||
### 3.2 资源浏览
|
||||
|
||||
浏览当前 Domain 下的所有 App,查看应用的自定义资源配置。
|
||||
#### 3.2.1 功能描述
|
||||
|
||||
**FR-BROWSE-001**: 获取 App 列表
|
||||
- 选择 Domain 后显示「加载应用」按钮
|
||||
- 点击按钮后全量获取该 Domain 下的所有 App
|
||||
- 获取过程中显示加载状态
|
||||
- App 列表按 App ID 降序排列
|
||||
- 显示 App 名称、App ID、所属 Space ID(若有)、创建时间
|
||||
- App 数据持久化存储,下次打开应用时自动加载
|
||||
- 支持重新加载(覆盖已有数据)
|
||||
HW|
|
||||
BB|**FR-BROWSE-002**: 列表显示
|
||||
- App 列表使用可点击列表(List 组件)展示,无需分页
|
||||
- 全量显示所有 App,显示 App 名称和 App ID
|
||||
- 点击列表项即可选择 App
|
||||
- 显示总数量和加载时间
|
||||
- 所属 Space 暂时不显示(后续版本支持)
|
||||
NK|
|
||||
**FR-BROWSE-003**: 搜索过滤
|
||||
- 支持按 App 名称搜索
|
||||
- 搜索结果实时过滤
|
||||
浏览当前 Domain 下的所有 Space 和 App,查看应用的自定义资源配置。
|
||||
|
||||
**FR-BROWSE-004**: 获取 App 详情
|
||||
#### 3.2.2 功能需求
|
||||
|
||||
**FR-BROWSE-001**: 获取 Space 列表
|
||||
- 调用 Kintone API 获取当前 Domain 下的所有 Space
|
||||
- 显示 Space 名称和 ID
|
||||
- 支持按 Space 名称排序
|
||||
|
||||
**FR-BROWSE-002**: 获取 App 列表
|
||||
- 按 Space 分组显示 App 列表
|
||||
- 或显示所有 App(不分 Space)
|
||||
- 显示 App 名称、ID、创建时间
|
||||
- 支持按 App 名称搜索和过滤
|
||||
|
||||
**FR-BROWSE-003**: 获取 App 详情
|
||||
- 选择 App 后查看详细配置
|
||||
- 显示以下信息:
|
||||
- App 名称
|
||||
- App ID
|
||||
- 所属 Space ID(如果 App 隶属于某个 Space)
|
||||
- 所属 Space
|
||||
- 创建时间
|
||||
- 最后更新时间
|
||||
|
||||
**FR-BROWSE-005**: 查看自定义资源配置
|
||||
**FR-BROWSE-004**: 查看自定义资源配置
|
||||
- 查看 PC 端的 JavaScript 文件配置
|
||||
- 查看 PC 端的 CSS 文件配置
|
||||
- 查看移动端的 JavaScript 文件配置
|
||||
- 查看移动端的 CSS 文件配置
|
||||
- 查看已安装的 Plugin 列表(只读,后续版本支持管理)
|
||||
|
||||
**FR-BROWSE-006**: 查看文件详情
|
||||
**FR-BROWSE-005**: 查看文件详情
|
||||
- 文件名
|
||||
- 文件类型(JS/CSS)
|
||||
- 部署位置(PC/移动端)
|
||||
@@ -334,12 +331,11 @@ NK|
|
||||
#### 3.2.3 Kintone API 端点
|
||||
|
||||
```
|
||||
# 获取 App 列表(支持分页)
|
||||
GET /k/v1/apps.json?limit={limit}&offset={offset}
|
||||
# 限制:limit 最大 100,offset 从 0 开始
|
||||
# 注意:API 不支持排序,需客户端排序
|
||||
# 注意:API 不返回 totalCount,需通过返回数量判断是否还有更多
|
||||
# 注意:spaceId 已包含在响应中(null 表示不属于任何 Space)
|
||||
# 获取 Space 列表
|
||||
GET /k/v1/space.json
|
||||
|
||||
# 获取 App 列表(按 Space)
|
||||
GET /k/v1/apps.json?space={spaceId}
|
||||
|
||||
# 获取 App 配置
|
||||
GET /k/v1/app.json?app={appId}
|
||||
@@ -348,37 +344,6 @@ GET /k/v1/app.json?app={appId}
|
||||
GET /k/v1/file.json?fileKey={fileKey}
|
||||
```
|
||||
|
||||
#### 3.2.4 数据结构
|
||||
|
||||
```typescript
|
||||
// App 列表项
|
||||
interface KintoneApp {
|
||||
appId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
spaceId?: string; // 如果 App 隶属于某个 Space,显示此 ID
|
||||
createdAt: string;
|
||||
creator?: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
modifiedAt?: string;
|
||||
modifier?: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
// App 列表状态(持久化)
|
||||
interface AppListState {
|
||||
apps: KintoneApp[];
|
||||
loadedAt: string; // 加载时间
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 下载已部署代码
|
||||
@@ -924,7 +889,8 @@ autoUpdater.on('update-downloaded', (info) => {
|
||||
|
||||
| 术语 | 说明 |
|
||||
|------|------|
|
||||
| Space | Kintone 空间,应用的容器(注:API 不支持获取所有 Space) |
|
||||
| Domain | Kintone 实例,如 company.kintone.com |
|
||||
| Space | Kintone 空间,应用的容器 |
|
||||
| App | Kintone 应用 |
|
||||
| FileKey | Kintone 文件的唯一标识 |
|
||||
| IPC | Electron 进程间通信 |
|
||||
@@ -935,8 +901,8 @@ autoUpdater.on('update-downloaded', (info) => {
|
||||
|
||||
| 版本 | 日期 | 变更内容 | 作者 |
|
||||
|------|------|----------|------|
|
||||
| 1.0.0 | 2026-03-11 | 初始版本 | - |
|
||||
| 1.1.0 | 2026-03-11 | 更新技术栈:LobeHub UI、CodeMirror 6、safeStorage | - |
|
||||
| 1.2.0 | 2026-03-12 | 移除 Space 功能,改为 App 列表分页显示;支持持久化存储 App 数据 | - |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,28 +4,15 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve('src/shared'),
|
||||
'@main': resolve('src/main')
|
||||
}
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
preload: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve('src/shared'),
|
||||
'@preload': resolve('src/preload')
|
||||
}
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('src/shared')
|
||||
'@renderer': resolve('src/renderer/src')
|
||||
}
|
||||
},
|
||||
plugins: [react()]
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import react from "eslint-plugin-react";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
// Ignore patterns
|
||||
{
|
||||
ignores: ["out/**", "node_modules/**", "dist/**", "*.min.js"],
|
||||
},
|
||||
|
||||
// Main process & Preload (Node.js)
|
||||
{
|
||||
files: ["src/main/**/*.ts", "src/preload/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
globals: {
|
||||
console: "readonly",
|
||||
process: "readonly",
|
||||
require: "readonly",
|
||||
module: "readonly",
|
||||
__dirname: "readonly",
|
||||
Buffer: "readonly",
|
||||
setTimeout: "readonly",
|
||||
setInterval: "readonly",
|
||||
clearTimeout: "readonly",
|
||||
clearInterval: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Renderer process (React)
|
||||
{
|
||||
files: ["src/renderer/**/*.ts", "src/renderer/**/*.tsx"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
console: "readonly",
|
||||
window: "readonly",
|
||||
document: "readonly",
|
||||
localStorage: "readonly",
|
||||
fetch: "readonly",
|
||||
FormData: "readonly",
|
||||
Blob: "readonly",
|
||||
URL: "readonly",
|
||||
requestAnimationFrame: "readonly",
|
||||
cancelAnimationFrame: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react,
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...react.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"no-console": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Shared types (TypeScript only, no React)
|
||||
{
|
||||
files: ["src/shared/**/*.ts"],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
);
|
||||
1173
package-lock.json
generated
1173
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -22,28 +22,18 @@
|
||||
"@codemirror/merge": "^6.9.0",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"@codemirror/view": "^6.36.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@kintone/rest-api-client": "^6.1.2",
|
||||
"@lobehub/ui": "^5.5.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@lobehub/ui": "^5.5.0",
|
||||
"@uiw/react-codemirror": "^4.23.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"antd": "^6.1.0",
|
||||
"antd-style": "^4.1.0",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-updater": "^6.3.0",
|
||||
"i18next": "^25.8.18",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.563.0",
|
||||
"motion": "^12.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^16.5.8",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -51,8 +41,6 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"electron": "^30.5.1",
|
||||
"electron-builder": "^26.0.0",
|
||||
@@ -62,7 +50,6 @@
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"prettier": "^3.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
8
src/main/env.d.ts
vendored
8
src/main/env.d.ts
vendored
@@ -1,10 +1,10 @@
|
||||
/// <reference types="electron-vite/node" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_API_URL: string;
|
||||
readonly MAIN_VITE_DEBUG: string;
|
||||
readonly MAIN_VITE_API_URL: string
|
||||
readonly MAIN_VITE_DEBUG: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Main process error messages
|
||||
* Simple i18n solution for main process (cannot use react-i18next)
|
||||
*/
|
||||
|
||||
import type { LocaleCode } from "@shared/types/locale";
|
||||
import { getLocale } from "./storage";
|
||||
|
||||
/**
|
||||
* Error message keys for main process
|
||||
*/
|
||||
export type MainErrorKey =
|
||||
| "domainNotFound"
|
||||
| "domainDuplicate"
|
||||
| "connectionFailed"
|
||||
| "unknownError"
|
||||
| "rollbackNotImplemented"
|
||||
| "encryptPasswordFailed"
|
||||
| "decryptPasswordFailed"
|
||||
| "deployFailed"
|
||||
| "deployCancelled"
|
||||
| "deployTimeout";
|
||||
|
||||
/**
|
||||
* Error messages by locale
|
||||
*/
|
||||
const errorMessages: Record<LocaleCode, Record<MainErrorKey, string>> = {
|
||||
"zh-CN": {
|
||||
domainNotFound: "域名未找到",
|
||||
domainDuplicate: '域名 "{{domain}}" 与用户 "{{username}}" 已存在。请编辑现有域名。',
|
||||
connectionFailed: "连接失败",
|
||||
unknownError: "未知错误",
|
||||
rollbackNotImplemented: "回滚功能尚未实现",
|
||||
encryptPasswordFailed: "密码加密失败",
|
||||
decryptPasswordFailed: "密码解密失败",
|
||||
deployFailed: "部署失败",
|
||||
deployCancelled: "部署已取消",
|
||||
deployTimeout: "部署超时,请稍后刷新查看结果",
|
||||
},
|
||||
"en-US": {
|
||||
domainNotFound: "Domain not found",
|
||||
domainDuplicate: 'Domain "{{domain}}" with user "{{username}}" already exists. Please edit the existing domain instead.',
|
||||
connectionFailed: "Connection failed",
|
||||
unknownError: "Unknown error",
|
||||
rollbackNotImplemented: "Rollback not yet implemented",
|
||||
encryptPasswordFailed: "Failed to encrypt password",
|
||||
decryptPasswordFailed: "Failed to decrypt password",
|
||||
deployFailed: "Deployment failed",
|
||||
deployCancelled: "Deployment cancelled",
|
||||
deployTimeout: "Deployment timed out, please refresh later",
|
||||
},
|
||||
"ja-JP": {
|
||||
domainNotFound: "ドメインが見つかりません",
|
||||
domainDuplicate: 'ドメイン "{{domain}}" とユーザー "{{username}}" は既に存在します。既存のドメインを編集してください。',
|
||||
connectionFailed: "接続に失敗しました",
|
||||
unknownError: "不明なエラー",
|
||||
rollbackNotImplemented: "ロールバック機能はまだ実装されていません",
|
||||
encryptPasswordFailed: "パスワードの暗号化に失敗しました",
|
||||
decryptPasswordFailed: "パスワードの復号化に失敗しました",
|
||||
deployFailed: "デプロイに失敗しました",
|
||||
deployCancelled: "デプロイがキャンセルされました",
|
||||
deployTimeout: "デプロイがタイムアウトしました。後で更新してください",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Interpolation parameters for error messages
|
||||
*/
|
||||
interface ErrorParams {
|
||||
domain?: string;
|
||||
username?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace placeholders in message with actual values
|
||||
*/
|
||||
function interpolate(message: string, params?: ErrorParams): string {
|
||||
if (!params) return message;
|
||||
|
||||
let result = message;
|
||||
if (params.domain !== undefined) {
|
||||
result = result.replace(/\{\{domain\}\}/g, params.domain);
|
||||
}
|
||||
if (params.username !== undefined) {
|
||||
result = result.replace(/\{\{username\}\}/g, params.username);
|
||||
}
|
||||
if (params.error !== undefined) {
|
||||
result = result.replace(/\{\{error\}\}/g, params.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an error message in the current locale
|
||||
* @param key - Error message key
|
||||
* @param params - Optional interpolation parameters
|
||||
* @param locale - Optional locale override (defaults to stored preference)
|
||||
*/
|
||||
export function getErrorMessage(key: MainErrorKey, params?: ErrorParams, locale?: LocaleCode): string {
|
||||
const targetLocale = locale ?? getLocale();
|
||||
const messages = errorMessages[targetLocale] || errorMessages["zh-CN"];
|
||||
const message = messages[key] || errorMessages["zh-CN"][key] || key;
|
||||
return interpolate(message, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current locale (wrapper for storage.getLocale)
|
||||
*/
|
||||
export function getCurrentLocale(): LocaleCode {
|
||||
return getLocale();
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { app, shell, BrowserWindow } from "electron";
|
||||
import { app, shell, BrowserWindow, ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import { registerIpcHandlers } from "./ipc-handlers";
|
||||
import { initializeStorage, isSecureStorageAvailable, getStorageBackend } from "./storage";
|
||||
import {
|
||||
initializeStorage,
|
||||
isSecureStorageAvailable,
|
||||
getStorageBackend,
|
||||
} from "./storage";
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
@@ -15,6 +19,7 @@ function createWindow(): void {
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 15, y: 10 },
|
||||
...(process.platform === "linux" ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
@@ -27,25 +32,11 @@ function createWindow(): void {
|
||||
mainWindow.show();
|
||||
});
|
||||
|
||||
// 阻止 Alt 键显示默认菜单栏
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (input.key === "Alt" && input.type === "keyDown") {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Electron from navigating to file:// URLs when files are dropped
|
||||
mainWindow.webContents.on("will-navigate", (event, url) => {
|
||||
if (url.startsWith("file://")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
@@ -67,7 +58,9 @@ app.whenReady().then(() => {
|
||||
|
||||
// Check secure storage availability
|
||||
if (!isSecureStorageAvailable()) {
|
||||
console.warn(`Warning: Secure storage not available (backend: ${getStorageBackend()})`);
|
||||
console.warn(
|
||||
`Warning: Secure storage not available (backend: ${getStorageBackend()})`,
|
||||
);
|
||||
console.warn("Passwords will not be securely encrypted on this system.");
|
||||
}
|
||||
|
||||
@@ -81,6 +74,9 @@ app.whenReady().then(() => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// IPC test (keep for debugging)
|
||||
ipcMain.on("ping", () => console.log("pong"));
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on("activate", function () {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,20 @@
|
||||
import { KintoneRestAPIClient } from "@kintone/rest-api-client";
|
||||
import type { KintoneRestAPIError } from "@kintone/rest-api-client";
|
||||
import type { DomainWithPassword } from "@shared/types/domain";
|
||||
import { type AppResponse, type AppDetail, type FileContent, type KintoneApiError, AppCustomizeParameter } from "@shared/types/kintone";
|
||||
import { getErrorMessage } from "./errors";
|
||||
/**
|
||||
* Kintone REST API Client
|
||||
* Handles authentication and API calls to Kintone
|
||||
* Based on REQUIREMENTS.md:331-345, 502-522
|
||||
*/
|
||||
|
||||
import type { DomainWithPassword } from "@renderer/types/domain";
|
||||
import type {
|
||||
KintoneSpace,
|
||||
KintoneApp,
|
||||
AppDetail,
|
||||
FileContent,
|
||||
AppCustomizationConfig,
|
||||
KintoneApiError,
|
||||
} from "@renderer/types/kintone";
|
||||
|
||||
const REQUEST_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Custom error class for Kintone API errors
|
||||
@@ -12,7 +24,11 @@ export class KintoneError extends Error {
|
||||
public readonly id?: string;
|
||||
public readonly statusCode?: number;
|
||||
|
||||
constructor(message: string, apiError?: KintoneApiError, statusCode?: number) {
|
||||
constructor(
|
||||
message: string,
|
||||
apiError?: KintoneApiError,
|
||||
statusCode?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "KintoneError";
|
||||
this.code = apiError?.code;
|
||||
@@ -25,163 +41,468 @@ export class KintoneError extends Error {
|
||||
* Kintone REST API Client
|
||||
*/
|
||||
export class KintoneClient {
|
||||
private client: KintoneRestAPIClient;
|
||||
private baseUrl: string;
|
||||
private headers: Headers;
|
||||
private domain: string;
|
||||
|
||||
constructor(domainConfig: DomainWithPassword) {
|
||||
this.domain = domainConfig.domain;
|
||||
|
||||
this.client = new KintoneRestAPIClient({
|
||||
baseUrl: `https://${domainConfig.domain}`,
|
||||
auth: {
|
||||
username: domainConfig.username,
|
||||
password: domainConfig.password,
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new KintoneError(error.message);
|
||||
}
|
||||
|
||||
return new KintoneError(getErrorMessage("unknownError"));
|
||||
}
|
||||
|
||||
private async withErrorHandling<T>(operation: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
throw this.convertError(error);
|
||||
if (error instanceof KintoneError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError") {
|
||||
throw new KintoneError("Request timeout");
|
||||
}
|
||||
throw new KintoneError(`Network error: ${error.message}`);
|
||||
}
|
||||
throw new KintoneError("Unknown error occurred");
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Space APIs ====================
|
||||
|
||||
/**
|
||||
* Get list of spaces
|
||||
* GET /k/v1/space.json
|
||||
*/
|
||||
async getSpaces(): Promise<KintoneSpace[]> {
|
||||
interface SpacesResponse {
|
||||
spaces: Array<{
|
||||
id: string;
|
||||
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,
|
||||
name: space.name,
|
||||
code: space.code,
|
||||
createdAt: space.createdAt,
|
||||
creator: space.creator,
|
||||
}));
|
||||
}
|
||||
|
||||
// ==================== App APIs ====================
|
||||
|
||||
/**
|
||||
* Get all apps with pagination support
|
||||
* Fetches all apps by making multiple requests if needed
|
||||
* Get list of apps, optionally filtered by space
|
||||
* GET /k/v1/apps.json?space={spaceId}
|
||||
*/
|
||||
async getApps(options?: { limit?: number; offset?: number }): Promise<AppResponse[]> {
|
||||
return this.withErrorHandling(async () => {
|
||||
// If pagination options provided, use them directly
|
||||
if (options?.limit !== undefined || options?.offset !== undefined) {
|
||||
const params: { limit?: number; offset?: number } = {};
|
||||
if (options.limit) params.limit = options.limit;
|
||||
if (options.offset) params.offset = options.offset;
|
||||
const response = await this.client.app.getApps(params);
|
||||
return response.apps;
|
||||
}
|
||||
async getApps(spaceId?: string): Promise<KintoneApp[]> {
|
||||
interface AppsResponse {
|
||||
apps: Array<{
|
||||
appId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
spaceId?: string;
|
||||
createdAt: string;
|
||||
creator?: { code: string; name: string };
|
||||
modifiedAt?: string;
|
||||
modifier?: { code: string; name: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
// Otherwise, fetch all apps (pagination handled internally)
|
||||
const allApps: AppResponse[] = [];
|
||||
const limit = 100; // Max allowed by Kintone API
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
let endpoint = "apps.json";
|
||||
if (spaceId) {
|
||||
endpoint += `?space=${spaceId}`;
|
||||
}
|
||||
|
||||
while (hasMore) {
|
||||
const response = await this.client.app.getApps({ limit, offset });
|
||||
allApps.push(...response.apps);
|
||||
const response = await this.request<AppsResponse>(endpoint);
|
||||
|
||||
// If we got fewer than limit, we've reached the end
|
||||
if (response.apps.length < limit) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
offset += limit;
|
||||
}
|
||||
}
|
||||
|
||||
return allApps;
|
||||
});
|
||||
return response.apps.map((app) => ({
|
||||
appId: app.appId,
|
||||
name: app.name,
|
||||
code: app.code,
|
||||
spaceId: app.spaceId,
|
||||
createdAt: app.createdAt,
|
||||
creator: app.creator,
|
||||
modifiedAt: app.modifiedAt,
|
||||
modifier: app.modifier,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app details including customization config
|
||||
* GET /k/v1/app.json?app={appId}
|
||||
*/
|
||||
async getAppDetail(appId: string): Promise<AppDetail> {
|
||||
return this.withErrorHandling(async () => {
|
||||
const [appInfo, customizeInfo] = await Promise.all([
|
||||
this.client.app.getApp({ id: appId }),
|
||||
this.client.app.getAppCustomize({ app: appId, preview: true }),
|
||||
]);
|
||||
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 {
|
||||
...appInfo,
|
||||
customization: customizeInfo,
|
||||
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,
|
||||
})) || [],
|
||||
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,
|
||||
})) || [],
|
||||
},
|
||||
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,
|
||||
})) || [],
|
||||
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,
|
||||
})) || [],
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
return {
|
||||
appId: appInfo.appId,
|
||||
name: appInfo.name,
|
||||
code: appInfo.code,
|
||||
description: appInfo.description,
|
||||
spaceId: appInfo.spaceId,
|
||||
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> {
|
||||
return this.withErrorHandling(async () => {
|
||||
const data = await this.client.file.downloadFile({ fileKey });
|
||||
const buffer = Buffer.from(data);
|
||||
const content = buffer.toString("base64");
|
||||
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 {
|
||||
fileKey,
|
||||
name: fileKey,
|
||||
size: buffer.byteLength,
|
||||
mimeType: "application/octet-stream",
|
||||
name: fileKey, // Kintone doesn't return filename in file API
|
||||
size: arrayBuffer.byteLength,
|
||||
mimeType: contentType,
|
||||
content,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(content: string | Buffer, fileName: string, _mimeType?: string): Promise<{ fileKey: string }> {
|
||||
return this.withErrorHandling(async () => {
|
||||
const response = await this.client.file.uploadFile({
|
||||
file: { name: fileName, data: content },
|
||||
/**
|
||||
* Upload a file to Kintone
|
||||
* POST /k/v1/file.json (multipart/form-data)
|
||||
*/
|
||||
async uploadFile(
|
||||
content: string | Buffer,
|
||||
fileName: string,
|
||||
mimeType: string = "application/javascript",
|
||||
): 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 { 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 ====================
|
||||
|
||||
async updateAppCustomize(appId: string, config: Omit<AppCustomizeParameter, "app">): Promise<void> {
|
||||
return this.withErrorHandling(async () => {
|
||||
await this.client.app.updateAppCustomize({ ...config, app: appId });
|
||||
/**
|
||||
* 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 = {
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy app changes
|
||||
* POST /k/v1/preview/app/deploy.json
|
||||
*/
|
||||
async deployApp(appId: string): Promise<void> {
|
||||
return this.withErrorHandling(async () => {
|
||||
await this.client.app.deployApp({ apps: [{ app: appId }] });
|
||||
await this.request("preview/app/deploy.json", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apps: [{ app: appId }] }),
|
||||
});
|
||||
}
|
||||
|
||||
async getDeployStatus(appId: string): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
|
||||
return this.withErrorHandling(async () => {
|
||||
const response = await this.client.app.getDeployStatus({ apps: [appId] });
|
||||
return response.apps[0]?.status || "FAIL";
|
||||
});
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
// ==================== Utility Methods ====================
|
||||
|
||||
/**
|
||||
* Test connection to Kintone
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Use limit=1 to minimize data transfer for faster connection testing
|
||||
await this.client.app.getApps({ limit: 1 });
|
||||
await this.getApps();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof KintoneError ? error.message : getErrorMessage("connectionFailed"),
|
||||
error:
|
||||
error instanceof KintoneError ? error.message : "Connection failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the domain name
|
||||
*/
|
||||
getDomain(): string {
|
||||
return this.domain;
|
||||
}
|
||||
}
|
||||
|
||||
// Export factory function for convenience
|
||||
export function createKintoneClient(domain: DomainWithPassword): KintoneClient {
|
||||
return new KintoneClient(domain);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
import { app, safeStorage } from "electron";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import type { Domain, DomainWithPassword } from "@shared/types/domain";
|
||||
import type { Version, DownloadMetadata, BackupMetadata } from "@shared/types/version";
|
||||
import type { LocaleCode } from "@shared/types/locale";
|
||||
import { DEFAULT_LOCALE } from "@shared/types/locale";
|
||||
import type { Domain, DomainWithPassword } from "@renderer/types/domain";
|
||||
import type {
|
||||
Version,
|
||||
DownloadMetadata,
|
||||
BackupMetadata,
|
||||
} from "@renderer/types/version";
|
||||
|
||||
// ==================== Path Helpers ====================
|
||||
|
||||
@@ -41,28 +43,6 @@ function ensureDir(dirPath: string): void {
|
||||
|
||||
interface AppConfig {
|
||||
domains: Domain[];
|
||||
locale?: LocaleCode;
|
||||
}
|
||||
|
||||
// ==================== Locale Management ====================
|
||||
|
||||
/**
|
||||
* Get the stored locale preference
|
||||
*/
|
||||
export function getLocale(): LocaleCode {
|
||||
const configPath = getConfigPath();
|
||||
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
||||
return config.locale ?? DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale preference
|
||||
*/
|
||||
export function setLocale(locale: LocaleCode): void {
|
||||
const configPath = getConfigPath();
|
||||
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
||||
config.locale = locale;
|
||||
writeJsonFile(configPath, config);
|
||||
}
|
||||
|
||||
interface SecureStore {
|
||||
@@ -103,14 +83,14 @@ function writeJsonFile<T>(filePath: string, data: T): void {
|
||||
export function isSecureStorageAvailable(): boolean {
|
||||
try {
|
||||
// Check if the method exists (added in Electron 30+)
|
||||
if (typeof safeStorage.getSelectedStorageBackend === "function") {
|
||||
const backend = safeStorage.getSelectedStorageBackend();
|
||||
return backend !== "basic_text";
|
||||
if (typeof safeStorage.getSelectedStorageBackend === 'function') {
|
||||
const backend = safeStorage.getSelectedStorageBackend()
|
||||
return backend !== 'basic_text'
|
||||
}
|
||||
// Fallback: check if encryption is available
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +99,12 @@ export function isSecureStorageAvailable(): boolean {
|
||||
*/
|
||||
export function getStorageBackend(): string {
|
||||
try {
|
||||
if (typeof safeStorage.getSelectedStorageBackend === "function") {
|
||||
return safeStorage.getSelectedStorageBackend();
|
||||
if (typeof safeStorage.getSelectedStorageBackend === 'function') {
|
||||
return safeStorage.getSelectedStorageBackend()
|
||||
}
|
||||
return "unknown";
|
||||
return 'unknown'
|
||||
} catch {
|
||||
return "unknown";
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +115,9 @@ export function encryptPassword(password: string): Buffer {
|
||||
try {
|
||||
return safeStorage.encryptString(password);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to encrypt password: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
throw new Error(
|
||||
`Failed to encrypt password: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +128,9 @@ export function decryptPassword(encrypted: Buffer): string {
|
||||
try {
|
||||
return safeStorage.decryptString(encrypted);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to decrypt password: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
throw new Error(
|
||||
`Failed to decrypt password: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +139,10 @@ export function decryptPassword(encrypted: Buffer): string {
|
||||
/**
|
||||
* Save a domain with encrypted password
|
||||
*/
|
||||
export async function saveDomain(domain: Domain, password: string): Promise<void> {
|
||||
export async function saveDomain(
|
||||
domain: Domain,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const configPath = getConfigPath();
|
||||
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
||||
const existingIndex = config.domains.findIndex((d) => d.id === domain.id);
|
||||
@@ -179,7 +166,9 @@ export async function saveDomain(domain: Domain, password: string): Promise<void
|
||||
/**
|
||||
* Get a domain by ID with decrypted password
|
||||
*/
|
||||
export async function getDomain(id: string): Promise<DomainWithPassword | null> {
|
||||
export async function getDomain(
|
||||
id: string,
|
||||
): Promise<DomainWithPassword | null> {
|
||||
const configPath = getConfigPath();
|
||||
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
||||
const domain = config.domains.find((d) => d.id === id);
|
||||
@@ -240,7 +229,13 @@ export async function deleteDomain(id: string): Promise<void> {
|
||||
* Save a version to local storage
|
||||
*/
|
||||
export async function saveVersion(version: Version): Promise<void> {
|
||||
const versionDir = getStoragePath("versions", version.domainId, version.appId, version.fileType, version.id);
|
||||
const versionDir = getStoragePath(
|
||||
"versions",
|
||||
version.domainId,
|
||||
version.appId,
|
||||
version.fileType,
|
||||
version.id,
|
||||
);
|
||||
|
||||
ensureDir(versionDir);
|
||||
|
||||
@@ -252,7 +247,10 @@ export async function saveVersion(version: Version): Promise<void> {
|
||||
/**
|
||||
* List versions for a specific app
|
||||
*/
|
||||
export async function listVersions(domainId: string, appId: string): Promise<Version[]> {
|
||||
export async function listVersions(
|
||||
domainId: string,
|
||||
appId: string,
|
||||
): Promise<Version[]> {
|
||||
const versions: Version[] = [];
|
||||
const baseDir = getStoragePath("versions", domainId, appId);
|
||||
|
||||
@@ -282,7 +280,9 @@ export async function listVersions(domainId: string, appId: string): Promise<Ver
|
||||
}
|
||||
|
||||
// Sort by createdAt descending
|
||||
return versions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return versions.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,9 +325,17 @@ export async function deleteVersion(id: string): Promise<void> {
|
||||
/**
|
||||
* Save downloaded files with metadata
|
||||
*/
|
||||
export async function saveDownload(metadata: DownloadMetadata, files: Map<string, Buffer>): Promise<string> {
|
||||
export async function saveDownload(
|
||||
metadata: DownloadMetadata,
|
||||
files: Map<string, Buffer>,
|
||||
): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const downloadDir = getStoragePath("downloads", metadata.domainId, metadata.appId, timestamp);
|
||||
const downloadDir = getStoragePath(
|
||||
"downloads",
|
||||
metadata.domainId,
|
||||
metadata.appId,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
ensureDir(downloadDir);
|
||||
|
||||
@@ -360,9 +368,18 @@ export function getDownloadPath(domainId: string, appId?: string): string {
|
||||
/**
|
||||
* Save backup files with metadata
|
||||
*/
|
||||
export async function saveBackup(metadata: BackupMetadata, files: Map<string, Buffer>): Promise<string> {
|
||||
export async function saveBackup(
|
||||
metadata: BackupMetadata,
|
||||
files: Map<string, Buffer>,
|
||||
): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupDir = getStoragePath("versions", metadata.domainId, metadata.appId, "backup", timestamp);
|
||||
const backupDir = getStoragePath(
|
||||
"versions",
|
||||
metadata.domainId,
|
||||
metadata.appId,
|
||||
"backup",
|
||||
timestamp,
|
||||
);
|
||||
|
||||
ensureDir(backupDir);
|
||||
|
||||
@@ -380,39 +397,6 @@ export async function saveBackup(metadata: BackupMetadata, files: Map<string, Bu
|
||||
return backupDir;
|
||||
}
|
||||
|
||||
// ==================== Customization File Storage ====================
|
||||
|
||||
/**
|
||||
* Save a customization file from a local source path to the managed storage area.
|
||||
* Destination: {userData}/.kintone-manager/files/{domainId}/{appId}/{platform}_{fileType}/{fileId}_{originalName}
|
||||
*/
|
||||
export async function saveCustomizationFile(params: {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
platform: "desktop" | "mobile";
|
||||
fileType: "js" | "css";
|
||||
fileId: string;
|
||||
sourcePath: string;
|
||||
}): Promise<{ storagePath: string; fileName: string; size: number }> {
|
||||
const { domainId, appId, platform, fileType, fileId, sourcePath } = params;
|
||||
const fileName = path.basename(sourcePath);
|
||||
const dir = getStoragePath("files", domainId, appId, `${platform}_${fileType}`);
|
||||
ensureDir(dir);
|
||||
const storagePath = path.join(dir, `${fileId}_${fileName}`);
|
||||
fs.copyFileSync(sourcePath, storagePath);
|
||||
const stat = fs.statSync(storagePath);
|
||||
return { storagePath, fileName, size: stat.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a customization file from storage.
|
||||
*/
|
||||
export async function deleteCustomizationFile(storagePath: string): Promise<void> {
|
||||
if (fs.existsSync(storagePath)) {
|
||||
fs.unlinkSync(storagePath);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Storage Info ====================
|
||||
|
||||
/**
|
||||
@@ -441,5 +425,4 @@ export function initializeStorage(): void {
|
||||
ensureDir(getStorageBase());
|
||||
ensureDir(getStoragePath("downloads"));
|
||||
ensureDir(getStoragePath("versions"));
|
||||
ensureDir(getStoragePath("files"));
|
||||
}
|
||||
|
||||
66
src/preload/index.d.ts
vendored
66
src/preload/index.d.ts
vendored
@@ -4,39 +4,34 @@ import type {
|
||||
CreateDomainParams,
|
||||
UpdateDomainParams,
|
||||
TestDomainConnectionParams,
|
||||
GetSpacesParams,
|
||||
GetAppsParams,
|
||||
GetAppDetailParams,
|
||||
GetFileContentParams,
|
||||
GetLocalFileContentParams,
|
||||
LocalFileContent,
|
||||
DeployParams,
|
||||
DeployResult,
|
||||
DownloadParams,
|
||||
DownloadResult,
|
||||
DownloadAllZipParams,
|
||||
DownloadAllZipResult,
|
||||
GetVersionsParams,
|
||||
RollbackParams,
|
||||
SetLocaleParams,
|
||||
ShowSaveDialogParams,
|
||||
SaveFileContentParams,
|
||||
CheckUpdateResult,
|
||||
FileSaveParams,
|
||||
FileSaveResult,
|
||||
FileDeleteParams,
|
||||
} from "@shared/types/ipc";
|
||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
||||
import type { AppResponse, AppDetail, FileContent, KintoneSpace } from "@shared/types/kintone";
|
||||
import type { Version } from "@shared/types/version";
|
||||
import type { LocaleCode } from "@shared/types/locale";
|
||||
} from "@renderer/types/ipc";
|
||||
import type { Domain, DomainWithStatus } from "@renderer/types/domain";
|
||||
import type {
|
||||
KintoneSpace,
|
||||
KintoneApp,
|
||||
AppDetail,
|
||||
FileContent,
|
||||
} from "@renderer/types/kintone";
|
||||
import type { Version } from "@renderer/types/version";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
api: SelfAPI;
|
||||
api: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SelfAPI {
|
||||
export interface ElectronAPI {
|
||||
// Platform detection
|
||||
platform: NodeJS.Platform;
|
||||
|
||||
@@ -49,44 +44,21 @@ export interface SelfAPI {
|
||||
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
|
||||
|
||||
// ==================== Browse ====================
|
||||
getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>;
|
||||
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>;
|
||||
getApps: (params: GetAppsParams) => Promise<Result<KintoneApp[]>>;
|
||||
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
|
||||
getFileContent: (params: GetFileContentParams) => Promise<Result<FileContent>>;
|
||||
getLocalFileContent: (params: GetLocalFileContentParams) => Promise<Result<LocalFileContent>>;
|
||||
getFileContent: (
|
||||
params: GetFileContentParams,
|
||||
) => Promise<Result<FileContent>>;
|
||||
|
||||
// ==================== Deploy ====================
|
||||
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
|
||||
|
||||
// ==================== File Storage ====================
|
||||
saveFile: (params: FileSaveParams) => Promise<Result<FileSaveResult>>;
|
||||
deleteFile: (params: FileDeleteParams) => Promise<Result<void>>;
|
||||
deploy: (params: DeployParams) => Promise<DeployResult>;
|
||||
|
||||
// ==================== Download ====================
|
||||
download: (params: DownloadParams) => Promise<DownloadResult>;
|
||||
downloadAllZip: (params: DownloadAllZipParams) => Promise<Result<DownloadAllZipResult>>;
|
||||
|
||||
// ==================== Version Management ====================
|
||||
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;
|
||||
deleteVersion: (id: string) => Promise<Result<void>>;
|
||||
rollback: (params: RollbackParams) => Promise<DeployResult>;
|
||||
|
||||
// ==================== Locale ====================
|
||||
getLocale: () => Promise<Result<LocaleCode>>;
|
||||
setLocale: (params: SetLocaleParams) => Promise<Result<void>>;
|
||||
|
||||
// ==================== App Version & Update ====================
|
||||
getAppVersion: () => Promise<Result<string>>;
|
||||
checkForUpdates: () => Promise<Result<CheckUpdateResult>>;
|
||||
|
||||
// ==================== Dialog ====================
|
||||
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
||||
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
|
||||
|
||||
// ==================== File Path Utility ====================
|
||||
/**
|
||||
* Get the file system path for a File object.
|
||||
* Use this for drag-and-drop file uploads.
|
||||
* @see https://electronjs.org/docs/latest/api/web-utils
|
||||
*/
|
||||
getPathForFile: (file: File) => string;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { SelfAPI } from "./index.d";
|
||||
import type { ElectronAPI } from "./index.d";
|
||||
|
||||
// Plain object API - contextBridge cannot serialize Proxy objects
|
||||
const api: SelfAPI = {
|
||||
// Custom APIs for renderer - bridges to IPC handlers
|
||||
const api: ElectronAPI = {
|
||||
// Platform detection
|
||||
platform: process.platform,
|
||||
|
||||
// Domain management
|
||||
// ==================== Domain Management ====================
|
||||
getDomains: () => ipcRenderer.invoke("getDomains"),
|
||||
createDomain: (params) => ipcRenderer.invoke("createDomain", params),
|
||||
updateDomain: (params) => ipcRenderer.invoke("updateDomain", params),
|
||||
@@ -14,42 +15,22 @@ const api: SelfAPI = {
|
||||
testConnection: (id) => ipcRenderer.invoke("testConnection", id),
|
||||
testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params),
|
||||
|
||||
// Browse
|
||||
// ==================== Browse ====================
|
||||
getSpaces: (params) => ipcRenderer.invoke("getSpaces", params),
|
||||
getApps: (params) => ipcRenderer.invoke("getApps", params),
|
||||
getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params),
|
||||
getFileContent: (params) => ipcRenderer.invoke("getFileContent", params),
|
||||
getLocalFileContent: (params) => ipcRenderer.invoke("getLocalFileContent", params),
|
||||
|
||||
// Deploy
|
||||
// ==================== Deploy ====================
|
||||
deploy: (params) => ipcRenderer.invoke("deploy", params),
|
||||
|
||||
// File storage
|
||||
saveFile: (params) => ipcRenderer.invoke("file:save", params),
|
||||
deleteFile: (params) => ipcRenderer.invoke("file:delete", params),
|
||||
|
||||
// Download
|
||||
// ==================== Download ====================
|
||||
download: (params) => ipcRenderer.invoke("download", params),
|
||||
downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params),
|
||||
|
||||
// Version management
|
||||
// ==================== Version Management ====================
|
||||
getVersions: (params) => ipcRenderer.invoke("getVersions", params),
|
||||
deleteVersion: (id) => ipcRenderer.invoke("deleteVersion", id),
|
||||
rollback: (params) => ipcRenderer.invoke("rollback", params),
|
||||
|
||||
// Locale
|
||||
getLocale: () => ipcRenderer.invoke("getLocale"),
|
||||
setLocale: (params) => ipcRenderer.invoke("setLocale", params),
|
||||
|
||||
// App Version & Update
|
||||
getAppVersion: () => ipcRenderer.invoke("getAppVersion"),
|
||||
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
|
||||
|
||||
// Dialog
|
||||
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params),
|
||||
saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params),
|
||||
|
||||
// File path utility (for drag-and-drop)
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
};
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
@@ -60,11 +41,11 @@ if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
contextBridge.exposeInMainWorld("api", api);
|
||||
} catch (error) {
|
||||
console.error("[Preload] Failed to expose API:", error);
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore - window is available in non-isolated context
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI;
|
||||
// @ts-ignore
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api;
|
||||
}
|
||||
|
||||
@@ -4,29 +4,35 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout, Typography, Space, Modal } from "antd";
|
||||
|
||||
import { Button, Tooltip } from "@lobehub/ui";
|
||||
|
||||
import { Cloud, History, PanelLeftClose, PanelLeftOpen, Settings as SettingsIcon } from "lucide-react";
|
||||
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import {
|
||||
Layout,
|
||||
Typography,
|
||||
theme,
|
||||
ConfigProvider,
|
||||
App as AntApp,
|
||||
Tabs,
|
||||
Button,
|
||||
Space,
|
||||
Dropdown,
|
||||
} from "antd";
|
||||
import {
|
||||
SettingOutlined,
|
||||
GithubOutlined,
|
||||
CloudServerOutlined,
|
||||
AppstoreOutlined,
|
||||
CloudUploadOutlined,
|
||||
HistoryOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import { useUIStore } from "@renderer/stores";
|
||||
import { DomainManager } from "@renderer/components/DomainManager";
|
||||
import { AppList } from "@renderer/components/AppList";
|
||||
import { SpaceTree } from "@renderer/components/SpaceTree";
|
||||
import { AppDetail } from "@renderer/components/AppDetail";
|
||||
import { Settings } from "@renderer/components/Settings";
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Title } = Typography;
|
||||
import { DeployDialog } from "@renderer/components/DeployDialog";
|
||||
|
||||
// Domain section heights
|
||||
const DOMAIN_SECTION_COLLAPSED = 68; // 增加高度,避免按钮覆盖文字
|
||||
const DOMAIN_SECTION_EXPANDED = 260;
|
||||
const DEFAULT_SIDER_WIDTH = 320;
|
||||
const MIN_SIDER_WIDTH = 280;
|
||||
const MAX_SIDER_WIDTH = 500;
|
||||
const { Header, Content, Sider } = Layout;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
layout: css`
|
||||
@@ -42,18 +48,14 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
background: ${token.colorBgContainer};
|
||||
border-right: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
siderCollapsed: css`
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden;
|
||||
`,
|
||||
logo: css`
|
||||
height: 32px;
|
||||
margin: ${token.paddingXS}px ${token.padding}px ${token.paddingXXS}px;
|
||||
height: 48px;
|
||||
margin: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
logoText: css`
|
||||
color: ${token.colorText};
|
||||
@@ -61,16 +63,12 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
font-weight: 600;
|
||||
`,
|
||||
siderContent: css`
|
||||
height: calc(100vh - 44px);
|
||||
height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
mainLayout: css`
|
||||
margin-left: ${DEFAULT_SIDER_WIDTH}px;
|
||||
transition: margin-left 0.2s;
|
||||
`,
|
||||
mainLayoutCollapsed: css`
|
||||
margin-left: 0;
|
||||
margin-left: 280px;
|
||||
transition: margin-left 0.2s;
|
||||
`,
|
||||
header: css`
|
||||
@@ -92,179 +90,116 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
display: flex;
|
||||
height: 100%;
|
||||
`,
|
||||
leftPanel: css`
|
||||
width: 300px;
|
||||
border-right: 1px solid ${token.colorBorderSecondary};
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`,
|
||||
rightPanel: css`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
`,
|
||||
domainSection: css`
|
||||
height: 200px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
padding-bottom: ${token.paddingXS}px;
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease-in-out;
|
||||
`,
|
||||
appSection: css`
|
||||
spaceSection: css`
|
||||
height: calc(100% - 200px);
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease-in-out;
|
||||
`,
|
||||
resizeHandle: css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
transition: background 0.2s;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorPrimary};
|
||||
}
|
||||
`,
|
||||
siderCloseButton: css`
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
headerLeft: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { styles } = useStyles();
|
||||
const token = useTheme();
|
||||
|
||||
// Prevent Electron from navigating to file:// URLs when files are dropped
|
||||
// outside of designated drop zones
|
||||
React.useEffect(() => {
|
||||
const preventNavigation = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener("dragover", preventNavigation);
|
||||
document.addEventListener("drop", preventNavigation);
|
||||
return () => {
|
||||
document.removeEventListener("dragover", preventNavigation);
|
||||
document.removeEventListener("drop", preventNavigation);
|
||||
};
|
||||
}, []);
|
||||
const {
|
||||
token: { colorBgContainer },
|
||||
} = theme.useToken();
|
||||
|
||||
const { currentDomain } = useDomainStore();
|
||||
const { sidebarWidth, siderCollapsed, domainExpanded, setSidebarWidth, setSiderCollapsed, setDomainExpanded } = useUIStore();
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
|
||||
const domainSectionHeight = domainExpanded ? DOMAIN_SECTION_EXPANDED : DOMAIN_SECTION_COLLAPSED;
|
||||
|
||||
// Handle resize start
|
||||
const handleResizeStart = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startX;
|
||||
const newWidth = Math.min(MAX_SIDER_WIDTH, Math.max(MIN_SIDER_WIDTH, startWidth + delta));
|
||||
setSidebarWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[sidebarWidth, setSidebarWidth]
|
||||
);
|
||||
|
||||
const toggleSider = () => {
|
||||
setSiderCollapsed(!siderCollapsed);
|
||||
};
|
||||
const [deployDialogOpen, setDeployDialogOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Layout className={styles.layout}>
|
||||
{/* Left Sider - Domain List & App List */}
|
||||
<Sider
|
||||
width={sidebarWidth}
|
||||
className={`${styles.sider} ${siderCollapsed ? styles.siderCollapsed : ""}`}
|
||||
style={{ width: siderCollapsed ? 0 : sidebarWidth }}
|
||||
>
|
||||
{!siderCollapsed && (
|
||||
<>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AntApp>
|
||||
<Layout className={styles.layout}>
|
||||
{/* Left Sider - Domain List & Space Tree */}
|
||||
<Sider width={280} className={styles.sider}>
|
||||
<div className={styles.logo}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Cloud size={24} style={{ color: token.colorPrimary }} />
|
||||
<span className={styles.logoText}>Kintone JS/CSS Manager</span>
|
||||
</div>
|
||||
<Tooltip title={t("collapseSidebar")} mouseEnterDelay={0.5}>
|
||||
<Button type="text" icon={<PanelLeftClose size={16} />} onClick={toggleSider} className={styles.siderCloseButton} size="small" />
|
||||
</Tooltip>
|
||||
<CloudServerOutlined style={{ fontSize: 24, color: "#1890ff" }} />
|
||||
<span className={styles.logoText}>Kintone Manager</span>
|
||||
</div>
|
||||
<div className={styles.siderContent}>
|
||||
<div className={styles.domainSection} style={{ height: domainSectionHeight }}>
|
||||
<DomainManager collapsed={!domainExpanded} onToggleCollapse={() => setDomainExpanded(!domainExpanded)} />
|
||||
<div className={styles.domainSection}>
|
||||
<DomainManager />
|
||||
</div>
|
||||
<div className={styles.appSection} style={{ height: `calc(100% - ${domainSectionHeight}px)` }}>
|
||||
<AppList />
|
||||
<div className={styles.spaceSection}>
|
||||
<SpaceTree />
|
||||
</div>
|
||||
</div>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
className={styles.resizeHandle}
|
||||
onMouseDown={handleResizeStart}
|
||||
style={{
|
||||
background: isResizing ? token.colorPrimary : undefined,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Sider>
|
||||
</Sider>
|
||||
|
||||
{/* Main Content */}
|
||||
<Layout className={`${styles.mainLayout} ${siderCollapsed ? styles.mainLayoutCollapsed : ""}`} style={{ marginLeft: siderCollapsed ? 0 : sidebarWidth }}>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
{siderCollapsed && (
|
||||
<Tooltip title={t("expandSidebar")} mouseEnterDelay={0.5}>
|
||||
<Button type="text" icon={<PanelLeftOpen size={16} />} onClick={toggleSider} size="small" />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{currentDomain ? currentDomain.name : "Kintone Customize Manager"}
|
||||
</Title>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<History size={16} />} disabled={!currentDomain}>
|
||||
{t("versionHistory")}
|
||||
</Button>
|
||||
<Tooltip title={t("settings")}>
|
||||
<Button icon={<SettingsIcon size={16} />} onClick={() => setSettingsOpen(true)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Header>
|
||||
{/* Main Content */}
|
||||
<Layout className={styles.mainLayout}>
|
||||
<Header className={styles.header}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{currentDomain
|
||||
? currentDomain.name
|
||||
: "Kintone Customize Manager"}
|
||||
</Title>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
onClick={() => setDeployDialogOpen(true)}
|
||||
disabled={!currentDomain}
|
||||
>
|
||||
部署文件
|
||||
</Button>
|
||||
<Button icon={<HistoryOutlined />} disabled={!currentDomain}>
|
||||
版本历史
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: "settings",
|
||||
icon: <SettingOutlined />,
|
||||
label: "设置",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "github",
|
||||
icon: <GithubOutlined />,
|
||||
label: "GitHub",
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button icon={<SettingOutlined />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<Content className={styles.content}>
|
||||
<div className={styles.mainContent}>
|
||||
<div className={styles.rightPanel}>
|
||||
<AppDetail />
|
||||
</div>
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
<Content className={styles.content}>
|
||||
<div className={styles.mainContent}>
|
||||
<div className={styles.rightPanel}>
|
||||
<AppDetail />
|
||||
</div>
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<Modal title={t("settings")} open={settingsOpen} onCancel={() => setSettingsOpen(false)} footer={null} width={480} mask={{ closable: false }}>
|
||||
<Settings />
|
||||
</Modal>
|
||||
</Layout>
|
||||
{/* Deploy Dialog */}
|
||||
<DeployDialog
|
||||
open={deployDialogOpen}
|
||||
onClose={() => setDeployDialogOpen(false)}
|
||||
/>
|
||||
</Layout>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
/**
|
||||
* AppDetail Component
|
||||
* Displays app configuration details with file management and deploy functionality.
|
||||
* Displays app configuration details
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spin, Tag, Space, App as AntApp, Tooltip, Badge } from "antd";
|
||||
import { Button, Empty } from "@lobehub/ui";
|
||||
import { LayoutGrid, Download, History, Rocket, Monitor, Smartphone, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import { useAppStore, useDomainStore, useSessionStore, useFileChangeStore } from "@renderer/stores";
|
||||
import React from "react";
|
||||
import {
|
||||
Card,
|
||||
Descriptions,
|
||||
Tabs,
|
||||
Empty,
|
||||
Spin,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
} from "antd";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
DownloadOutlined,
|
||||
HistoryOutlined,
|
||||
CodeOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import { useAppStore } from "@renderer/stores";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import { CodeViewer } from "../CodeViewer";
|
||||
import { transformCustomizeToFiles } from "@shared/utils/fileTransform";
|
||||
import type { DeployFileEntry } from "@shared/types/ipc";
|
||||
import FileSection from "./FileSection";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
@@ -23,9 +34,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: ${token.paddingMD}px ${token.paddingLG}px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
@@ -33,6 +41,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingSM}px;
|
||||
margin-bottom: ${token.marginSM}px;
|
||||
`,
|
||||
appName: css`
|
||||
font-size: ${token.fontSizeHeading5}px;
|
||||
@@ -43,7 +52,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: ${token.paddingMD}px;
|
||||
padding-top: ${token.paddingXS}px;
|
||||
`,
|
||||
loading: css`
|
||||
display: flex;
|
||||
@@ -51,314 +59,88 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
`,
|
||||
fileItem: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
margin-bottom: ${token.marginSM}px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: ${token.colorPrimary};
|
||||
background: ${token.colorPrimaryBg};
|
||||
}
|
||||
`,
|
||||
fileInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingSM}px;
|
||||
`,
|
||||
fileName: css`
|
||||
font-weight: 500;
|
||||
`,
|
||||
fileType: css`
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
color: ${token.colorTextSecondary};
|
||||
`,
|
||||
emptySection: css`
|
||||
height: 100%;
|
||||
padding: ${token.paddingLG}px;
|
||||
text-align: center;
|
||||
color: ${token.colorTextSecondary};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`,
|
||||
backButton: css`
|
||||
padding: ${token.marginSM}px 0;
|
||||
padding-left: ${token.marginXS}px;
|
||||
margin-bottom: ${token.marginXS}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
`,
|
||||
codeView: css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
}));
|
||||
|
||||
const AppDetail: React.FC = () => {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const { styles } = useStyles();
|
||||
const token = useTheme();
|
||||
const { currentDomain } = useDomainStore();
|
||||
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } = useAppStore();
|
||||
const { viewMode, selectedFile, setViewMode, setSelectedFile } = useSessionStore();
|
||||
const fileChangeStore = useFileChangeStore();
|
||||
const { message } = AntApp.useApp();
|
||||
|
||||
const [downloadingKey, setDownloadingKey] = React.useState<string | null>(null);
|
||||
const [downloadingAll, setDownloadingAll] = React.useState(false);
|
||||
const [deploying, setDeploying] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [isAnySectionOverLimit, setIsAnySectionOverLimit] = useState(false);
|
||||
|
||||
// Track over-limit status from each FileSection using refs
|
||||
const overLimitSectionsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const handleOverLimitChange = useCallback(
|
||||
(sectionId: string) => (isOverLimit: boolean) => {
|
||||
if (isOverLimit) {
|
||||
overLimitSectionsRef.current.add(sectionId);
|
||||
} else {
|
||||
overLimitSectionsRef.current.delete(sectionId);
|
||||
}
|
||||
setIsAnySectionOverLimit(overLimitSectionsRef.current.size > 0);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset view mode when app changes
|
||||
useEffect(() => {
|
||||
setViewMode("list");
|
||||
setSelectedFile(null);
|
||||
}, [selectedAppId]);
|
||||
|
||||
const loadAppDetail = useCallback(
|
||||
async (onSuccessCallback?: () => Promise<void> | void) => {
|
||||
if (!currentDomain || !selectedAppId) return undefined;
|
||||
|
||||
// Capture at request time to detect staleness after awaits
|
||||
const capturedDomainId = currentDomain.id;
|
||||
const capturedAppId = selectedAppId;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await window.api.getAppDetail({
|
||||
domainId: capturedDomainId,
|
||||
appId: capturedAppId,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Guard: discard stale responses from previous domain/app switches
|
||||
const nowDomainId = useDomainStore.getState().currentDomain?.id;
|
||||
const nowAppId = useAppStore.getState().selectedAppId;
|
||||
if (nowDomainId !== capturedDomainId || nowAppId !== capturedAppId) return undefined;
|
||||
|
||||
if (onSuccessCallback) {
|
||||
await onSuccessCallback();
|
||||
}
|
||||
// Store revision after callback to avoid being cleared by clearChanges
|
||||
const revision = result.data.customization?.revision;
|
||||
setCurrentApp(result.data);
|
||||
// Must be AFTER callback since clearChanges() resets serverRevision to null
|
||||
// Only update knownRevision when user explicitly refreshes or after deployment
|
||||
if (revision && shouldUpdateKnownRevision) {
|
||||
fileChangeStore.setServerRevision(capturedDomainId, capturedAppId, revision);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error("Failed to load app detail:", error);
|
||||
return undefined;
|
||||
} finally {
|
||||
// Only reset loading if still on the same context; otherwise the new
|
||||
// request's loading state should not be cleared by this stale response.
|
||||
const nowDomainId = useDomainStore.getState().currentDomain?.id;
|
||||
const nowAppId = useAppStore.getState().selectedAppId;
|
||||
if (nowDomainId === capturedDomainId && nowAppId === capturedAppId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentDomain, selectedAppId, setCurrentApp, setLoading]
|
||||
);
|
||||
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
|
||||
useAppStore();
|
||||
|
||||
// Load app detail when selected
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (currentDomain && selectedAppId) {
|
||||
loadAppDetail();
|
||||
}
|
||||
}, [currentDomain, selectedAppId, loadAppDetail]);
|
||||
}, [currentDomain, selectedAppId]);
|
||||
|
||||
// Initialize file change store from Kintone data
|
||||
useEffect(() => {
|
||||
if (!currentApp || !currentDomain || !selectedAppId) return;
|
||||
// Guard against race condition: currentApp may be a stale response from a
|
||||
// previous app's request that resolved after selectedAppId already changed.
|
||||
if (String(currentApp.appId) !== String(selectedAppId)) return;
|
||||
const loadAppDetail = async () => {
|
||||
if (!currentDomain || !selectedAppId) return;
|
||||
|
||||
const customize = currentApp.customization;
|
||||
if (!customize) return;
|
||||
|
||||
const files = transformCustomizeToFiles(customize);
|
||||
fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files);
|
||||
}, [currentApp]);
|
||||
|
||||
const handleFileClick = useCallback((fileKey: string | undefined, name: string, storagePath?: string) => {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
const type = ext === "css" ? "css" : "js";
|
||||
setSelectedFile({ type, fileKey, name, storagePath });
|
||||
setViewMode("code");
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!currentDomain || !selectedAppId || refreshing) return;
|
||||
|
||||
setRefreshing(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
// Clear changes before reloading from Kintone
|
||||
fileChangeStore.clearChanges(currentDomain.id, selectedAppId);
|
||||
await loadAppDetail(() => fileChangeStore.clearChanges(currentDomain.id, selectedAppId));
|
||||
message.success(t("refreshSuccess", { ns: "common" }));
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh:", error);
|
||||
message.error(t("refreshFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [currentDomain, selectedAppId, refreshing, fileChangeStore, loadAppDetail, message, t]);
|
||||
|
||||
const handleBackToList = useCallback(() => {
|
||||
setViewMode("list");
|
||||
setSelectedFile(null);
|
||||
}, []);
|
||||
|
||||
// Handle mouse back button (XButton1) to navigate back from code view
|
||||
useEffect(() => {
|
||||
const handleMouseClick = (e: MouseEvent) => {
|
||||
// XButton1 (back button) is typically button 3 on mice with side buttons
|
||||
if (e.button === 3 && viewMode === "code") {
|
||||
// Prevent any default browser back action
|
||||
e.preventDefault();
|
||||
handleBackToList();
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener for mouse clicks
|
||||
document.addEventListener("mousedown", handleMouseClick);
|
||||
|
||||
// Clean up event listener on unmount
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseClick);
|
||||
};
|
||||
}, [viewMode, handleBackToList]);
|
||||
|
||||
const handleDownloadFile = useCallback(
|
||||
async (fileKey: string, fileName: string) => {
|
||||
if (!currentDomain || downloadingKey) return;
|
||||
|
||||
const type = fileName.endsWith(".css") ? "css" : "js";
|
||||
const hasExt = /\.(js|css)$/i.test(fileName);
|
||||
const finalFileName = hasExt ? fileName : `${fileName}.${type}`;
|
||||
|
||||
setDownloadingKey(fileKey);
|
||||
|
||||
try {
|
||||
const dialogResult = await window.api.showSaveDialog({
|
||||
defaultPath: finalFileName,
|
||||
});
|
||||
|
||||
if (!dialogResult.success || !dialogResult.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentResult = await window.api.getFileContent({
|
||||
domainId: currentDomain.id,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
if (!contentResult.success || !contentResult.data.content) {
|
||||
message.error(contentResult.success ? t("downloadFailed", { ns: "common" }) : contentResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const saveResult = await window.api.saveFileContent({
|
||||
filePath: dialogResult.data,
|
||||
content: contentResult.data.content,
|
||||
});
|
||||
|
||||
if (saveResult.success) {
|
||||
message.success(t("downloadSuccess", { ns: "common" }));
|
||||
} else {
|
||||
message.error(saveResult.error || t("downloadFailed", { ns: "common" }));
|
||||
}
|
||||
} catch {
|
||||
message.error(t("downloadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setDownloadingKey(null);
|
||||
}
|
||||
},
|
||||
[currentDomain, downloadingKey, message, t]
|
||||
);
|
||||
|
||||
const handleDownloadAll = useCallback(async () => {
|
||||
if (!currentDomain || !selectedAppId || downloadingAll || !currentApp) return;
|
||||
|
||||
const appName = currentApp.name || "app";
|
||||
const sanitizedAppName = appName.replace(/[:*?"<>|]/g, "_");
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const defaultFilename = `${sanitizedAppName}_${date}.zip`;
|
||||
|
||||
const dialogResult = await window.api.showSaveDialog({
|
||||
defaultPath: defaultFilename,
|
||||
});
|
||||
|
||||
if (!dialogResult.success || !dialogResult.data) return;
|
||||
|
||||
const savePath = dialogResult.data;
|
||||
setDownloadingAll(true);
|
||||
|
||||
try {
|
||||
const result = await window.api.downloadAllZip({
|
||||
const result = await window.api.getAppDetail({
|
||||
domainId: currentDomain.id,
|
||||
appId: selectedAppId,
|
||||
savePath,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
message.success(t("downloadAllSuccess", { path: result.data?.path, ns: "common" }));
|
||||
} else {
|
||||
message.error(result.error || t("downloadFailed", { ns: "common" }));
|
||||
}
|
||||
} catch {
|
||||
message.error(t("downloadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setDownloadingAll(false);
|
||||
}
|
||||
}, [currentDomain, selectedAppId, downloadingAll, currentApp, message, t]);
|
||||
|
||||
const handleDeploy = useCallback(async () => {
|
||||
if (!currentDomain || !selectedAppId || deploying) return;
|
||||
|
||||
const allFiles = fileChangeStore.getFiles(currentDomain.id, selectedAppId);
|
||||
const deployEntries: DeployFileEntry[] = allFiles.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType,
|
||||
platform: f.platform,
|
||||
status: f.status,
|
||||
fileKey: f.fileKey,
|
||||
url: f.url,
|
||||
storagePath: f.storagePath,
|
||||
}));
|
||||
|
||||
setDeploying(true);
|
||||
try {
|
||||
const result = await window.api.deploy({
|
||||
domainId: currentDomain.id,
|
||||
appId: selectedAppId,
|
||||
files: deployEntries,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
message.success(t("deploySuccess"));
|
||||
// Clear changes after successful deploy
|
||||
await loadAppDetail(() => fileChangeStore.clearChanges(currentDomain.id, selectedAppId));
|
||||
} else {
|
||||
message.error(result.error || t("deployFailed"));
|
||||
setCurrentApp(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : t("deployFailed"));
|
||||
console.error("Failed to load app detail:", error);
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentDomain, selectedAppId, deploying, fileChangeStore, loadAppDetail, message, t]);
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = React.useState("info");
|
||||
const [selectedFile, setSelectedFile] = React.useState<{
|
||||
type: "js" | "css";
|
||||
fileKey: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
if (!currentDomain || !selectedAppId) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.emptySection}>
|
||||
<Empty description={t("selectApp")} />
|
||||
<Empty
|
||||
description="请选择一个应用"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -376,131 +158,175 @@ const AppDetail: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.emptySection}>
|
||||
<Empty description={t("appNotFound")} />
|
||||
<Empty
|
||||
description="未找到应用信息"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const changeCount = currentDomain && selectedAppId ? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId) : { added: 0, deleted: 0, reordered: 0 };
|
||||
const hasChanges = changeCount.added > 0 || changeCount.deleted > 0 || changeCount.reordered > 0;
|
||||
const hasRemoteRevisionChange =
|
||||
currentDomain && selectedAppId && currentApp.customization?.revision
|
||||
? fileChangeStore.hasRemoteChange(currentDomain.id, selectedAppId, currentApp.customization.revision)
|
||||
: false;
|
||||
const renderFileList = (
|
||||
files: (JSFileConfig | CSSFileConfig)[] | undefined,
|
||||
type: "js" | "css",
|
||||
) => {
|
||||
if (!files || files.length === 0) {
|
||||
return <div className={styles.emptySection}>暂无配置</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{files.map((file, index) => {
|
||||
const fileName = file.file?.name || file.url || `文件 ${index + 1}`;
|
||||
const fileKey = file.file?.fileKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.fileItem}
|
||||
onClick={() => {
|
||||
if (fileKey) {
|
||||
setSelectedFile({ type, fileKey, name: fileName });
|
||||
setActiveTab("code");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.fileInfo}>
|
||||
<FileTextOutlined />
|
||||
<div>
|
||||
<div className={styles.fileName}>{fileName}</div>
|
||||
<div className={styles.fileType}>
|
||||
{type.toUpperCase()} ·{" "}
|
||||
{file.type === "FILE" ? "文件上传" : "URL"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{fileKey && (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFile({ type, fileKey, name: fileName });
|
||||
setActiveTab("code");
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<DownloadOutlined />}>
|
||||
下载
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<LayoutGrid size={24} style={{ color: token.colorLink }} />
|
||||
<AppstoreOutlined style={{ fontSize: 24, color: "#1890ff" }} />
|
||||
<h3 className={styles.appName}>{currentApp.name}</h3>
|
||||
<Tag>ID: {currentApp.appId}</Tag>
|
||||
<Tag color="blue">{currentApp.appId}</Tag>
|
||||
</div>
|
||||
<Space>
|
||||
<Badge dot={hasRemoteRevisionChange}>
|
||||
<Button icon={<RefreshCw size={16} />} loading={refreshing} onClick={handleRefresh}>
|
||||
{t("refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</Badge>
|
||||
<Button icon={<History size={16} />}>{t("versionHistory", { ns: "common" })}</Button>
|
||||
<Button icon={<Download size={16} />} loading={downloadingAll} onClick={handleDownloadAll}>
|
||||
{t("downloadAll")}
|
||||
<Button icon={<HistoryOutlined />}>版本历史</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />}>
|
||||
下载全部
|
||||
</Button>
|
||||
<Tooltip title={isAnySectionOverLimit ? t("deployDisabledReason") : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Rocket size={16} />}
|
||||
loading={deploying}
|
||||
disabled={(!hasChanges && !deploying) || isAnySectionOverLimit}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
{t("deploy")}
|
||||
{hasChanges && (
|
||||
<Tag
|
||||
color="white"
|
||||
style={{
|
||||
color: token.colorPrimary,
|
||||
marginLeft: 4,
|
||||
fontSize: token.fontSizeSM,
|
||||
padding: "0 4px",
|
||||
lineHeight: "16px",
|
||||
}}
|
||||
>
|
||||
{changeCount.added > 0 && `+${changeCount.added}`}
|
||||
{changeCount.added > 0 && (changeCount.deleted > 0 || changeCount.reordered > 0) && " "}
|
||||
{changeCount.deleted > 0 && `-${changeCount.deleted}`}
|
||||
{(changeCount.added > 0 || changeCount.deleted > 0) && changeCount.reordered > 0 && " "}
|
||||
{changeCount.reordered > 0 && `~${changeCount.reordered}`}
|
||||
</Tag>
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{viewMode === "list" ? (
|
||||
<>
|
||||
<FileSection
|
||||
title={t("pcJs")}
|
||||
icon={<Monitor size={14} />}
|
||||
platform="desktop"
|
||||
fileType="js"
|
||||
domainId={currentDomain.id}
|
||||
appId={selectedAppId}
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("desktop-js")}
|
||||
/>
|
||||
<FileSection
|
||||
title={t("pcCss")}
|
||||
icon={<Monitor size={14} />}
|
||||
platform="desktop"
|
||||
fileType="css"
|
||||
domainId={currentDomain.id}
|
||||
appId={selectedAppId}
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("desktop-css")}
|
||||
/>
|
||||
<FileSection
|
||||
title={t("mobileJs")}
|
||||
icon={<Smartphone size={14} />}
|
||||
platform="mobile"
|
||||
fileType="js"
|
||||
domainId={currentDomain.id}
|
||||
appId={selectedAppId}
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("mobile-js")}
|
||||
/>
|
||||
<FileSection
|
||||
title={t("mobileCss")}
|
||||
icon={<Smartphone size={14} />}
|
||||
platform="mobile"
|
||||
fileType="css"
|
||||
domainId={currentDomain.id}
|
||||
appId={selectedAppId}
|
||||
downloadingKey={downloadingKey}
|
||||
onView={handleFileClick}
|
||||
onDownload={handleDownloadFile}
|
||||
onOverLimitChange={handleOverLimitChange("mobile-css")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.codeView}>
|
||||
<Button type="text" icon={<ArrowLeft size={16} />} onClick={handleBackToList} className={styles.backButton}>
|
||||
{t("backToList")}
|
||||
</Button>
|
||||
{selectedFile && (
|
||||
<CodeViewer fileKey={selectedFile.fileKey} fileName={selectedFile.name} fileType={selectedFile.type} storagePath={selectedFile.storagePath} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: "info",
|
||||
label: "基本信息",
|
||||
children: (
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="应用ID">
|
||||
{currentApp.appId}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="应用代码">
|
||||
{currentApp.code || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{currentApp.createdAt}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建者">
|
||||
{currentApp.creator?.name}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{currentApp.modifiedAt}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新者">
|
||||
{currentApp.modifier?.name}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="所属Space" span={2}>
|
||||
{currentApp.spaceName || currentApp.spaceId || "-"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pc-js",
|
||||
label: "PC端 JS",
|
||||
children: renderFileList(
|
||||
currentApp.customization?.javascript?.pc,
|
||||
"js",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pc-css",
|
||||
label: "PC端 CSS",
|
||||
children: renderFileList(
|
||||
currentApp.customization?.stylesheet?.pc,
|
||||
"css",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mobile-js",
|
||||
label: "移动端 JS",
|
||||
children: renderFileList(
|
||||
currentApp.customization?.javascript?.mobile,
|
||||
"js",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "mobile-css",
|
||||
label: "移动端 CSS",
|
||||
children: renderFileList(
|
||||
currentApp.customization?.stylesheet?.mobile,
|
||||
"css",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "代码查看",
|
||||
children: selectedFile ? (
|
||||
<CodeViewer
|
||||
fileKey={selectedFile.fileKey}
|
||||
fileName={selectedFile.name}
|
||||
fileType={selectedFile.type}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.emptySection}>
|
||||
请从文件列表中选择要查看的文件
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* DropZone Component
|
||||
* A click-to-select file button (visual hint for the drop zone).
|
||||
* Actual drag-and-drop is handled at the FileSection level.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createStyles } from "antd-style";
|
||||
import { CloudUpload } from "lucide-react";
|
||||
|
||||
interface DropZoneProps {
|
||||
fileType: "js" | "css";
|
||||
isSaving: boolean;
|
||||
onFileSelected: (files: File[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
button: css`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${token.paddingXS}px;
|
||||
padding: ${token.paddingMD}px;
|
||||
border: 2px dashed ${token.colorBorderSecondary};
|
||||
border-radius: ${token.borderRadiusSM}px;
|
||||
color: ${token.colorTextQuaternary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: ${token.colorPrimary};
|
||||
color: ${token.colorPrimary};
|
||||
background: ${token.colorPrimaryBg};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const DropZone: React.FC<DropZoneProps> = ({ fileType, isSaving, onFileSelected }) => {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const { styles } = useStyles();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
await onFileSelected(files);
|
||||
}
|
||||
e.target.value = "";
|
||||
},
|
||||
[onFileSelected]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.button} onClick={handleClick} disabled={isSaving} type="button">
|
||||
<CloudUpload size={14} />
|
||||
<span>{isSaving ? t("loading", { ns: "common" }) : t("dropZoneHint", { fileType: `.${fileType}` })}</span>
|
||||
</button>
|
||||
<input ref={inputRef} type="file" accept={`.${fileType}`} multiple style={{ display: "none" }} onChange={handleChange} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropZone;
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* FileItem Component
|
||||
* Displays a single file with status indicator (unchanged/added/deleted/reordered).
|
||||
* Shows appropriate action buttons based on status.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Space, Badge, Tooltip } from "antd";
|
||||
import { Button, MaterialFileTypeIcon, SortableList } from "@lobehub/ui";
|
||||
import { Download, Trash2, Undo2 } from "lucide-react";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import type { FileEntry } from "@renderer/stores";
|
||||
|
||||
interface FileItemProps {
|
||||
entry: FileEntry;
|
||||
onDelete: () => void;
|
||||
onRestore: () => void;
|
||||
onView?: () => void;
|
||||
onDownload?: () => void;
|
||||
isDownloading?: boolean;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
item: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: ${token.colorBgTextHover};
|
||||
}
|
||||
`,
|
||||
fileInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingXS}px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`,
|
||||
fileName: css`
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
fileNameDeleted: css`
|
||||
text-decoration: line-through;
|
||||
`,
|
||||
fileSize: css`
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const formatFileSize = (size: number | undefined): string => {
|
||||
if (!size) return "";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const FileItem: React.FC<FileItemProps> = ({ entry, onDelete, onRestore, onView, onDownload, isDownloading }) => {
|
||||
const { t } = useTranslation(["app", "common"]);
|
||||
const { styles, cx } = useStyles();
|
||||
const token = useTheme();
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
added: token.colorSuccess,
|
||||
deleted: token.colorError,
|
||||
reordered: token.colorWarning,
|
||||
};
|
||||
|
||||
const handleItemClick = () => {
|
||||
// Allow viewing if fileKey (Kintone file) OR storagePath (local file)
|
||||
if (onView && (entry.fileKey || entry.storagePath)) {
|
||||
onView();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragHandleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent, handler: () => void) => {
|
||||
e.stopPropagation();
|
||||
handler();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.item} onClick={handleItemClick} data-file-item="true">
|
||||
<div className={styles.fileInfo}>
|
||||
<div onClick={handleDragHandleClick}>
|
||||
<SortableList.DragHandle />
|
||||
</div>
|
||||
<MaterialFileTypeIcon type="file" filename={`file.${entry.fileType}`} size={16} />
|
||||
<span
|
||||
className={cx(styles.fileName, entry.status === "deleted" && styles.fileNameDeleted)}
|
||||
style={entry.status !== "unchanged" ? { color: statusColor[entry.status] } : undefined}
|
||||
>
|
||||
{entry.fileName}
|
||||
</span>
|
||||
{entry.status === "added" && <Badge color={token.colorSuccess} text={t("statusAdded")} style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }} />}
|
||||
{entry.status === "deleted" && (
|
||||
<Badge color={token.colorError} text={t("statusDeleted")} style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }} />
|
||||
)}
|
||||
{entry.status === "reordered" && (
|
||||
<Badge color={token.colorWarning} text={t("statusReordered")} style={{ fontSize: token.fontSizeSM, whiteSpace: "nowrap" }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
{entry.size && <span className={styles.fileSize}>{formatFileSize(entry.size)}</span>}
|
||||
|
||||
{entry.status === "deleted" ? (
|
||||
<Button type="text" size="small" icon={<Undo2 size={16} />} onClick={(e) => handleButtonClick(e, onRestore)}>
|
||||
{t("restore")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{(entry.status === "unchanged" || entry.status === "reordered") && onDownload && entry.fileKey && (
|
||||
<Tooltip title={t("downloadFile", { ns: "common" })}>
|
||||
<Button type="text" size="small" icon={<Download size={16} />} loading={isDownloading} onClick={(e) => handleButtonClick(e, onDownload!)} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t("deleteFile", { ns: "common" })}>
|
||||
<Button type="text" size="small" danger icon={<Trash2 size={16} />} onClick={(e) => handleButtonClick(e, onDelete)} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileItem;
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* FileSection Component
|
||||
* Displays a file section (PC JS / PC CSS / Mobile JS / Mobile CSS).
|
||||
* The entire section is a drag-and-drop target for files.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tag, App as AntApp } from "antd";
|
||||
import { Alert } from "@lobehub/ui";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import { useFileChangeStore } from "@renderer/stores";
|
||||
import type { FileEntry } from "@renderer/stores";
|
||||
import FileItem from "./FileItem";
|
||||
import DropZone from "./DropZone";
|
||||
import SortableFileList from "./SortableFileList";
|
||||
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
interface FileSectionProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
platform: "desktop" | "mobile";
|
||||
fileType: "js" | "css";
|
||||
domainId: string;
|
||||
appId: string;
|
||||
downloadingKey: string | null;
|
||||
onView: (fileKey: string | undefined, fileName: string, storagePath?: string) => void;
|
||||
onDownload: (fileKey: string, fileName: string) => void;
|
||||
onOverLimitChange?: (isOverLimit: boolean) => void;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
section: css`
|
||||
margin-top: ${token.marginMD}px;
|
||||
position: relative;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
`,
|
||||
sectionHeader: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingXS}px;
|
||||
padding: ${token.paddingSM}px 0;
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
`,
|
||||
fileTable: css`
|
||||
border: 2px dashed transparent;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
background 0.15s;
|
||||
`,
|
||||
fileTableBorder: css`
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
fileTableDragging: css`
|
||||
border-color: ${token.colorPrimary} !important;
|
||||
background: ${token.colorPrimaryBg};
|
||||
`,
|
||||
fileTableDraggingInvalid: css`
|
||||
border-color: ${token.colorError} !important;
|
||||
background: ${token.colorErrorBg};
|
||||
`,
|
||||
emptySection: css`
|
||||
padding: ${token.paddingMD}px;
|
||||
text-align: center;
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
`,
|
||||
dropZoneWrapper: css`
|
||||
padding: ${token.paddingXS}px ${token.paddingSM}px;
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
dropZoneWrapperWithBorder: css`
|
||||
border-top: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
dropOverlay: css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
font-size: ${token.fontSize}px;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
`,
|
||||
}));
|
||||
|
||||
const FileSection: React.FC<FileSectionProps> = ({
|
||||
title,
|
||||
icon,
|
||||
platform,
|
||||
fileType,
|
||||
domainId,
|
||||
appId,
|
||||
downloadingKey,
|
||||
onView,
|
||||
onDownload,
|
||||
onOverLimitChange,
|
||||
}) => {
|
||||
const { t } = useTranslation("app");
|
||||
const { styles, cx } = useStyles();
|
||||
const token = useTheme();
|
||||
const { message } = AntApp.useApp();
|
||||
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const [isDragInvalid, setIsDragInvalid] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const { getSectionFiles, addFile, deleteFile, restoreFile, reorderSection } = useFileChangeStore();
|
||||
|
||||
const files = getSectionFiles(domainId, appId, platform, fileType);
|
||||
// Count files excluding deleted ones for the 30-file limit
|
||||
const fileCount = files.filter((f) => f.status !== "deleted").length;
|
||||
const isOverLimit = fileCount > 30;
|
||||
|
||||
// Notify parent when over-limit state changes
|
||||
useEffect(() => {
|
||||
onOverLimitChange?.(isOverLimit);
|
||||
}, [isOverLimit, onOverLimitChange]);
|
||||
|
||||
const addedCount = files.filter((f) => f.status === "added").length;
|
||||
const deletedCount = files.filter((f) => f.status === "deleted").length;
|
||||
const reorderedCount = files.filter((f) => f.status === "reordered").length;
|
||||
const hasChanges = addedCount > 0 || deletedCount > 0 || reorderedCount > 0;
|
||||
|
||||
// ── Shared save logic ─────────────────────────────────────────────────────
|
||||
const saveFile = useCallback(
|
||||
async (fileOrFiles: File | File[]) => {
|
||||
const files = Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles];
|
||||
|
||||
for (const file of files) {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
if (ext !== fileType) {
|
||||
message.error(t("fileTypeNotSupported", { expected: `.${fileType}` }));
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(t("fileSizeExceeded"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = window.api.getPathForFile(file) || (file as File & { path?: string }).path;
|
||||
if (!sourcePath) {
|
||||
message.error(t("fileAddFailed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const fileId = crypto.randomUUID();
|
||||
const result = await window.api.saveFile({
|
||||
domainId,
|
||||
appId,
|
||||
platform,
|
||||
fileType,
|
||||
fileId,
|
||||
sourcePath,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
message.error(result.error || t("fileAddFailed"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: FileEntry = {
|
||||
id: fileId,
|
||||
fileName: result.data.fileName,
|
||||
fileType,
|
||||
platform,
|
||||
status: "added",
|
||||
size: result.data.size,
|
||||
storagePath: result.data.storagePath,
|
||||
};
|
||||
|
||||
addFile(domainId, appId, entry);
|
||||
} catch {
|
||||
message.error(t("fileAddFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[domainId, appId, platform, fileType, addFile, message, t]
|
||||
);
|
||||
|
||||
// ── Drag-and-drop handlers (entire section is the drop zone) ──────────────
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current++;
|
||||
|
||||
if (dragCounterRef.current === 1) {
|
||||
const items = Array.from(e.dataTransfer.items);
|
||||
const hasFile = items.some((i) => i.kind === "file");
|
||||
if (!hasFile) return;
|
||||
|
||||
// Best-effort type check — MIME types are unreliable on Windows,
|
||||
// so we fall back to "probably valid" if type is empty/unknown.
|
||||
const hasInvalidType = items.some((i) => {
|
||||
if (i.kind !== "file") return false;
|
||||
const mime = i.type.toLowerCase();
|
||||
if (!mime) return false; // unknown → allow, validate on drop
|
||||
if (fileType === "js") {
|
||||
return !(mime.includes("javascript") || mime.includes("text/plain") || mime === "");
|
||||
}
|
||||
if (fileType === "css") {
|
||||
return !(mime.includes("css") || mime.includes("text/plain") || mime === "");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
setIsDragInvalid(hasInvalidType);
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
},
|
||||
[fileType]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDraggingOver(false);
|
||||
setIsDragInvalid(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = 0;
|
||||
setIsDraggingOver(false);
|
||||
setIsDragInvalid(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
if (droppedFiles.length === 0) return;
|
||||
await saveFile(droppedFiles);
|
||||
},
|
||||
[saveFile]
|
||||
);
|
||||
|
||||
// ── Delete / restore ───────────────────────────────────────────────────────
|
||||
const handleDelete = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
if (entry.status === "added" && entry.storagePath) {
|
||||
const result = await window.api.deleteFile({
|
||||
storagePath: entry.storagePath,
|
||||
});
|
||||
if (!result.success) {
|
||||
message.error(result.error || t("fileDeleteFailed"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
deleteFile(domainId, appId, entry.id);
|
||||
},
|
||||
[domainId, appId, deleteFile, message, t]
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
(fileId: string) => {
|
||||
restoreFile(domainId, appId, fileId);
|
||||
},
|
||||
[domainId, appId, restoreFile]
|
||||
);
|
||||
|
||||
// ── Reorder ────────────────────────────────────────────────────────────────
|
||||
const handleReorder = useCallback(
|
||||
(newOrder: string[], draggedFileId: string) => {
|
||||
reorderSection(domainId, appId, platform, fileType, newOrder, draggedFileId);
|
||||
},
|
||||
[domainId, appId, platform, fileType, reorderSection]
|
||||
);
|
||||
|
||||
// ── Render item ────────────────────────────────────────────────────────────
|
||||
const renderItem = useCallback(
|
||||
(entry: FileEntry) => {
|
||||
// Determine if file is viewable (has fileKey for Kintone files OR storagePath for local files)
|
||||
const isViewable = entry.fileKey || entry.storagePath;
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
entry={entry}
|
||||
onDelete={() => handleDelete(entry)}
|
||||
onRestore={() => handleRestore(entry.id)}
|
||||
onView={isViewable ? () => onView(entry.fileKey, entry.fileName, entry.storagePath) : undefined}
|
||||
onDownload={entry.fileKey ? () => onDownload(entry.fileKey!, entry.fileName) : undefined}
|
||||
isDownloading={downloadingKey === entry.fileKey}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleDelete, handleRestore, onView, onDownload, downloadingKey]
|
||||
);
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{hasChanges && (
|
||||
<>
|
||||
{addedCount > 0 && (
|
||||
<Tag color="success" style={{ fontSize: token.fontSizeSM, padding: "0 4px", lineHeight: "16px" }}>
|
||||
+{addedCount}
|
||||
</Tag>
|
||||
)}
|
||||
{deletedCount > 0 && (
|
||||
<Tag color="error" style={{ fontSize: token.fontSizeSM, padding: "0 4px", lineHeight: "16px" }}>
|
||||
-{deletedCount}
|
||||
</Tag>
|
||||
)}
|
||||
{reorderedCount > 0 && (
|
||||
<Tag color="warning" style={{ fontSize: token.fontSizeSM, padding: "0 4px", lineHeight: "16px" }}>
|
||||
~{reorderedCount}
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File limit warning */}
|
||||
{isOverLimit && <Alert type="warning" title={t("fileLimitWarning")} showIcon style={{ marginBottom: token.marginSM }} />}
|
||||
|
||||
{/* The entire card is the drop target */}
|
||||
<div
|
||||
className={cx(
|
||||
styles.fileTable,
|
||||
!isDraggingOver && styles.fileTableBorder,
|
||||
isDraggingOver && !isDragInvalid && styles.fileTableDragging,
|
||||
isDraggingOver && isDragInvalid && styles.fileTableDraggingInvalid
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDraggingOver && (
|
||||
<div
|
||||
className={styles.dropOverlay}
|
||||
style={{
|
||||
background: isDragInvalid ? `${token.colorErrorBg}DD` : `${token.colorPrimaryBg}DD`,
|
||||
color: isDragInvalid ? token.colorError : token.colorPrimary,
|
||||
}}
|
||||
>
|
||||
{isDragInvalid ? t("fileTypeNotSupported", { expected: `.${fileType}` }) : t("dropFileHere")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && <SortableFileList items={files} onReorder={handleReorder} renderItem={renderItem} dividerColor={token.colorBorder} />}
|
||||
|
||||
{/* Click-to-add strip */}
|
||||
<div className={cx(styles.dropZoneWrapper, files.length > 0 && styles.dropZoneWrapperWithBorder)}>
|
||||
<DropZone fileType={fileType} isSaving={isSaving} onFileSelected={saveFile} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File limit warning - bottom */}
|
||||
{isOverLimit && <Alert type="warning" title={t("fileLimitWarning")} showIcon style={{ marginTop: token.marginSM }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSection;
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* SortableFileList Component
|
||||
* A sortable list component using dnd-kit for drag-and-drop reordering.
|
||||
* Provides precise tracking of which item was dragged.
|
||||
* Uses LobeHub UI's SortableList.Item and SortableList.DragHandle for consistent styling.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { SortableList } from "@lobehub/ui";
|
||||
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { restrictToVerticalAxis, restrictToWindowEdges } from "@dnd-kit/modifiers";
|
||||
import { createStyles } from "antd-style";
|
||||
import type { FileEntry } from "@renderer/stores";
|
||||
|
||||
const useStyles = createStyles(({ css }) => ({
|
||||
fileList: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
/* 分割线:非最后一个子元素的 .fileItem 显示 */
|
||||
& > *:not(:last-child) [data-file-item="true"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--divider-color, #d9d9d9);
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
// ── SortableFileList Component ────────────────────────────────────────────────
|
||||
interface SortableFileListProps {
|
||||
items: FileEntry[];
|
||||
onReorder: (newOrder: string[], draggedItemId: string) => void;
|
||||
renderItem: (entry: FileEntry) => React.ReactNode;
|
||||
dividerColor?: string;
|
||||
}
|
||||
|
||||
const SortableFileList: React.FC<SortableFileListProps> = ({ items, onReorder, renderItem, dividerColor }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex((f) => f.id === active.id);
|
||||
const newIndex = items.findIndex((f) => f.id === over.id);
|
||||
const newOrder = arrayMove(items, oldIndex, newIndex).map((f) => f.id);
|
||||
|
||||
onReorder(newOrder, active.id as string);
|
||||
}
|
||||
},
|
||||
[items, onReorder]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={undefined} onDragEnd={handleDragEnd} modifiers={[restrictToVerticalAxis, restrictToWindowEdges]}>
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className={styles.fileList} style={dividerColor ? ({ "--divider-color": dividerColor } as React.CSSProperties) : undefined}>
|
||||
{items.map((entry) => (
|
||||
<SortableList.Item key={entry.id} id={entry.id}>
|
||||
{renderItem(entry)}
|
||||
</SortableList.Item>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableFileList;
|
||||
@@ -1,294 +0,0 @@
|
||||
/**
|
||||
* AppList Component
|
||||
* Displays apps in a clickable list with sorting and pinning
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spin, Typography, Space } from "antd";
|
||||
import { Button, Tooltip, Empty, Select, Input } from "@lobehub/ui";
|
||||
|
||||
import { RefreshCw, Search, ArrowUpDown, ArrowDownUp } from "lucide-react";
|
||||
import { createStyles } from "antd-style";
|
||||
import { useAppStore, useDomainStore, useUIStore, useFileChangeStore } from "@renderer/stores";
|
||||
import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck";
|
||||
import type { AppDetail } from "@shared/types/kintone";
|
||||
import AppListItem from "./AppListItem";
|
||||
|
||||
const { Text } = Typography;
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
header: css`
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: ${token.paddingSM}px;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
searchWrapper: css`
|
||||
flex: 1;
|
||||
max-width: 200px;
|
||||
`,
|
||||
content: css`
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
`,
|
||||
loading: css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
`,
|
||||
empty: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
`,
|
||||
appId: css`
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextSecondary};
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
footer: css`
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
border-top: 1px solid ${token.colorBorderSecondary};
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
loadedInfo: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: 12px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const AppList: React.FC = () => {
|
||||
const { t } = useTranslation("app");
|
||||
const { styles } = useStyles();
|
||||
const { currentDomain } = useDomainStore();
|
||||
const { apps, loading, error, searchText, loadedAt, selectedAppId, setApps, setLoading, setError, setSearchText, setSelectedAppId } = useAppStore();
|
||||
const { pinnedApps, appSortBy, appSortOrder, togglePinnedApp, setAppSortBy, setAppSortOrder } = useUIStore();
|
||||
const { clearChanges } = useFileChangeStore();
|
||||
const { checkAndConfirmAppSwitch } = usePendingChangesCheck();
|
||||
|
||||
const currentPinnedApps = currentDomain ? pinnedApps[currentDomain.id] || [] : [];
|
||||
|
||||
// Load apps from Kintone
|
||||
const handleLoadApps = async () => {
|
||||
if (!currentDomain) return;
|
||||
|
||||
const domainIdAtStart = currentDomain.id;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.api.getApps({
|
||||
domainId: domainIdAtStart,
|
||||
});
|
||||
|
||||
// Discard result if domain switched during the request
|
||||
if (useDomainStore.getState().currentDomain?.id !== domainIdAtStart) return;
|
||||
|
||||
if (result.success) {
|
||||
setApps(result.data);
|
||||
} else {
|
||||
setError(result.error || t("loadAppsFailed"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (useDomainStore.getState().currentDomain?.id === domainIdAtStart) {
|
||||
setError(err instanceof Error ? err.message : t("loadAppsFailed"));
|
||||
}
|
||||
} finally {
|
||||
if (useDomainStore.getState().currentDomain?.id === domainIdAtStart) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Sort apps
|
||||
const sortApps = (appsToSort: typeof apps) => {
|
||||
const sorted = [...appsToSort];
|
||||
sorted.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (appSortBy) {
|
||||
case "name":
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case "appId":
|
||||
comparison = parseInt(a.appId, 10) - parseInt(b.appId, 10);
|
||||
break;
|
||||
default:
|
||||
comparison = parseInt(a.appId, 10) - parseInt(b.appId, 10);
|
||||
}
|
||||
|
||||
return appSortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
return sorted;
|
||||
};
|
||||
|
||||
// Filter and sort apps
|
||||
const processedApps = React.useMemo(() => {
|
||||
let filtered = apps;
|
||||
|
||||
// Filter by search text
|
||||
if (searchText) {
|
||||
const lowerSearch = searchText.toLowerCase();
|
||||
filtered = apps.filter(
|
||||
(app) => app.name.toLowerCase().includes(lowerSearch) || app.appId.includes(searchText) || (app.code && app.code.toLowerCase().includes(lowerSearch))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
return sortApps(filtered);
|
||||
}, [apps, searchText, appSortBy, appSortOrder]);
|
||||
|
||||
// Separate pinned and unpinned apps
|
||||
const { pinnedAppsList, unpinnedAppsList } = React.useMemo(() => {
|
||||
const pinned: typeof apps = [];
|
||||
const unpinned: typeof apps = [];
|
||||
|
||||
processedApps.forEach((app) => {
|
||||
if (currentPinnedApps.includes(app.appId)) {
|
||||
pinned.push(app);
|
||||
} else {
|
||||
unpinned.push(app);
|
||||
}
|
||||
});
|
||||
|
||||
return { pinnedAppsList: pinned, unpinnedAppsList: unpinned };
|
||||
}, [processedApps, currentPinnedApps]);
|
||||
|
||||
// Final display list: pinned first, then unpinned
|
||||
const displayApps = [...pinnedAppsList, ...unpinnedAppsList];
|
||||
|
||||
// Handle item click
|
||||
const handleItemClick = async (app: AppDetail) => {
|
||||
// Check for pending changes before switching
|
||||
const confirmed = await checkAndConfirmAppSwitch(app.appId);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear changes from previous app before switching
|
||||
if (selectedAppId && currentDomain && selectedAppId !== app.appId) {
|
||||
clearChanges(currentDomain.id, selectedAppId);
|
||||
}
|
||||
setSelectedAppId(app.appId);
|
||||
};
|
||||
|
||||
// Handle pin toggle
|
||||
const handlePinToggle = (e: React.MouseEvent, appId: string) => {
|
||||
e.stopPropagation();
|
||||
if (currentDomain) {
|
||||
togglePinnedApp(currentDomain.id, appId);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle sort order
|
||||
const toggleSortOrder = () => {
|
||||
setAppSortOrder(appSortOrder === "asc" ? "desc" : "asc");
|
||||
};
|
||||
|
||||
if (!currentDomain) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<Empty description={t("selectDomainFirst")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header with search, sort and load button */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.searchWrapper}>
|
||||
<Input
|
||||
placeholder={t("searchApp")}
|
||||
prefix={<Search size={16} />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
disabled={apps.length === 0 && !searchText}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Select
|
||||
value={appSortBy}
|
||||
onChange={setAppSortBy}
|
||||
size="small"
|
||||
options={[
|
||||
{ label: t("sortByAppId"), value: "appId" },
|
||||
{ label: t("sortByName"), value: "name" },
|
||||
]}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<Tooltip title={appSortOrder === "asc" ? t("ascending") : t("descending")}>
|
||||
<Button type="text" size="small" icon={appSortOrder === "asc" ? <ArrowUpDown size={16} /> : <ArrowDownUp size={16} />} onClick={toggleSortOrder} />
|
||||
</Tooltip>
|
||||
<Tooltip title={apps.length > 0 ? t("reload") : t("loadApps")}>
|
||||
<Button type="text" icon={<RefreshCw size={16} />} onClick={handleLoadApps} loading={loading} size="small" />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={styles.content}>
|
||||
{loading && apps.length === 0 ? (
|
||||
<div className={styles.loading}>
|
||||
<Spin size="large" tip={t("loadingApps")} />
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Empty description={t("noApps")}>
|
||||
<Button type="primary" onClick={handleLoadApps}>
|
||||
{t("loadApps")}
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
displayApps.map((app) => (
|
||||
<AppListItem
|
||||
key={app.appId}
|
||||
app={app}
|
||||
isActive={selectedAppId === app.appId}
|
||||
isPinned={currentPinnedApps.includes(app.appId)}
|
||||
onItemClick={handleItemClick}
|
||||
onPinToggle={handlePinToggle}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* Footer with info */}
|
||||
{apps.length > 0 && (
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.loadedInfo}>
|
||||
{loadedAt && (
|
||||
<Text type="secondary">
|
||||
{t("lastLoaded")}: {new Date(loadedAt).toLocaleString("zh-CN")}
|
||||
</Text>
|
||||
)}
|
||||
<Text type="secondary" style={{ marginLeft: 16 }}>
|
||||
{t("totalApps", { count: displayApps.length })}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppList;
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* AppListItem Component
|
||||
* Individual app item in the app list with pinning support
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Tooltip, Tag } from "@lobehub/ui";
|
||||
import { Pin, LayoutGrid } from "lucide-react";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import type { AppDetail } from "@shared/types/kintone";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
listItemMotion: css`
|
||||
cursor: pointer;
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px !important;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary} !important;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorBgTextHover};
|
||||
}
|
||||
`,
|
||||
listItemActive: css`
|
||||
background: ${token.colorPrimaryBgHover} !important;
|
||||
border-left: 3px solid ${token.colorPrimary} !important;
|
||||
`,
|
||||
listItemPinned: css`
|
||||
background: ${token.colorWarningBg} !important;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorWarningBgHover} !important;
|
||||
}
|
||||
`,
|
||||
appInfoWrapper: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingSM}px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
`,
|
||||
iconWrapper: css`
|
||||
position: relative;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
pinOverlay: css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1;
|
||||
`,
|
||||
pinOverlayVisible: css`
|
||||
opacity: 1;
|
||||
`,
|
||||
appName: css`
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
`,
|
||||
pinButton: css`
|
||||
color: ${token.colorTextTertiary};
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: ${token.colorWarning};
|
||||
}
|
||||
`,
|
||||
pinButtonPinned: css`
|
||||
color: ${token.colorWarning};
|
||||
`,
|
||||
}));
|
||||
|
||||
export interface AppListItemProps {
|
||||
app: AppDetail;
|
||||
isActive: boolean;
|
||||
isPinned: boolean;
|
||||
onItemClick: (app: AppDetail) => void;
|
||||
onPinToggle: (e: React.MouseEvent, appId: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const AppListItem: React.FC<AppListItemProps> = ({ app, isActive, isPinned, onItemClick, onPinToggle, t }) => {
|
||||
const { styles } = useStyles();
|
||||
const token = useTheme();
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
// Pin overlay is visible when:
|
||||
// 1. Item is pinned (always show)
|
||||
// 2. Item is hovered (show for unpinned items)
|
||||
const showPinOverlay = isPinned || isHovered;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className={`${styles.listItemMotion} ${isActive ? styles.listItemActive : ""} ${isPinned ? styles.listItemPinned : ""}`}
|
||||
onClick={() => onItemClick(app)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
>
|
||||
<div className={styles.appInfoWrapper}>
|
||||
<div className={styles.iconWrapper}>
|
||||
{/* Pin overlay - visible on hover for unpinned, always visible for pinned */}
|
||||
<div className={`${styles.pinOverlay} ${showPinOverlay ? styles.pinOverlayVisible : ""}`} onClick={(e) => onPinToggle(e, app.appId)}>
|
||||
<Tooltip title={isPinned ? t("unpin") : t("pinApp")}>
|
||||
<span className={`${styles.pinButton} ${isPinned ? styles.pinButtonPinned : ""}`}>
|
||||
{isPinned ? <Pin size={16} className="fill-current" /> : <Pin size={16} />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* App icon - hidden when pin overlay is visible */}
|
||||
{!showPinOverlay && <LayoutGrid size={16} style={{ color: token.colorLink }} />}
|
||||
</div>
|
||||
<Tooltip title={app.name}>
|
||||
<span className={styles.appName}>{app.name}</span>
|
||||
</Tooltip>
|
||||
<Tag>ID: {app.appId}</Tag>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppListItem;
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* AppList Components
|
||||
* Export all app list components
|
||||
*/
|
||||
|
||||
export { default as AppList } from "./AppList";
|
||||
@@ -4,11 +4,13 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spin, Alert, Space, message } from "antd";
|
||||
import { Button, Empty } from "@lobehub/ui";
|
||||
import { Copy, Download } from "lucide-react";
|
||||
import { createStyles, useTheme } from "antd-style";
|
||||
import { Spin, Empty, Alert, Button, Space, message } from "antd";
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FullscreenOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
@@ -51,82 +53,58 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
}));
|
||||
|
||||
interface CodeViewerProps {
|
||||
fileKey?: string;
|
||||
fileKey: string;
|
||||
fileName: string;
|
||||
fileType: "js" | "css";
|
||||
/** For locally added files: absolute path on disk */
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, storagePath }) => {
|
||||
const { t } = useTranslation("file");
|
||||
const CodeViewer: React.FC<CodeViewerProps> = ({
|
||||
fileKey,
|
||||
fileName,
|
||||
fileType,
|
||||
}) => {
|
||||
const { styles } = useStyles();
|
||||
const { appearance } = useTheme();
|
||||
const themeMode = appearance === "dark" ? "dark" : ("light" as const);
|
||||
const { currentDomain } = useDomainStore();
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [content, setContent] = React.useState<string>("");
|
||||
const [language, setLanguage] = React.useState<"js" | "css">(fileType);
|
||||
const [downloading, setDownloading] = React.useState(false);
|
||||
|
||||
// Load file content
|
||||
React.useEffect(() => {
|
||||
if (currentDomain && (fileKey || storagePath)) {
|
||||
if (currentDomain && fileKey) {
|
||||
loadFileContent();
|
||||
}
|
||||
}, [currentDomain, fileKey, storagePath]);
|
||||
}, [currentDomain, fileKey]);
|
||||
|
||||
const loadFileContent = async () => {
|
||||
if (!currentDomain) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Prefer local file (storagePath) for added files
|
||||
if (storagePath) {
|
||||
const result = await window.api.getLocalFileContent({ storagePath });
|
||||
const result = await window.api.getFileContent({
|
||||
domainId: currentDomain.id,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Decode base64 content properly for UTF-8
|
||||
const base64 = result.data.content || "";
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const decoded = decoder.decode(bytes);
|
||||
setContent(decoded);
|
||||
detectLanguage();
|
||||
} else {
|
||||
setError(result.error || "Failed to load local file content");
|
||||
}
|
||||
} else if (currentDomain && fileKey) {
|
||||
// Load from Kintone
|
||||
const result = await window.api.getFileContent({
|
||||
domainId: currentDomain.id,
|
||||
fileKey,
|
||||
});
|
||||
if (result.success) {
|
||||
// Decode base64 content
|
||||
const decoded = atob(result.data.content || "");
|
||||
setContent(decoded);
|
||||
|
||||
if (result.success) {
|
||||
// Decode base64 content properly for UTF-8 (including Japanese characters)
|
||||
const base64 = result.data.content || "";
|
||||
const binaryString = atob(base64);
|
||||
// Decode as UTF-8 to properly handle Japanese and other multi-byte characters
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const decoded = decoder.decode(bytes);
|
||||
setContent(decoded);
|
||||
detectLanguage();
|
||||
// Detect language from file name
|
||||
if (fileName.endsWith(".css")) {
|
||||
setLanguage("css");
|
||||
} else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
|
||||
setLanguage("js");
|
||||
} else {
|
||||
setError(result.error || "Failed to load file content");
|
||||
setLanguage(fileType);
|
||||
}
|
||||
} else {
|
||||
setError("No file source available");
|
||||
setError(result.error || "Failed to load file content");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
@@ -135,79 +113,28 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
||||
}
|
||||
};
|
||||
|
||||
const detectLanguage = () => {
|
||||
// Detect language from file name
|
||||
if (fileName.endsWith(".css")) {
|
||||
setLanguage("css");
|
||||
} else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
|
||||
setLanguage("js");
|
||||
} else {
|
||||
setLanguage(fileType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(content);
|
||||
message.success(t("copiedToClipboard"));
|
||||
message.success("已复制到剪贴板");
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!currentDomain || downloading) return;
|
||||
|
||||
// Check if fileName already has extension
|
||||
const hasExt = /\.(js|css)$/i.test(fileName);
|
||||
const finalFileName = hasExt ? fileName : `${fileName}.${fileType}`;
|
||||
|
||||
setDownloading(true);
|
||||
|
||||
try {
|
||||
// 1. Show save dialog
|
||||
const dialogResult = await window.api.showSaveDialog({
|
||||
defaultPath: finalFileName,
|
||||
});
|
||||
|
||||
if (!dialogResult.success || !dialogResult.data) {
|
||||
// User cancelled
|
||||
setDownloading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const savePath = dialogResult.data;
|
||||
|
||||
// 2. Get current file content (already decoded in state)
|
||||
const encoder = new TextEncoder();
|
||||
const uint8Array = encoder.encode(content);
|
||||
// Convert to base64 for IPC transfer
|
||||
let base64 = "";
|
||||
const chunkSize = 8192;
|
||||
for (let i = 0; i < uint8Array.length; i += chunkSize) {
|
||||
base64 += String.fromCharCode.apply(null, Array.from(uint8Array.slice(i, i + chunkSize)));
|
||||
}
|
||||
const base64Content = btoa(base64);
|
||||
|
||||
// 3. Save to selected path
|
||||
const saveResult = await window.api.saveFileContent({
|
||||
filePath: savePath,
|
||||
content: base64Content,
|
||||
});
|
||||
|
||||
if (saveResult.success) {
|
||||
message.success(t("downloadSuccess"));
|
||||
} else {
|
||||
message.error(saveResult.error || t("downloadFailed", { ns: "common" }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
message.error(t("downloadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
message.success("下载成功");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<Spin size="large" description={t("loading")} />
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -217,12 +144,12 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
||||
<div className={styles.error}>
|
||||
<Alert
|
||||
type="error"
|
||||
message={t("loadFailed")}
|
||||
message="加载失败"
|
||||
description={error}
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" onClick={loadFileContent}>
|
||||
{t("retry", { ns: "deploy" })}
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
@@ -233,7 +160,10 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
||||
if (!content) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Empty description={t("fileEmpty")} />
|
||||
<Empty
|
||||
description="文件内容为空"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -243,11 +173,21 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
||||
<div className={styles.header}>
|
||||
<span className={styles.fileName}>{fileName}</span>
|
||||
<Space size="small">
|
||||
<Button type="text" size="small" icon={<Copy size={16} />} onClick={handleCopy}>
|
||||
{t("copy", { ns: "common" })}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button type="text" size="small" icon={<Download size={16} />} loading={downloading} onClick={handleDownload}>
|
||||
{t("download", { ns: "common" })}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -258,7 +198,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
||||
height="100%"
|
||||
extensions={[language === "js" ? javascript() : css()]}
|
||||
editable={false}
|
||||
theme={themeMode}
|
||||
theme="light"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: false,
|
||||
|
||||
419
src/renderer/src/components/DeployDialog/DeployDialog.tsx
Normal file
419
src/renderer/src/components/DeployDialog/DeployDialog.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* DeployDialog Component
|
||||
* Dialog for confirming and executing deployment
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
Steps,
|
||||
Button,
|
||||
Space,
|
||||
Alert,
|
||||
Spin,
|
||||
Result,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Divider,
|
||||
} from "antd";
|
||||
import {
|
||||
CloudUploadOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import { FileUploader } from "../FileUploader";
|
||||
import { useDeployStore } from "@renderer/stores";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import { useAppStore } from "@renderer/stores";
|
||||
import type { DeployFile } from "@renderer/types/ipc";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
min-height: 400px;
|
||||
`,
|
||||
stepContent: css`
|
||||
margin-top: ${token.marginMD}px;
|
||||
`,
|
||||
positionSelector: css`
|
||||
margin-bottom: ${token.marginMD}px;
|
||||
`,
|
||||
positionItem: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
margin-bottom: ${token.marginSM}px;
|
||||
`,
|
||||
summary: css`
|
||||
padding: ${token.paddingMD}px;
|
||||
background: ${token.colorBgLayout};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
`,
|
||||
summaryItem: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: ${token.paddingXS}px 0;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface DeployDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const DeployDialog: React.FC<DeployDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { styles } = useStyles();
|
||||
const { currentDomain } = useDomainStore();
|
||||
const { currentApp } = useAppStore();
|
||||
const {
|
||||
step,
|
||||
files,
|
||||
setStep,
|
||||
setFiles,
|
||||
setTargetAppId,
|
||||
setDeploying,
|
||||
setResult,
|
||||
setError,
|
||||
reset,
|
||||
} = useDeployStore();
|
||||
|
||||
// Reset when dialog opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
reset();
|
||||
if (currentApp) {
|
||||
setTargetAppId(currentApp.appId);
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!currentDomain || !currentApp) return;
|
||||
|
||||
setStep("deploying");
|
||||
setDeploying(true);
|
||||
|
||||
try {
|
||||
const result = await window.api.deploy({
|
||||
domainId: currentDomain.id,
|
||||
appId: currentApp.appId,
|
||||
files,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setResult(result);
|
||||
setStep("success");
|
||||
onSuccess?.();
|
||||
} else {
|
||||
setError(result.error || "部署失败");
|
||||
setStep("error");
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "部署失败");
|
||||
setStep("error");
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleFilesChange = (newFiles: DeployFile[]) => {
|
||||
setFiles(newFiles);
|
||||
};
|
||||
|
||||
const handlePositionChange = (
|
||||
index: number,
|
||||
position: DeployFile["position"],
|
||||
) => {
|
||||
const newFiles = [...files];
|
||||
newFiles[index] = { ...newFiles[index], position };
|
||||
setFiles(newFiles);
|
||||
};
|
||||
|
||||
const canProceedToConfigure = files.length > 0;
|
||||
const canProceedToConfirm = files.every((f) => f.position);
|
||||
|
||||
// Position options for JS files
|
||||
const jsPositionOptions = [
|
||||
{ value: "pc_header", label: "PC端 - Header" },
|
||||
{ value: "pc_body", label: "PC端 - Body" },
|
||||
{ value: "pc_footer", label: "PC端 - Footer" },
|
||||
{ value: "mobile_header", label: "移动端 - Header" },
|
||||
{ value: "mobile_body", label: "移动端 - Body" },
|
||||
{ value: "mobile_footer", label: "移动端 - Footer" },
|
||||
];
|
||||
|
||||
// Position options for CSS files
|
||||
const cssPositionOptions = [
|
||||
{ value: "pc_css", label: "PC端" },
|
||||
{ value: "mobile_css", label: "移动端" },
|
||||
];
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (step) {
|
||||
case "select":
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<Alert
|
||||
message="选择要部署的文件"
|
||||
description="请拖拽或选择要部署的 JavaScript 或 CSS 文件"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<FileUploader files={files} onChange={handleFilesChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "configure":
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<Alert
|
||||
message="配置部署位置"
|
||||
description="为每个文件选择部署位置"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className={styles.positionItem}>
|
||||
<Space>
|
||||
<Tag color={file.fileType === "js" ? "gold" : "blue"}>
|
||||
{file.fileType.toUpperCase()}
|
||||
</Tag>
|
||||
<Text>{file.fileName}</Text>
|
||||
</Space>
|
||||
<Select
|
||||
value={file.position}
|
||||
onChange={(value) => handlePositionChange(index, value)}
|
||||
options={
|
||||
file.fileType === "js"
|
||||
? jsPositionOptions
|
||||
: cssPositionOptions
|
||||
}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "confirm":
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<Alert
|
||||
message="确认部署"
|
||||
description="请确认以下部署信息"
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryItem}>
|
||||
<Text strong>目标Domain</Text>
|
||||
<Text>{currentDomain?.name}</Text>
|
||||
</div>
|
||||
<div className={styles.summaryItem}>
|
||||
<Text strong>目标应用</Text>
|
||||
<Text>
|
||||
{currentApp?.name} ({currentApp?.appId})
|
||||
</Text>
|
||||
</div>
|
||||
<Divider style={{ margin: "12px 0" }} />
|
||||
<Text strong>部署文件:</Text>
|
||||
<Table
|
||||
size="small"
|
||||
dataSource={files.map((f, i) => ({ ...f, key: i }))}
|
||||
columns={[
|
||||
{
|
||||
title: "文件名",
|
||||
dataIndex: "fileName",
|
||||
key: "fileName",
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "fileType",
|
||||
key: "fileType",
|
||||
render: (type) => (
|
||||
<Tag color={type === "js" ? "gold" : "blue"}>
|
||||
{type.toUpperCase()}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "部署位置",
|
||||
dataIndex: "position",
|
||||
key: "position",
|
||||
render: (pos) => {
|
||||
const labels: Record<string, string> = {
|
||||
pc_header: "PC端 Header",
|
||||
pc_body: "PC端 Body",
|
||||
pc_footer: "PC端 Footer",
|
||||
mobile_header: "移动端 Header",
|
||||
mobile_body: "移动端 Body",
|
||||
mobile_footer: "移动端 Footer",
|
||||
pc_css: "PC端",
|
||||
mobile_css: "移动端",
|
||||
};
|
||||
return labels[pos] || pos;
|
||||
},
|
||||
},
|
||||
]}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "deploying":
|
||||
return (
|
||||
<div
|
||||
className={styles.stepContent}
|
||||
style={{ textAlign: "center", padding: 48 }}
|
||||
>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />}
|
||||
/>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text>正在部署到 Kintone...</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "success":
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<Result
|
||||
status="success"
|
||||
title="部署成功"
|
||||
subTitle="文件已成功部署到 Kintone"
|
||||
extra={[
|
||||
<Button type="primary" key="close" onClick={handleClose}>
|
||||
完成
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<div className={styles.stepContent}>
|
||||
<Result
|
||||
status="error"
|
||||
title="部署失败"
|
||||
subTitle="部署过程中发生错误"
|
||||
extra={[
|
||||
<Button key="retry" onClick={() => setStep("confirm")}>
|
||||
重试
|
||||
</Button>,
|
||||
<Button key="close" onClick={handleClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const currentStepIndex = [
|
||||
"select",
|
||||
"configure",
|
||||
"confirm",
|
||||
"deploying",
|
||||
"success",
|
||||
"error",
|
||||
].indexOf(step);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<CloudUploadOutlined />
|
||||
部署文件
|
||||
</Space>
|
||||
}
|
||||
open={open}
|
||||
onCancel={step === "deploying" ? undefined : handleClose}
|
||||
width={640}
|
||||
footer={
|
||||
step === "deploying" ||
|
||||
step === "success" ||
|
||||
step === "error" ? null : (
|
||||
<Space>
|
||||
<Button onClick={handleClose}>取消</Button>
|
||||
{step === "select" && (
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canProceedToConfigure}
|
||||
onClick={() => setStep("configure")}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
{step === "configure" && (
|
||||
<>
|
||||
<Button onClick={() => setStep("select")}>上一步</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canProceedToConfirm}
|
||||
onClick={() => setStep("confirm")}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<>
|
||||
<Button onClick={() => setStep("configure")}>上一步</Button>
|
||||
<Button type="primary" onClick={handleDeploy}>
|
||||
确认部署
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
destroyOnClose
|
||||
maskClosable={false}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
{step !== "success" && step !== "error" && step !== "deploying" && (
|
||||
<Steps
|
||||
size="small"
|
||||
current={currentStepIndex}
|
||||
items={[
|
||||
{ title: "选择文件" },
|
||||
{ title: "配置位置" },
|
||||
{ title: "确认部署" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeployDialog;
|
||||
6
src/renderer/src/components/DeployDialog/index.ts
Normal file
6
src/renderer/src/components/DeployDialog/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* DeployDialog Components
|
||||
* Export all deploy dialog components
|
||||
*/
|
||||
|
||||
export { default as DeployDialog } from "./DeployDialog";
|
||||
@@ -4,13 +4,27 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Input, InputPassword, Modal } from "@lobehub/ui";
|
||||
import { Form } from "antd";
|
||||
import { Modal, Form, Input, Select, Button, Space, message } from "antd";
|
||||
import { createStyles } from "antd-style";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useTheme } from "antd-style";
|
||||
import type { Domain } from "@renderer/types/domain";
|
||||
import type {
|
||||
CreateDomainParams,
|
||||
UpdateDomainParams,
|
||||
} from "@renderer/types/ipc";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
form: css`
|
||||
.ant-form-item {
|
||||
margin-bottom: ${token.marginMD}px;
|
||||
}
|
||||
`,
|
||||
passwordHint: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
margin-top: ${token.marginXXS}px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface DomainFormProps {
|
||||
open: boolean;
|
||||
@@ -18,213 +32,49 @@ interface DomainFormProps {
|
||||
domainId: string | null;
|
||||
}
|
||||
|
||||
// Connection test result state
|
||||
type TestResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
// Test connection parameters type
|
||||
export type TestConnectionParams = {
|
||||
domain: string;
|
||||
username: string;
|
||||
password: string;
|
||||
processedDomain: string;
|
||||
};
|
||||
|
||||
// Create error type
|
||||
type CreateErrorType = "connection" | "duplicate" | "unknown" | null;
|
||||
|
||||
const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
const { t } = useTranslation("domain");
|
||||
const token = useTheme();
|
||||
const { styles } = useStyles();
|
||||
const [form] = Form.useForm();
|
||||
const { domains, createDomain, updateDomainById } = useDomainStore();
|
||||
const { domains, createDomain, updateDomainById, loading } = useDomainStore();
|
||||
|
||||
const isEdit = !!domainId;
|
||||
const editingDomain = domainId ? domains.find((d) => d.id === domainId) : null;
|
||||
|
||||
// Test connection state
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [testResult, setTestResult] = React.useState<TestResult>(null);
|
||||
|
||||
// Submit state (separate from testing)
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
// Create error state
|
||||
const [createError, setCreateError] = React.useState<{
|
||||
type: CreateErrorType;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const editingDomain = domainId
|
||||
? domains.find((d) => d.id === domainId)
|
||||
: null;
|
||||
|
||||
// Reset form when dialog opens
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Reset states
|
||||
setTestResult(null);
|
||||
setCreateError(null);
|
||||
setSubmitting(false);
|
||||
setTesting(false);
|
||||
|
||||
if (editingDomain) {
|
||||
form.setFieldsValue({
|
||||
name: editingDomain.name,
|
||||
domain: editingDomain.domain,
|
||||
username: editingDomain.username,
|
||||
authType: editingDomain.authType,
|
||||
apiToken: editingDomain.apiToken || "",
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
name: "",
|
||||
domain: "https://alicorn.cybozu.com",
|
||||
username: "maxz",
|
||||
password: "7ld7i8vd",
|
||||
});
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ authType: "password" });
|
||||
}
|
||||
}
|
||||
}, [open, editingDomain, form]);
|
||||
|
||||
// Clear test result when form values change
|
||||
const handleFieldChange = () => {
|
||||
if (testResult) {
|
||||
setTestResult(null);
|
||||
}
|
||||
if (createError) {
|
||||
setCreateError(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Process domain: remove protocol prefix and trailing slashes
|
||||
*/
|
||||
const processDomain = (domain: string): string => {
|
||||
let processed = domain.trim();
|
||||
if (processed.startsWith("https://")) {
|
||||
processed = processed.slice(8);
|
||||
} else if (processed.startsWith("http://")) {
|
||||
processed = processed.slice(7);
|
||||
}
|
||||
return processed.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate domain format
|
||||
*/
|
||||
const validateDomainFormat = (domain: string): boolean => {
|
||||
return /^[\w.-]+$/.test(domain);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get form values for domain connection test
|
||||
*/
|
||||
const getConnectionParams = async (): Promise<TestConnectionParams | null> => {
|
||||
try {
|
||||
const values = await form.validateFields(["domain", "username", "password"]);
|
||||
|
||||
const processedDomain = processDomain(values.domain);
|
||||
|
||||
if (!validateDomainFormat(processedDomain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
domain: values.domain,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
processedDomain,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Test connection with current form values
|
||||
*/
|
||||
const testConnection = async (params: TestConnectionParams): Promise<TestResult> => {
|
||||
if (!params) {
|
||||
return { success: false, message: t("validDomainRequired") };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.api.testDomainConnection({
|
||||
domain: params.processedDomain,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, message: t("connectionSuccess") };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: result.error || t("connectionFailed"),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : t("connectionFailed"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle test connection button click
|
||||
*/
|
||||
const handleTestConnection = async () => {
|
||||
// Clear previous result
|
||||
setTestResult(null);
|
||||
setCreateError(null);
|
||||
|
||||
const params = await getConnectionParams();
|
||||
if (!params) return;
|
||||
|
||||
setTesting(true);
|
||||
const result = await testConnection(params);
|
||||
setTestResult(result);
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
const showError = (type: CreateErrorType, message: string) => {
|
||||
setCreateError({
|
||||
type,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const processedDomain = processDomain(values.domain);
|
||||
const name = values.name?.trim() || processedDomain;
|
||||
|
||||
// Clear previous error
|
||||
setCreateError(null);
|
||||
|
||||
// For new domains, test connection first
|
||||
if (!isEdit) {
|
||||
setSubmitting(true);
|
||||
const testResult = await testConnection({
|
||||
domain: values.domain,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
processedDomain,
|
||||
});
|
||||
|
||||
if (!testResult?.success) {
|
||||
showError("connection", t("createConnectionFailed"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setSubmitting(true);
|
||||
// Process domain: remove protocol prefix and trailing slashes
|
||||
let processedDomain = values.domain.trim();
|
||||
if (processedDomain.startsWith("https://")) {
|
||||
processedDomain = processedDomain.slice(8);
|
||||
} else if (processedDomain.startsWith("http://")) {
|
||||
processedDomain = processedDomain.slice(7);
|
||||
}
|
||||
processedDomain = processedDomain.replace(/\/+$/, "");
|
||||
|
||||
// Use domain as name if name is empty
|
||||
const name = values.name?.trim() || processedDomain;
|
||||
|
||||
if (isEdit && editingDomain) {
|
||||
const params: UpdateDomainParams = {
|
||||
@@ -232,96 +82,132 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
name,
|
||||
domain: processedDomain,
|
||||
username: values.username,
|
||||
authType: values.authType,
|
||||
apiToken:
|
||||
values.authType === "api_token" ? values.apiToken : undefined,
|
||||
};
|
||||
|
||||
// Only include password if provided
|
||||
if (values.password) {
|
||||
params.password = values.password;
|
||||
}
|
||||
|
||||
const success = await updateDomainById(params);
|
||||
setSubmitting(false);
|
||||
if (success) {
|
||||
message.success("Domain 更新成功");
|
||||
onClose();
|
||||
} else {
|
||||
showError("unknown", t("updateFailed"));
|
||||
message.error("更新失败");
|
||||
}
|
||||
} else {
|
||||
// Check for duplicate before creating
|
||||
const existingDomain = domains.find(
|
||||
(d) => d.domain.toLowerCase() === processedDomain.toLowerCase() && d.username.toLowerCase() === values.username.toLowerCase()
|
||||
);
|
||||
|
||||
if (existingDomain) {
|
||||
showError("duplicate", t("domainDuplicate"));
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const params: CreateDomainParams = {
|
||||
name,
|
||||
domain: processedDomain,
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
authType: values.authType,
|
||||
apiToken:
|
||||
values.authType === "api_token" ? values.apiToken : undefined,
|
||||
};
|
||||
|
||||
const success = await createDomain(params);
|
||||
setSubmitting(false);
|
||||
if (success) {
|
||||
message.success("Domain 创建成功");
|
||||
onClose();
|
||||
} else {
|
||||
showError("unknown", t("createFailed"));
|
||||
message.error("创建失败");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitting(false);
|
||||
console.error("Form validation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render test button with result icon inside
|
||||
*/
|
||||
const renderTestButton = () => {
|
||||
const getIcon = () => {
|
||||
if (!testResult) return undefined;
|
||||
return testResult.success ? <CheckCircle2 size={16} color={token.colorSuccess} /> : <XCircle size={16} color={token.colorError} />;
|
||||
};
|
||||
const authType = Form.useWatch("authType", form);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Button onClick={handleTestConnection} loading={testing} icon={getIcon()}>
|
||||
{t("testConnection")}
|
||||
</Button>
|
||||
);
|
||||
// Test connection with current form values
|
||||
const handleTestConnection = async () => {
|
||||
try {
|
||||
const values = await form.validateFields([
|
||||
"domain",
|
||||
"username",
|
||||
"authType",
|
||||
"password",
|
||||
"apiToken",
|
||||
]);
|
||||
|
||||
// Process domain
|
||||
let processedDomain = values.domain.trim();
|
||||
if (processedDomain.startsWith("https://")) {
|
||||
processedDomain = processedDomain.slice(8);
|
||||
} else if (processedDomain.startsWith("http://")) {
|
||||
processedDomain = processedDomain.slice(7);
|
||||
}
|
||||
processedDomain = processedDomain.replace(/\/+$/, "");
|
||||
|
||||
setTesting(true);
|
||||
const result = await window.api.testDomainConnection({
|
||||
domain: processedDomain,
|
||||
username: values.username,
|
||||
authType: values.authType,
|
||||
password: values.authType === "password" ? values.password : undefined,
|
||||
apiToken: values.authType === "api_token" ? values.apiToken : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
message.success("连接成功");
|
||||
} else {
|
||||
message.error(result.error || "连接失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Test connection failed:", error);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? t("editDomain") : t("addDomain")}
|
||||
title={isEdit ? "编辑 Domain" : "添加 Domain"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={520}
|
||||
destroyOnHidden
|
||||
mask={{ closable: false }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFieldChange}>
|
||||
<Form.Item name="name" label={t("name")} style={{ marginTop: 8 }}>
|
||||
<Input placeholder={t("nameOptional")} />
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className={styles.form}
|
||||
initialValues={{ authType: "password" }}
|
||||
>
|
||||
<Form.Item name="name" label="名称">
|
||||
<Input placeholder="可选,留空则使用域名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="domain"
|
||||
label={t("kintoneDomain")}
|
||||
label="Kintone 域名"
|
||||
rules={[
|
||||
{ required: true, message: t("enterDomain") },
|
||||
{ required: true, message: "请输入域名" },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve();
|
||||
const processed = processDomain(value);
|
||||
if (validateDomainFormat(processed)) {
|
||||
// Allow https:// or http:// prefix
|
||||
let domain = value.trim();
|
||||
if (domain.startsWith("https://")) {
|
||||
domain = domain.slice(8);
|
||||
} else if (domain.startsWith("http://")) {
|
||||
domain = domain.slice(7);
|
||||
}
|
||||
// Remove trailing slashes
|
||||
domain = domain.replace(/\/+$/, "");
|
||||
// Validate domain format
|
||||
if (/^[\w.-]+$/.test(domain)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t("validDomainRequired")));
|
||||
return Promise.reject(new Error("请输入有效的域名"));
|
||||
},
|
||||
},
|
||||
]}
|
||||
@@ -329,37 +215,57 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||
<Input placeholder="https://company.kintone.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="username" label={t("username")} rules={[{ required: true, message: t("enterUsername") }]}>
|
||||
<Input placeholder={t("usernameLoginHint")} />
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[{ required: true, message: "请输入用户名" }]}
|
||||
>
|
||||
<Input placeholder="登录 Kintone 的用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" label={t("password")} rules={isEdit ? [] : [{ required: true, message: t("enterPassword") }]}>
|
||||
<InputPassword placeholder={isEdit ? t("keepPasswordHint") : t("enterPassword")} />
|
||||
<Form.Item
|
||||
name="authType"
|
||||
label="认证方式"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="password">密码认证</Select.Option>
|
||||
<Select.Option value="api_token">API Token 认证</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 32, marginBottom: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
}}
|
||||
{authType === "password" && (
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={isEdit ? [] : [{ required: true, message: "请输入密码" }]}
|
||||
>
|
||||
{/* Left side: Cancel button and error message */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Button onClick={onClose}>{t("cancel", { ns: "common" })}</Button>
|
||||
</div>
|
||||
<Input.Password
|
||||
placeholder={isEdit ? "留空则保持原密码" : "请输入密码"}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Right side: Test button and Create/Update button */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{createError && <span style={{ color: token.colorError, fontSize: 14 }}>{createError.message}</span>}
|
||||
{renderTestButton()}
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
||||
{isEdit ? t("update") : t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{authType === "api_token" && (
|
||||
<Form.Item
|
||||
name="apiToken"
|
||||
label="API Token"
|
||||
rules={[{ required: true, message: "请输入 API Token" }]}
|
||||
>
|
||||
<Input placeholder="从 Kintone 设置中获取 API Token" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}>
|
||||
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleTestConnection} loading={testing}>
|
||||
测试连接
|
||||
</Button>
|
||||
<Button type="primary" onClick={handleSubmit} loading={loading}>
|
||||
{isEdit ? "更新" : "创建"}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,92 +1,59 @@
|
||||
/**
|
||||
* DomainList Component
|
||||
* Displays list of domains with drag-to-reorder functionality
|
||||
* Displays list of domains with connection status
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popconfirm, Space } from "antd";
|
||||
import { Button, SortableList, Tooltip } from "@lobehub/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { List, Avatar, Tag, Button, Popconfirm, Space, Tooltip } from "antd";
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
QuestionCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck";
|
||||
import type { Domain } from "@shared/types/domain";
|
||||
import type { Domain } from "@renderer/types/domain";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
itemWrapper: css`
|
||||
width: 100%;
|
||||
padding: ${token.paddingXS}px ${token.paddingSM}px;
|
||||
item: css`
|
||||
padding: ${token.paddingMD}px;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
background: ${token.colorBgContainer};
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
margin-bottom: ${token.marginSM}px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: ${token.colorPrimary};
|
||||
box-shadow: ${token.boxShadowSecondary};
|
||||
}
|
||||
|
||||
&:hover .domain-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
|
||||
selectedItem: css`
|
||||
border-color: ${token.colorPrimary};
|
||||
background: ${token.colorPrimaryBg};
|
||||
`,
|
||||
|
||||
domainInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingSM}px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`,
|
||||
|
||||
domainName: css`
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
font-size: ${token.fontSizeLG}px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
domainUrl: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
|
||||
actions: css`
|
||||
position: absolute;
|
||||
right: ${token.paddingXXS}px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
gap: ${token.paddingXS}px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
background: ${token.colorBgContainer};
|
||||
border-radius: ${token.borderRadiusSM}px;
|
||||
padding: 2px;
|
||||
box-shadow: ${token.boxShadowSecondary};
|
||||
`,
|
||||
|
||||
itemContent: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingXS}px;
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
domainText: css`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
statusTag: css`
|
||||
margin-left: ${token.paddingSM}px;
|
||||
`,
|
||||
}));
|
||||
|
||||
@@ -95,20 +62,17 @@ interface DomainListProps {
|
||||
}
|
||||
|
||||
const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||
const { t } = useTranslation("domain");
|
||||
const { styles } = useStyles();
|
||||
const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } = useDomainStore();
|
||||
const { checkAndConfirmDomainSwitch } = usePendingChangesCheck();
|
||||
const {
|
||||
domains,
|
||||
currentDomain,
|
||||
connectionStatuses,
|
||||
switchDomain,
|
||||
deleteDomain,
|
||||
testConnection,
|
||||
} = useDomainStore();
|
||||
|
||||
const handleSelect = async (domain: Domain) => {
|
||||
if (currentDomain?.id === domain.id) {
|
||||
return;
|
||||
}
|
||||
// Check for pending changes before switching domain
|
||||
const confirmed = await checkAndConfirmDomainSwitch(domain.id);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const handleSelect = (domain: Domain) => {
|
||||
switchDomain(domain);
|
||||
};
|
||||
|
||||
@@ -116,80 +80,115 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||
await deleteDomain(id);
|
||||
};
|
||||
|
||||
// Handle reorder - convert SortableListItem[] back to reorder action
|
||||
const handleSortChange = (newItems: { id: string }[]) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
const oldOrder = domains.map((d) => d.id);
|
||||
|
||||
// Find the element that was moved: its position changed from old to new
|
||||
// When item at oldIndex moves to newIndex, oldOrder[newIndex] !== newOrder[newIndex]
|
||||
for (let i = 0; i < newOrder.length; i++) {
|
||||
if (oldOrder[i] !== newOrder[i]) {
|
||||
// The item at position i in newOrder came from somewhere in oldOrder
|
||||
const movedItemId = newOrder[i];
|
||||
const fromIndex = oldOrder.indexOf(movedItemId);
|
||||
const toIndex = i;
|
||||
reorderDomains(fromIndex, toIndex);
|
||||
break;
|
||||
}
|
||||
const getStatusIcon = (id: string) => {
|
||||
const status = connectionStatuses[id];
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <CheckCircleOutlined style={{ color: "#52c41a" }} />;
|
||||
case "error":
|
||||
return <CloseCircleOutlined style={{ color: "#ff4d4f" }} />;
|
||||
default:
|
||||
return <QuestionCircleOutlined style={{ color: "#faad14" }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = (item: { id: string }) => {
|
||||
const domain = domains.find((d) => d.id === item.id);
|
||||
if (!domain) return null;
|
||||
|
||||
const isSelected = currentDomain?.id === domain.id;
|
||||
|
||||
return (
|
||||
<SortableList.Item id={domain.id}>
|
||||
<div className={`${styles.itemWrapper} ${isSelected ? styles.selectedItem : ""}`} onClick={() => handleSelect(domain)}>
|
||||
<div className={styles.itemContent}>
|
||||
<SortableList.DragHandle />
|
||||
<div className={styles.domainInfo}>
|
||||
<div className={styles.domainText}>
|
||||
<div className={styles.domainName}>{domain.name}</div>
|
||||
<div className={styles.domainUrl}>
|
||||
{domain.username} · {domain.domain}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space className={"domain-item-actions " + styles.actions}>
|
||||
<Tooltip title={t("edit")}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Pencil size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(domain.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={t("confirmDelete")}
|
||||
description={t("confirmDeleteDesc")}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete(domain.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText={t("delete", { ns: "common" })}
|
||||
cancelText={t("cancel", { ns: "common" })}
|
||||
>
|
||||
<Tooltip title={t("delete", { ns: "common" })}>
|
||||
<Button type="text" size="small" danger icon={<Trash2 size={16} />} onClick={(e) => e.stopPropagation()} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</SortableList.Item>
|
||||
);
|
||||
const getStatusTag = (id: string) => {
|
||||
const status = connectionStatuses[id];
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return <Tag color="success">已连接</Tag>;
|
||||
case "error":
|
||||
return <Tag color="error">连接失败</Tag>;
|
||||
default:
|
||||
return <Tag color="warning">未检测</Tag>;
|
||||
}
|
||||
};
|
||||
|
||||
return <SortableList items={domains} renderItem={renderItem} onChange={handleSortChange} gap={4} />;
|
||||
return (
|
||||
<List
|
||||
dataSource={domains}
|
||||
renderItem={(domain) => {
|
||||
const isSelected = currentDomain?.id === domain.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.item} ${isSelected ? styles.selectedItem : ""}`}
|
||||
onClick={() => handleSelect(domain)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div className={styles.domainInfo}>
|
||||
<Avatar
|
||||
icon={<CloudServerOutlined />}
|
||||
style={{
|
||||
backgroundColor: isSelected ? "#1890ff" : "#87d068",
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className={styles.domainName}>
|
||||
{domain.name}
|
||||
{getStatusTag(domain.id)}
|
||||
</div>
|
||||
<div className={styles.domainUrl}>
|
||||
{domain.domain} · {domain.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Tooltip title="测试连接">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={getStatusIcon(domain.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
testConnection(domain.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(domain.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除此 Domain 配置吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDelete(domain.id);
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DomainList;
|
||||
|
||||
@@ -1,61 +1,45 @@
|
||||
/**
|
||||
* DomainManager Component
|
||||
* Main container for domain management
|
||||
* Supports collapsed/expanded view
|
||||
* Expand/collapse triggered by clicking header chevron
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Spin } from "antd";
|
||||
import { Button, Tooltip, Avatar, Empty, Block } from "@lobehub/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Building, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { Button, Empty, Spin } from "antd";
|
||||
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import DomainList from "./DomainList";
|
||||
import DomainForm from "./DomainForm";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
wrapper: css`
|
||||
height: 100%;
|
||||
margin: 0 ${token.paddingSM}px;
|
||||
`,
|
||||
container: css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${token.colorFillSecondary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
padding: ${token.paddingSM}px;
|
||||
padding: ${token.paddingLG}px;
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
header: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: ${token.paddingXXS}px ${token.paddingSM}px;
|
||||
`,
|
||||
headerLeft: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
padding-right: ${token.paddingSM}px;
|
||||
`,
|
||||
headerRight: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.paddingXS}px;
|
||||
margin-bottom: ${token.marginLG}px;
|
||||
padding-bottom: ${token.paddingMD}px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
title: css`
|
||||
font-size: ${token.fontSize}px;
|
||||
font-size: ${token.fontSizeHeading4}px;
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
color: ${token.colorText};
|
||||
margin: 0;
|
||||
`,
|
||||
actions: css`
|
||||
display: flex;
|
||||
gap: ${token.paddingXS}px;
|
||||
`,
|
||||
content: css`
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0 ${token.paddingXXS}px;
|
||||
`,
|
||||
loading: css`
|
||||
display: flex;
|
||||
@@ -63,57 +47,11 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
`,
|
||||
collapsedName: css`
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
font-size: ${token.fontSize}px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
collapsedDesc: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
noDomainText: css`
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
`,
|
||||
collapsedBlock: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
`,
|
||||
collapsedInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`,
|
||||
collapsedText: css`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`,
|
||||
collapsedIcon: css`
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface DomainManagerProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
const DomainManager: React.FC<DomainManagerProps> = ({ collapsed = false, onToggleCollapse }) => {
|
||||
const { t } = useTranslation("domain");
|
||||
const DomainManager: React.FC = () => {
|
||||
const { styles } = useStyles();
|
||||
const { domains, loading, loadDomains, currentDomain } = useDomainStore();
|
||||
const { domains, loading, loadDomains } = useDomainStore();
|
||||
const [formOpen, setFormOpen] = React.useState(false);
|
||||
const [editingDomain, setEditingDomain] = React.useState<string | null>(null);
|
||||
|
||||
@@ -136,83 +74,53 @@ const DomainManager: React.FC<DomainManagerProps> = ({ collapsed = false, onTogg
|
||||
setEditingDomain(null);
|
||||
};
|
||||
|
||||
// Collapsed view - show current domain only
|
||||
if (collapsed) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.wrapper}>
|
||||
<Block direction="horizontal" variant="filled" clickable onClick={onToggleCollapse} className={styles.collapsedBlock}>
|
||||
<div className={styles.collapsedInfo}>
|
||||
<Avatar size={36} className={styles.collapsedIcon} icon={<Building size={18} />} />
|
||||
<div className={styles.collapsedText}>
|
||||
{currentDomain ? (
|
||||
<>
|
||||
<div className={styles.collapsedName}>{currentDomain.name}</div>
|
||||
<div className={styles.collapsedDesc}>
|
||||
{currentDomain.username} ·{" "}
|
||||
<a target="_blank" rel="noreferrer" href={"https://" + currentDomain.domain} onClick={(e) => e.stopPropagation()}>
|
||||
{currentDomain.domain}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.noDomainText}>{t("noDomainSelected")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip title={t("expand")}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ChevronDown size={18} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleCollapse?.();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Block>
|
||||
</div>
|
||||
const handleRefresh = () => {
|
||||
loadDomains();
|
||||
};
|
||||
|
||||
<DomainForm open={formOpen} onClose={handleCloseForm} domainId={editingDomain} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded view - full list
|
||||
return (
|
||||
<>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<Tooltip title={t("collapse")} placement="topRight">
|
||||
<div className={styles.headerLeft} onClick={onToggleCollapse}>
|
||||
<h3 className={styles.title}>{t("domainManagement")}</h3>
|
||||
<ChevronUp size={16} style={{ opacity: 0.5 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className={styles.headerRight}>
|
||||
<Tooltip title={t("addDomain")}>
|
||||
<Button type="primary" size="small" icon={<Plus size={16} />} onClick={handleAdd} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{loading && domains.length === 0 ? (
|
||||
<div className={styles.loading}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : domains.length === 0 ? (
|
||||
<Empty description={t("noDomainConfig")}></Empty>
|
||||
) : (
|
||||
<DomainList onEdit={handleEdit} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h2 className={styles.title}>Domain 管理</h2>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
添加 Domain
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DomainForm open={formOpen} onClose={handleCloseForm} domainId={editingDomain} />
|
||||
</>
|
||||
|
||||
<div className={styles.content}>
|
||||
{loading && domains.length === 0 ? (
|
||||
<div className={styles.loading}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : domains.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无 Domain 配置"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" onClick={handleAdd}>
|
||||
添加第一个 Domain
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<DomainList onEdit={handleEdit} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DomainForm
|
||||
open={formOpen}
|
||||
onClose={handleCloseForm}
|
||||
domainId={editingDomain}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,17 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Upload, List, Space, Tag, message, Popconfirm } from "antd";
|
||||
import { Button } from "@lobehub/ui";
|
||||
import { Inbox, Trash2, File, Code } from "lucide-react";
|
||||
import { Upload, Button, List, Space, Tag, message, Popconfirm } from "antd";
|
||||
import {
|
||||
InboxOutlined,
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
FileOutlined,
|
||||
CodeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import type { DeployFile } from "@shared/types/ipc";
|
||||
import type { UploadFile, UploadProps } from "antd";
|
||||
import type { DeployFile } from "@renderer/types/ipc";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
@@ -58,20 +63,19 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
onChange,
|
||||
maxFileSize = 10 * 1024 * 1024, // 10MB
|
||||
}) => {
|
||||
const { t } = useTranslation("file");
|
||||
const { styles } = useStyles();
|
||||
|
||||
const handleBeforeUpload = (file: File) => {
|
||||
const handleBeforeUpload: UploadProps["beforeUpload"] = (file) => {
|
||||
// Check file type
|
||||
const isJsOrCss = file.name.endsWith(".js") || file.name.endsWith(".css");
|
||||
if (!isJsOrCss) {
|
||||
message.error(t("onlyJsCss"));
|
||||
message.error("只支持 .js 和 .css 文件");
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > maxFileSize) {
|
||||
message.error(t("fileSizeLimit", { size: maxFileSize / 1024 / 1024 }));
|
||||
message.error(`文件大小不能超过 ${maxFileSize / 1024 / 1024}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
@@ -116,12 +120,20 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Dragger className={styles.dragger} beforeUpload={handleBeforeUpload} showUploadList={false} multiple accept=".js,.css">
|
||||
<Dragger
|
||||
className={styles.dragger}
|
||||
beforeUpload={handleBeforeUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept=".js,.css"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<Inbox size={24} />
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 .js 和 .css 文件,单个文件最大 {maxFileSize / 1024 / 1024}MB
|
||||
</p>
|
||||
<p className="ant-upload-text">{t("clickOrDragToUpload")}</p>
|
||||
<p className="ant-upload-hint">{t("supportFiles", { size: maxFileSize / 1024 / 1024 })}</p>
|
||||
</Dragger>
|
||||
|
||||
{files.length > 0 && (
|
||||
@@ -134,16 +146,16 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span>{t("selectedFiles", { count: files.length })}</span>
|
||||
<span>已选择 {files.length} 个文件</span>
|
||||
<Popconfirm
|
||||
title={t("confirmClear")}
|
||||
description={t("confirmClearDesc")}
|
||||
title="确认清空"
|
||||
description="确定要清空所有文件吗?"
|
||||
onConfirm={handleClear}
|
||||
okText={t("clearAll")}
|
||||
cancelText={t("cancel", { ns: "common" })}
|
||||
okText="清空"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button size="small" danger>
|
||||
{t("clearAll")}
|
||||
清空全部
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
@@ -153,18 +165,31 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
renderItem={(file, index) => (
|
||||
<div className={styles.fileItem}>
|
||||
<div className={styles.fileInfo}>
|
||||
<File size={20} style={{ color: getFileTypeColor(file.fileType) }} />
|
||||
<FileOutlined
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: getFileTypeColor(file.fileType),
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div className={styles.fileName}>{file.fileName}</div>
|
||||
<div className={styles.fileSize}>
|
||||
{formatFileSize(new Blob([file.content]).size)}
|
||||
<Tag color={file.fileType === "js" ? "gold" : "blue"} style={{ marginLeft: 8 }}>
|
||||
<Tag
|
||||
color={file.fileType === "js" ? "gold" : "blue"}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{file.fileType.toUpperCase()}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="text" danger icon={<Trash2 size={16} />} onClick={() => handleRemove(index)} />
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemove(index)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Settings Component
|
||||
* Application settings modal with language switcher and theme selector
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography, Radio, Divider, Space, message } from "antd";
|
||||
import { Globe, Info, ExternalLink, Sun, Moon, Monitor } from "lucide-react";
|
||||
import { Button } from "@lobehub/ui";
|
||||
|
||||
import { createStyles } from "antd-style";
|
||||
import { useLocaleStore } from "@renderer/stores/localeStore";
|
||||
import { useThemeStore, type ThemeMode } from "@renderer/stores/themeStore";
|
||||
import { LOCALES, type LocaleCode } from "@shared/types/locale";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
padding: ${token.paddingLG}px;
|
||||
max-width: 600px;
|
||||
`,
|
||||
section: css`
|
||||
margin-bottom: ${token.marginLG}px;
|
||||
`,
|
||||
sectionTitle: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.marginXS}px;
|
||||
margin-bottom: ${token.marginMD}px;
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
radioGroup: css`
|
||||
display: flex;
|
||||
gap: ${token.marginSM}px;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
border-radius: ${token.borderRadius}px;
|
||||
border: 1px solid ${token.colorBorder};
|
||||
color: ${token.colorText};
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-radio-button-wrapper-checked {
|
||||
background: ${token.colorPrimary} !important;
|
||||
border-color: ${token.colorPrimary} !important;
|
||||
color: ${token.colorBgContainer} !important;
|
||||
}
|
||||
`,
|
||||
versionInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.marginSM}px;
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: 14px;
|
||||
`,
|
||||
versionNumber: css`
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
`,
|
||||
updateInfo: css`
|
||||
margin-top: ${token.marginSM}px;
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
background: ${token.colorInfoBg};
|
||||
border-radius: ${token.borderRadius}px;
|
||||
font-size: 13px;
|
||||
`,
|
||||
updateAvailable: css`
|
||||
background: ${token.colorSuccessBg};
|
||||
color: ${token.colorSuccessText};
|
||||
`,
|
||||
}));
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: ThemeMode;
|
||||
labelKey: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{ value: "light", labelKey: "lightTheme", icon: <Sun size={14} /> },
|
||||
{ value: "dark", labelKey: "darkTheme", icon: <Moon size={14} /> },
|
||||
{ value: "auto", labelKey: "systemTheme", icon: <Monitor size={14} /> },
|
||||
];
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { styles } = useStyles();
|
||||
const { locale, setLocale } = useLocaleStore();
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
const i18n = useTranslation().i18n;
|
||||
|
||||
// Version and update state
|
||||
const [appVersion, setAppVersion] = React.useState<string>("");
|
||||
const [checkingUpdate, setCheckingUpdate] = React.useState(false);
|
||||
const [updateInfo, setUpdateInfo] = React.useState<{
|
||||
hasUpdate: boolean;
|
||||
version?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Load app version on mount
|
||||
React.useEffect(() => {
|
||||
const loadVersion = async () => {
|
||||
const result = await window.api.getAppVersion();
|
||||
if (result.success) {
|
||||
setAppVersion(result.data);
|
||||
}
|
||||
};
|
||||
loadVersion();
|
||||
}, []);
|
||||
|
||||
const handleLocaleChange = async (newLocale: LocaleCode) => {
|
||||
setLocale(newLocale);
|
||||
i18n.changeLanguage(newLocale);
|
||||
// Sync locale to main process
|
||||
await window.api.setLocale({ locale: newLocale });
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: ThemeMode) => {
|
||||
setThemeMode(newTheme);
|
||||
};
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setCheckingUpdate(true);
|
||||
setUpdateInfo(null);
|
||||
|
||||
try {
|
||||
const result = await window.api.checkForUpdates();
|
||||
if (result.success) {
|
||||
setUpdateInfo({
|
||||
hasUpdate: result.data.hasUpdate,
|
||||
version: result.data.updateInfo?.version,
|
||||
});
|
||||
} else {
|
||||
message.error(t("checkUpdateFailed"));
|
||||
}
|
||||
} catch {
|
||||
message.error(t("checkUpdateFailed"));
|
||||
} finally {
|
||||
setCheckingUpdate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenReleasePage = () => {
|
||||
// Open GitHub releases page (or configured update URL)
|
||||
const releaseUrl = "https://github.com/example/kintone-customize-manager/releases";
|
||||
window.electron?.shell?.openExternal?.(releaseUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Language Section */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<Globe size={16} />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{t("language")}
|
||||
</Title>
|
||||
</div>
|
||||
<Radio.Group value={locale} onChange={(e) => handleLocaleChange(e.target.value)} optionType="button" buttonStyle="solid" className={styles.radioGroup}>
|
||||
{LOCALES.map((localeConfig) => (
|
||||
<Radio.Button key={localeConfig.code} value={localeConfig.code}>
|
||||
{localeConfig.nativeName}
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Theme Section */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<Monitor size={16} />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{t("theme")}
|
||||
</Title>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={themeMode}
|
||||
onChange={(e) => handleThemeChange(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<Radio.Button key={option.value} value={option.value}>
|
||||
<Space size={4}>
|
||||
{option.icon}
|
||||
{t(option.labelKey)}
|
||||
</Space>
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* About Section */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<Info size={16} />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{t("about")}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<div className={styles.versionInfo}>
|
||||
<span>{t("version")}:</span>
|
||||
<span className={styles.versionNumber}>{appVersion || "-"}</span>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%", marginTop: 12 }}>
|
||||
<Button type="primary" icon={<ExternalLink size={14} />} onClick={handleCheckUpdate} loading={checkingUpdate}>
|
||||
{t("checkUpdate")}
|
||||
</Button>
|
||||
|
||||
{updateInfo && (
|
||||
<div className={`${styles.updateInfo} ${updateInfo.hasUpdate ? styles.updateAvailable : ""}`}>
|
||||
{updateInfo.hasUpdate ? (
|
||||
<Space direction="vertical" size={4}>
|
||||
<span>
|
||||
{t("updateAvailable")}: v{updateInfo.version}
|
||||
</span>
|
||||
<Button type="link" size="small" icon={<ExternalLink size={12} />} onClick={handleOpenReleasePage} style={{ padding: 0, height: "auto" }}>
|
||||
{t("downloadUpdate")}
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<span>{t("noUpdates")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Settings Components
|
||||
* Export all settings components
|
||||
*/
|
||||
|
||||
export { default as Settings } from "./Settings";
|
||||
228
src/renderer/src/components/SpaceTree/SpaceTree.tsx
Normal file
228
src/renderer/src/components/SpaceTree/SpaceTree.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* SpaceTree Component
|
||||
* Tree view for browsing Spaces and Apps
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Tree, Input, Empty, Spin, Badge } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
AppstoreOutlined,
|
||||
FolderOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import type { TreeDataNode, TreeProps } from "antd";
|
||||
import { useAppStore } from "@renderer/stores";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import type { KintoneSpace, KintoneApp } from "@renderer/types/kintone";
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
search: css`
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
tree: css`
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: ${token.paddingSM}px;
|
||||
`,
|
||||
loading: css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
`,
|
||||
empty: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const SpaceTree: React.FC = () => {
|
||||
const { styles } = useStyles();
|
||||
const [searchText, setSearchText] = React.useState("");
|
||||
const [expandedKeys, setExpandedKeys] = React.useState<React.Key[]>([]);
|
||||
|
||||
const { currentDomain } = useDomainStore();
|
||||
const {
|
||||
spaces,
|
||||
apps,
|
||||
loading,
|
||||
setSpaces,
|
||||
setApps,
|
||||
setCurrentSpace,
|
||||
setSelectedAppId,
|
||||
clear,
|
||||
} = useAppStore();
|
||||
|
||||
// Load spaces and apps when domain changes
|
||||
React.useEffect(() => {
|
||||
if (currentDomain) {
|
||||
loadSpacesAndApps();
|
||||
} else {
|
||||
clear();
|
||||
}
|
||||
}, [currentDomain]);
|
||||
|
||||
const loadSpacesAndApps = async () => {
|
||||
if (!currentDomain) return;
|
||||
|
||||
try {
|
||||
// Load spaces
|
||||
const spacesResult = await window.api.getSpaces({
|
||||
domainId: currentDomain.id,
|
||||
});
|
||||
if (spacesResult.success) {
|
||||
setSpaces(spacesResult.data);
|
||||
|
||||
// Load apps for each space
|
||||
const allApps: KintoneApp[] = [];
|
||||
for (const space of spacesResult.data) {
|
||||
const appsResult = await window.api.getApps({
|
||||
domainId: currentDomain.id,
|
||||
spaceId: space.id,
|
||||
});
|
||||
if (appsResult.success) {
|
||||
allApps.push(...appsResult.data);
|
||||
}
|
||||
}
|
||||
setApps(allApps);
|
||||
|
||||
// Expand all spaces by default
|
||||
setExpandedKeys(spacesResult.data.map((s) => `space-${s.id}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load spaces and apps:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Build tree data
|
||||
const buildTreeData = (): TreeDataNode[] => {
|
||||
if (!currentDomain) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredApps = searchText
|
||||
? apps.filter((app) =>
|
||||
app.name.toLowerCase().includes(searchText.toLowerCase()),
|
||||
)
|
||||
: apps;
|
||||
|
||||
// Group apps by space
|
||||
const appsBySpace: Record<string, KintoneApp[]> = {};
|
||||
filteredApps.forEach((app) => {
|
||||
const spaceId = app.spaceId || "no-space";
|
||||
if (!appsBySpace[spaceId]) {
|
||||
appsBySpace[spaceId] = [];
|
||||
}
|
||||
appsBySpace[spaceId].push(app);
|
||||
});
|
||||
|
||||
// Build tree nodes
|
||||
return spaces.map((space) => ({
|
||||
key: `space-${space.id}`,
|
||||
title: (
|
||||
<span>
|
||||
<FolderOutlined style={{ marginRight: 8 }} />
|
||||
{space.name}
|
||||
<Badge
|
||||
count={appsBySpace[space.id]?.length || 0}
|
||||
style={{ marginLeft: 8 }}
|
||||
size="small"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
icon: <FolderOutlined />,
|
||||
children: (appsBySpace[space.id] || []).map((app) => ({
|
||||
key: `app-${app.appId}`,
|
||||
title: (
|
||||
<span>
|
||||
<AppstoreOutlined style={{ marginRight: 8 }} />
|
||||
{app.name}
|
||||
</span>
|
||||
),
|
||||
icon: <AppstoreOutlined />,
|
||||
isLeaf: true,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelect: TreeProps["onSelect"] = (selectedKeys, info) => {
|
||||
if (selectedKeys.length === 0) return;
|
||||
|
||||
const key = selectedKeys[0] as string;
|
||||
if (key.startsWith("space-")) {
|
||||
const spaceId = key.replace("space-", "");
|
||||
const space = spaces.find((s) => s.id === spaceId);
|
||||
if (space) {
|
||||
setCurrentSpace(space);
|
||||
}
|
||||
} else if (key.startsWith("app-")) {
|
||||
const appId = key.replace("app-", "");
|
||||
setSelectedAppId(appId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpand: TreeProps["onExpand"] = (expandedKeys) => {
|
||||
setExpandedKeys(expandedKeys);
|
||||
};
|
||||
|
||||
if (!currentDomain) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<Empty description="请先选择一个 Domain" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && spaces.length === 0) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
placeholder="搜索应用..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tree}>
|
||||
{spaces.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无 Space"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<Tree
|
||||
showIcon
|
||||
treeData={buildTreeData()}
|
||||
selectedKeys={[]}
|
||||
expandedKeys={expandedKeys}
|
||||
onSelect={handleSelect}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpaceTree;
|
||||
6
src/renderer/src/components/SpaceTree/index.ts
Normal file
6
src/renderer/src/components/SpaceTree/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* SpaceTree Components
|
||||
* Export all space tree components
|
||||
*/
|
||||
|
||||
export { default as SpaceTree } from "./SpaceTree";
|
||||
@@ -4,16 +4,32 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { List, Tag, Space, Spin, Popconfirm, Typography } from "antd";
|
||||
import { Button, Tooltip, Empty, Avatar } from "@lobehub/ui";
|
||||
|
||||
import { History, Download, Trash2, Undo2, Code, FileText } from "lucide-react";
|
||||
import {
|
||||
List,
|
||||
Avatar,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Empty,
|
||||
Spin,
|
||||
Popconfirm,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
HistoryOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined,
|
||||
RollbackOutlined,
|
||||
TagOutlined,
|
||||
CodeOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { createStyles } from "antd-style";
|
||||
import { useVersionStore } from "@renderer/stores";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import { useAppStore } from "@renderer/stores";
|
||||
import type { Version } from "@shared/types/version";
|
||||
import type { Version } from "@renderer/types/version";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -86,11 +102,11 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
}));
|
||||
|
||||
const VersionHistory: React.FC = () => {
|
||||
const { t } = useTranslation("version");
|
||||
const { styles } = useStyles();
|
||||
const { currentDomain } = useDomainStore();
|
||||
const { currentApp } = useAppStore();
|
||||
const { versions, loading, setVersions, setLoading, removeVersion } = useVersionStore();
|
||||
const { versions, loading, setVersions, setLoading, removeVersion } =
|
||||
useVersionStore();
|
||||
|
||||
// Load versions when app changes
|
||||
React.useEffect(() => {
|
||||
@@ -165,9 +181,9 @@ const VersionHistory: React.FC = () => {
|
||||
|
||||
const getSourceTag = (source: Version["source"]) => {
|
||||
const config = {
|
||||
upload: { color: "blue", text: t("sourceUpload") },
|
||||
download: { color: "green", text: t("sourceDownload") },
|
||||
rollback: { color: "orange", text: t("sourceRollback") },
|
||||
upload: { color: "blue", text: "上传" },
|
||||
download: { color: "green", text: "下载" },
|
||||
rollback: { color: "orange", text: "回滚" },
|
||||
};
|
||||
return config[source] || { color: "default", text: source };
|
||||
};
|
||||
@@ -176,7 +192,10 @@ const VersionHistory: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div style={{ padding: 24, textAlign: "center" }}>
|
||||
<Empty description={t("selectApp", { ns: "app" })} />
|
||||
<Empty
|
||||
description="请选择一个应用"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -194,18 +213,25 @@ const VersionHistory: React.FC = () => {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>
|
||||
<History size={20} />
|
||||
<Text strong>{t("title")}</Text>
|
||||
<Tag>{t("totalVersions", { count: versions.length })}</Tag>
|
||||
<HistoryOutlined style={{ fontSize: 20 }} />
|
||||
<Text strong>版本历史</Text>
|
||||
<Tag>{versions.length} 个版本</Tag>
|
||||
</div>
|
||||
<Button icon={<Download size={16} />} onClick={loadVersions} loading={loading}>
|
||||
{t("refresh", { ns: "common" })}
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={loadVersions}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
{versions.length === 0 ? (
|
||||
<Empty description={t("noVersions")} />
|
||||
<Empty
|
||||
description="暂无版本历史"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={versions}
|
||||
@@ -215,9 +241,16 @@ const VersionHistory: React.FC = () => {
|
||||
<div className={styles.versionItem}>
|
||||
<div className={styles.versionInfo}>
|
||||
<Avatar
|
||||
icon={version.fileType === "js" ? <Code size={16} /> : <FileText size={16} />}
|
||||
icon={
|
||||
version.fileType === "js" ? (
|
||||
<CodeOutlined />
|
||||
) : (
|
||||
<FileTextOutlined />
|
||||
)
|
||||
}
|
||||
style={{
|
||||
backgroundColor: version.fileType === "js" ? "#f7df1e" : "#264de4",
|
||||
backgroundColor:
|
||||
version.fileType === "js" ? "#f7df1e" : "#264de4",
|
||||
}}
|
||||
/>
|
||||
<div className={styles.versionDetails}>
|
||||
@@ -225,48 +258,75 @@ const VersionHistory: React.FC = () => {
|
||||
<div className={styles.versionMeta}>
|
||||
<Tag color={sourceTag.color}>{sourceTag.text}</Tag>
|
||||
<Tag>{version.fileType.toUpperCase()}</Tag>
|
||||
<Text type="secondary">{formatFileSize(version.size)}</Text>
|
||||
<Text type="secondary">{formatDate(version.createdAt)}</Text>
|
||||
<Text type="secondary">
|
||||
{formatFileSize(version.size)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{formatDate(version.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
{version.tags && version.tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{version.tags.map((tag, i) => (
|
||||
<Tag key={i} color="processing">
|
||||
<Tag
|
||||
key={i}
|
||||
icon={<TagOutlined />}
|
||||
color="processing"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{version.notes && <Text type="secondary">{version.notes}</Text>}
|
||||
{version.notes && (
|
||||
<Text type="secondary">{version.notes}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Tooltip title={t("viewCode")}>
|
||||
<Button type="text" size="small" icon={<Code size={16} />} />
|
||||
<Tooltip title="查看代码">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("download", { ns: "common" })}>
|
||||
<Button type="text" size="small" icon={<Download size={16} />} />
|
||||
<Tooltip title="下载">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("confirmRollback")}>
|
||||
<Tooltip title="回滚到此版本">
|
||||
<Popconfirm
|
||||
title={t("confirmRollback")}
|
||||
description={t("confirmRollbackDesc")}
|
||||
title="确认回滚"
|
||||
description="确定要回滚到此版本吗?"
|
||||
onConfirm={() => handleRollback(version)}
|
||||
okText={t("sourceRollback")}
|
||||
cancelText={t("cancel", { ns: "common" })}
|
||||
okText="回滚"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" size="small" icon={<Undo2 size={16} />} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RollbackOutlined />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={t("confirmDelete")}
|
||||
description={t("confirmDeleteDesc")}
|
||||
title="确认删除"
|
||||
description="确定要删除此版本吗?"
|
||||
onConfirm={() => handleDelete(version.id)}
|
||||
okText={t("delete", { ns: "common" })}
|
||||
cancelText={t("cancel", { ns: "common" })}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<Trash2 size={16} />} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
8
src/renderer/src/env.d.ts
vendored
8
src/renderer/src/env.d.ts
vendored
@@ -1,10 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly MAIN_VITE_API_URL: string;
|
||||
readonly MAIN_VITE_DEBUG: string;
|
||||
readonly MAIN_VITE_API_URL: string
|
||||
readonly MAIN_VITE_DEBUG: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* usePendingChangesCheck Hook
|
||||
* 检查是否有未保存的变更,并弹出确认对话框
|
||||
*/
|
||||
|
||||
import { Modal } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileChangeStore } from "@renderer/stores/fileChangeStore";
|
||||
import { useAppStore } from "@renderer/stores/appStore";
|
||||
import { useDomainStore } from "@renderer/stores/domainStore";
|
||||
|
||||
interface PendingChangesCheckResult {
|
||||
/**
|
||||
* 检查切换 app 是否有未保存的变更
|
||||
* @param targetAppId 目标 app ID
|
||||
* @returns Promise<boolean> 用户确认返回 true,取消返回 false
|
||||
*/
|
||||
checkAndConfirmAppSwitch: (targetAppId: string) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 检查切换 domain 是否有未保存的变更
|
||||
* @param targetDomainId 目标 domain ID
|
||||
* @returns Promise<boolean> 用户确认返回 true,取消返回 false
|
||||
*/
|
||||
checkAndConfirmDomainSwitch: (targetDomainId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const usePendingChangesCheck = (): PendingChangesCheckResult => {
|
||||
const { t } = useTranslation("common");
|
||||
const { hasPendingChanges, getChangeCount } = useFileChangeStore();
|
||||
const { currentDomain } = useDomainStore();
|
||||
const { selectedAppId } = useAppStore();
|
||||
|
||||
/**
|
||||
* 获取变更详情描述
|
||||
*/
|
||||
const getChangeDescription = (domainId: string, appId: string): string => {
|
||||
const changes = getChangeCount(domainId, appId);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (changes.added > 0) {
|
||||
parts.push(t("pendingChangesAdded", { count: changes.added }));
|
||||
}
|
||||
if (changes.deleted > 0) {
|
||||
parts.push(t("pendingChangesDeleted", { count: changes.deleted }));
|
||||
}
|
||||
if (changes.reordered > 0) {
|
||||
parts.push(t("pendingChangesReordered", { count: changes.reordered }));
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查并确认切换 app
|
||||
*/
|
||||
const checkAndConfirmAppSwitch = async (targetAppId: string): Promise<boolean> => {
|
||||
// 如果是同一个 app,不需要确认
|
||||
if (!currentDomain || !selectedAppId || selectedAppId === targetAppId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查当前 app 是否有未保存的变更
|
||||
if (!hasPendingChanges(currentDomain.id, selectedAppId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 有未保存的变更,弹窗确认
|
||||
const changeDesc = getChangeDescription(currentDomain.id, selectedAppId);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title: t("unsavedChangesTitle"),
|
||||
content: t("unsavedChangesAppMessage", { changes: changeDesc }),
|
||||
okText: t("confirm"),
|
||||
cancelText: t("cancel"),
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查并确认切换 domain
|
||||
*/
|
||||
const checkAndConfirmDomainSwitch = async (targetDomainId: string): Promise<boolean> => {
|
||||
// 如果是同一个 domain,不需要确认
|
||||
if (!currentDomain || currentDomain.id === targetDomainId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查当前 domain 和 app 是否有未保存的变更
|
||||
if (selectedAppId && hasPendingChanges(currentDomain.id, selectedAppId)) {
|
||||
const changeDesc = getChangeDescription(currentDomain.id, selectedAppId);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Modal.confirm({
|
||||
title: t("unsavedChangesTitle"),
|
||||
content: t("unsavedChangesDomainMessage", { changes: changeDesc }),
|
||||
okText: t("confirm"),
|
||||
cancelText: t("cancel"),
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
checkAndConfirmAppSwitch,
|
||||
checkAndConfirmDomainSwitch,
|
||||
};
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
// Import locale files statically
|
||||
import commonZHCN from "./locales/zh-CN/common.json";
|
||||
import domainZHCN from "./locales/zh-CN/domain.json";
|
||||
import settingsZHCN from "./locales/zh-CN/settings.json";
|
||||
import errorsZHCN from "./locales/zh-CN/errors.json";
|
||||
import appZHCN from "./locales/zh-CN/app.json";
|
||||
import fileZHCN from "./locales/zh-CN/file.json";
|
||||
import versionZHCN from "./locales/zh-CN/version.json";
|
||||
import commonJAJP from "./locales/ja-JP/common.json";
|
||||
import domainJAJP from "./locales/ja-JP/domain.json";
|
||||
import settingsJAJP from "./locales/ja-JP/settings.json";
|
||||
import errorsJAJP from "./locales/ja-JP/errors.json";
|
||||
import appJAJP from "./locales/ja-JP/app.json";
|
||||
import fileJAJP from "./locales/ja-JP/file.json";
|
||||
import versionJAJP from "./locales/ja-JP/version.json";
|
||||
import commonENUS from "./locales/en-US/common.json";
|
||||
import domainENUS from "./locales/en-US/domain.json";
|
||||
import settingsENUS from "./locales/en-US/settings.json";
|
||||
import errorsENUS from "./locales/en-US/errors.json";
|
||||
import appENUS from "./locales/en-US/app.json";
|
||||
import fileENUS from "./locales/en-US/file.json";
|
||||
import versionENUS from "./locales/en-US/version.json";
|
||||
// Define resources with namespaces
|
||||
const resources = {
|
||||
"zh-CN": {
|
||||
common: commonZHCN,
|
||||
domain: domainZHCN,
|
||||
settings: settingsZHCN,
|
||||
errors: errorsZHCN,
|
||||
app: appZHCN,
|
||||
file: fileZHCN,
|
||||
version: versionZHCN,
|
||||
},
|
||||
"ja-JP": {
|
||||
common: commonJAJP,
|
||||
domain: domainJAJP,
|
||||
settings: settingsJAJP,
|
||||
errors: errorsJAJP,
|
||||
app: appJAJP,
|
||||
file: fileJAJP,
|
||||
version: versionJAJP,
|
||||
},
|
||||
"en-US": {
|
||||
common: commonENUS,
|
||||
domain: domainENUS,
|
||||
settings: settingsENUS,
|
||||
errors: errorsENUS,
|
||||
app: appENUS,
|
||||
file: fileENUS,
|
||||
version: versionENUS,
|
||||
},
|
||||
};
|
||||
|
||||
// Configure and initialize i18next
|
||||
i18next
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: "ja-JP",
|
||||
ns: ["common", "domain", "settings", "errors", "app", "file", "version"],
|
||||
interpolation: {
|
||||
escapeValue: false, // React already handles escaping
|
||||
},
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "i18nextLng",
|
||||
},
|
||||
});
|
||||
|
||||
export default i18next;
|
||||
@@ -4,9 +4,9 @@ body,
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol", "Noto Color Emoji";
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
|
||||
/* macOS style window controls area */
|
||||
@@ -47,4 +47,4 @@ body,
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"selectApp": "Please select an app",
|
||||
"appNotFound": "App information not found",
|
||||
"noConfig": "No configuration",
|
||||
"fileUpload": "File Upload",
|
||||
"view": "View",
|
||||
"downloadAll": "Download All",
|
||||
"basicInfo": "Basic Information",
|
||||
"appId": "App ID",
|
||||
"appCode": "App Code",
|
||||
"createdAt": "Created At",
|
||||
"creator": "Creator",
|
||||
"modifiedAt": "Modified At",
|
||||
"modifier": "Modifier",
|
||||
"spaceId": "Space ID",
|
||||
"pcJs": "PC JS",
|
||||
"pcCss": "PC CSS",
|
||||
"mobileJs": "Mobile JS",
|
||||
"mobileCss": "Mobile CSS",
|
||||
"codeView": "Code View",
|
||||
"selectFileToView": "Select a file from the list to view",
|
||||
"searchApp": "Search apps...",
|
||||
"sortByAppId": "App ID",
|
||||
"sortByName": "Name",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"reload": "Reload",
|
||||
"loadApps": "Load Apps",
|
||||
"loadingApps": "Loading apps...",
|
||||
"noApps": "No app data",
|
||||
"noMatchingApps": "No matching apps",
|
||||
"lastLoaded": "Last loaded",
|
||||
"totalApps": "{{count}} apps total",
|
||||
"unpin": "Unpin",
|
||||
"pinApp": "Pin App",
|
||||
"selectDomainFirst": "Please select a Domain first",
|
||||
"loadAppsFailed": "Failed to load apps",
|
||||
"backToList": "Back to List",
|
||||
"deploy": "Deploy",
|
||||
"deploySuccess": "Deployment successful",
|
||||
"deployFailed": "Deployment failed",
|
||||
"dropZoneHint": "Drop {{fileType}} here or click to add",
|
||||
"dropFileHere": "Drop file here",
|
||||
"fileTypeNotSupported": "Only {{expected}} files are supported",
|
||||
"fileSizeExceeded": "File size exceeds the 20 MB limit",
|
||||
"fileAddFailed": "Failed to add file",
|
||||
"fileDeleteFailed": "Failed to delete file",
|
||||
"statusAdded": "New",
|
||||
"statusDeleted": "Deleted",
|
||||
"statusReordered": "Moved",
|
||||
"restore": "Restore",
|
||||
"fileLimitWarning": "This section has more than 30 files. Please reduce files before deploying.",
|
||||
"deployDisabledReason": "One or more sections exceed the file limit (30)"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"appName": "Kintone JS/CSS Manager",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"confirm": "Confirm",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"expandSidebar": "Expand sidebar",
|
||||
"add": "Add",
|
||||
"close": "Close",
|
||||
"search": "Search",
|
||||
"loading": "Loading...",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"warning": "Warning",
|
||||
"info": "Info",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"refresh": "Refresh",
|
||||
"upload": "Upload",
|
||||
"download": "Download",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"cut": "Cut",
|
||||
"selectAll": "Select all",
|
||||
"versionHistory": "Version History",
|
||||
"settings": "Settings",
|
||||
"downloadSuccess": "Download successful",
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadAllSuccess": "Downloaded to: {{path}}",
|
||||
"refreshSuccess": "Refreshed successfully",
|
||||
"refreshFailed": "Refresh failed",
|
||||
"downloadFile": "Download file",
|
||||
"deleteFile": "Delete file",
|
||||
"unsavedChangesTitle": "Unsaved Changes",
|
||||
"unsavedChangesAppMessage": "The current app has unsaved changes ({{changes}}). Switching apps will discard these changes. Continue?",
|
||||
"unsavedChangesDomainMessage": "The current app has unsaved changes ({{changes}}). Switching domains will discard these changes. Continue?",
|
||||
"pendingChangesAdded": "{{count}} added file(s)",
|
||||
"pendingChangesDeleted": "{{count}} deleted file(s)",
|
||||
"pendingChangesReordered": "{{count}} reordered file(s)"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"title": "Domain Management",
|
||||
"addDomain": "Add Domain",
|
||||
"editDomain": "Edit Domain",
|
||||
"deleteDomain": "Delete Domain",
|
||||
"domainName": "Domain Name",
|
||||
"domainUrl": "Domain URL",
|
||||
"domainList": "Domain List",
|
||||
"noDomains": "No domains",
|
||||
"addFirstDomain": "Add your first domain",
|
||||
"domainPlaceholder": "e.g., example.kintone.com",
|
||||
"namePlaceholder": "e.g., My Kintone",
|
||||
"verifyConnection": "Verify Connection",
|
||||
"connectionSuccess": "Connection successful",
|
||||
"connectionVerified": "Verified",
|
||||
"notVerified": "Not verified",
|
||||
"apiToken": "API Token",
|
||||
"apiTokenPlaceholder": "Enter API token",
|
||||
"authType": "Authentication Type",
|
||||
"password": "Password",
|
||||
"apiTokenAuth": "API Token",
|
||||
"username": "Username",
|
||||
"usernamePlaceholder": "Enter username",
|
||||
"passwordPlaceholder": "Enter password",
|
||||
"basicAuth": "Basic Auth",
|
||||
"deleteConfirm": "Are you sure you want to delete this domain?",
|
||||
"deleteWarning": "This action cannot be undone",
|
||||
"lastSync": "Last sync",
|
||||
"syncNow": "Sync now",
|
||||
"syncing": "Syncing...",
|
||||
"syncSuccess": "Sync successful",
|
||||
"syncFailed": "Sync failed",
|
||||
"domainManagement": "Domain Management",
|
||||
"add": "Add",
|
||||
"noDomainConfig": "No Domain configuration",
|
||||
"noDomainSelected": "No Domain selected",
|
||||
"edit": "Edit",
|
||||
"testConnection": "Test Connection",
|
||||
"connected": "Connected",
|
||||
"connectionFailed": "Connection Failed",
|
||||
"notTested": "Not Tested",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"confirmDeleteDesc": "Are you sure you want to delete this Domain configuration?",
|
||||
"name": "Name",
|
||||
"nameOptional": "Optional, will use domain if empty",
|
||||
"kintoneDomain": "Kintone Domain",
|
||||
"enterDomain": "Please enter domain",
|
||||
"validDomainRequired": "Please enter a valid domain",
|
||||
"enterUsername": "Please enter username",
|
||||
"usernameLoginHint": "Username for Kintone login",
|
||||
"enterPassword": "Please enter password",
|
||||
"keepPasswordHint": "Leave empty to keep current password",
|
||||
"create": "Create",
|
||||
"update": "Update",
|
||||
"domainCreated": "Domain created successfully",
|
||||
"domainUpdated": "Domain updated successfully",
|
||||
"createConnectionFailed": "Connection failed during creation",
|
||||
"domainDuplicate": "This domain and username combination already exists",
|
||||
"createFailed": "Creation failed",
|
||||
"updateFailed": "Update failed",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"networkError": "Network error",
|
||||
"invalidDomain": "Invalid domain",
|
||||
"connectionFailed": "Connection failed",
|
||||
"saveFailed": "Save failed",
|
||||
"loadFailed": "Load failed",
|
||||
"unknownError": "Unknown error",
|
||||
"timeout": "Request timeout",
|
||||
"unauthorized": "Unauthorized",
|
||||
"forbidden": "Forbidden",
|
||||
"notFound": "Resource not found",
|
||||
"serverError": "Server error",
|
||||
"invalidInput": "Invalid input",
|
||||
"requiredField": "This field is required",
|
||||
"invalidFormat": "Invalid format",
|
||||
"duplicateEntry": "Duplicate entry",
|
||||
"operationCancelled": "Operation cancelled",
|
||||
"permissionDenied": "Permission denied",
|
||||
"fileTooLarge": "File too large",
|
||||
"unsupportedFormat": "Unsupported file format",
|
||||
"storageFull": "Storage full",
|
||||
"domainAlreadyExists": "This domain already exists"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"loading": "Loading...",
|
||||
"loadFailed": "Load failed",
|
||||
"fileEmpty": "File content is empty",
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"downloadSuccess": "Download successful",
|
||||
"clickOrDragToUpload": "Click or drag files to this area to upload",
|
||||
"supportFiles": "Supports .js and .css files, max {{size}}MB per file",
|
||||
"selectedFiles": "{{count}} files selected",
|
||||
"confirmClear": "Confirm Clear",
|
||||
"confirmClearDesc": "Are you sure you want to clear all files?",
|
||||
"clearAll": "Clear All",
|
||||
"onlyJsCss": "Only .js and .css files are supported",
|
||||
"fileSizeLimit": "File size cannot exceed {{size}}MB"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"selectLanguage": "Select Language",
|
||||
"general": "General",
|
||||
"appearance": "Appearance",
|
||||
"theme": "Theme",
|
||||
"lightTheme": "Light",
|
||||
"darkTheme": "Dark",
|
||||
"systemTheme": "System",
|
||||
"fontSize": "Font Size",
|
||||
"small": "Small",
|
||||
"medium": "Medium",
|
||||
"large": "Large",
|
||||
"autoSave": "Auto Save",
|
||||
"autoSaveEnabled": "Auto save enabled",
|
||||
"autoSaveDisabled": "Auto save disabled",
|
||||
"cache": "Cache",
|
||||
"clearCache": "Clear Cache",
|
||||
"cacheCleared": "Cache cleared",
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
"checkUpdate": "Check for Updates",
|
||||
"checkUpdateFailed": "Failed to check for updates",
|
||||
"noUpdates": "You're up to date",
|
||||
"updateAvailable": "Update available",
|
||||
"downloadUpdate": "Download Update"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"title": "Version History",
|
||||
"totalVersions": "{{count}} versions",
|
||||
"noVersions": "No version history",
|
||||
"viewCode": "View Code",
|
||||
"confirmRollback": "Confirm Rollback",
|
||||
"confirmRollbackDesc": "Are you sure you want to rollback to this version?",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"confirmDeleteDesc": "Are you sure you want to delete this version?",
|
||||
"sourceUpload": "Upload",
|
||||
"sourceDownload": "Download",
|
||||
"sourceRollback": "Rollback"
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"selectApp": "アプリを選択してください",
|
||||
"appNotFound": "アプリ情報が見つかりません",
|
||||
"noConfig": "設定なし",
|
||||
"fileUpload": "ファイルアップロード",
|
||||
"view": "表示",
|
||||
"downloadAll": "すべてダウンロード",
|
||||
"basicInfo": "基本情報",
|
||||
"appId": "アプリID",
|
||||
"appCode": "アプリコード",
|
||||
"createdAt": "作成日時",
|
||||
"creator": "作成者",
|
||||
"modifiedAt": "更新日時",
|
||||
"modifier": "更新者",
|
||||
"spaceId": "スペースID",
|
||||
"pcJs": "PC JS",
|
||||
"pcCss": "PC CSS",
|
||||
"mobileJs": "モバイル JS",
|
||||
"mobileCss": "モバイル CSS",
|
||||
"codeView": "コード表示",
|
||||
"selectFileToView": "表示するファイルをリストから選択してください",
|
||||
"searchApp": "アプリを検索...",
|
||||
"sortByAppId": "アプリID",
|
||||
"sortByName": "名前",
|
||||
"ascending": "昇順",
|
||||
"descending": "降順",
|
||||
"reload": "再読み込み",
|
||||
"loadApps": "アプリ読み込み",
|
||||
"loadingApps": "アプリを読み込み中...",
|
||||
"noApps": "アプリデータなし",
|
||||
"noMatchingApps": "一致するアプリなし",
|
||||
"lastLoaded": "最終読み込み",
|
||||
"totalApps": "合計 {{count}} アプリ",
|
||||
"unpin": "ピン留め解除",
|
||||
"pinApp": "アプリをピン留め",
|
||||
"selectDomainFirst": "最初にドメインを選択してください",
|
||||
"loadAppsFailed": "アプリの読み込みに失敗しました",
|
||||
"backToList": "リストに戻る",
|
||||
"deploy": "デプロイ",
|
||||
"deploySuccess": "デプロイ成功",
|
||||
"deployFailed": "デプロイ失敗",
|
||||
"dropZoneHint": "{{fileType}} ファイルをここにドロップ、またはクリックして選択",
|
||||
"dropFileHere": "ここにドロップ",
|
||||
"fileTypeNotSupported": "{{expected}} ファイルのみ対応しています",
|
||||
"fileSizeExceeded": "ファイルサイズが 20 MB 制限を超えています",
|
||||
"fileAddFailed": "ファイルの追加に失敗しました",
|
||||
"fileDeleteFailed": "ファイルの削除に失敗しました",
|
||||
"statusAdded": "新規",
|
||||
"statusDeleted": "削除",
|
||||
"statusReordered": "順序変更",
|
||||
"restore": "復元",
|
||||
"fileLimitWarning": "このセクションには30以上のファイルがあります。デプロイ前にファイル数を減らしてください。",
|
||||
"deployDisabledReason": "1つ以上のセクションがファイル制限(30)を超えています"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"appName": "Kintone JS/CSS Manager",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"confirm": "確認",
|
||||
"collapseSidebar": "サイドバーを折りたたむ",
|
||||
"expandSidebar": "サイドバーを展開",
|
||||
"add": "追加",
|
||||
"close": "閉じる",
|
||||
"search": "検索",
|
||||
"loading": "読み込み中...",
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"warning": "警告",
|
||||
"info": "情報",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"ok": "OK",
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"previous": "前へ",
|
||||
"submit": "送信",
|
||||
"reset": "リセット",
|
||||
"refresh": "更新",
|
||||
"upload": "アップロード",
|
||||
"download": "ダウンロード",
|
||||
"copy": "コピー",
|
||||
"paste": "貼り付け",
|
||||
"cut": "切り取り",
|
||||
"selectAll": "すべて選択",
|
||||
"versionHistory": "バージョン履歴",
|
||||
"settings": "設定",
|
||||
"downloadSuccess": "ダウンロード成功",
|
||||
"downloadFailed": "ダウンロード失敗",
|
||||
"downloadAllSuccess": "ダウンロード先: {{path}}",
|
||||
"refreshSuccess": "更新しました",
|
||||
"refreshFailed": "更新に失敗しました",
|
||||
"downloadFile": "ファイルをダウンロード",
|
||||
"deleteFile": "ファイルを削除",
|
||||
"unsavedChangesTitle": "未保存の変更",
|
||||
"unsavedChangesAppMessage": "現在のアプリには未保存の変更があります ({{changes}})。アプリを切り替えると、これらの変更は失われます。続行しますか?",
|
||||
"unsavedChangesDomainMessage": "現在のアプリには未保存の変更があります ({{changes}})。ドメインを切り替えると、これらの変更は失われます。続行しますか?",
|
||||
"pendingChangesAdded": "{{count}} 件の追加ファイル",
|
||||
"pendingChangesDeleted": "{{count}} 件の削除ファイル",
|
||||
"pendingChangesReordered": "{{count}} 件の移動ファイル"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"title": "ドメイン管理",
|
||||
"addDomain": "ドメインを追加",
|
||||
"editDomain": "ドメインを編集",
|
||||
"deleteDomain": "ドメインを削除",
|
||||
"domainName": "ドメイン名",
|
||||
"domainUrl": "ドメインURL",
|
||||
"domainList": "ドメイン一覧",
|
||||
"noDomains": "ドメインがありません",
|
||||
"addFirstDomain": "最初のドメインを追加してください",
|
||||
"domainPlaceholder": "例:example.kintone.com",
|
||||
"namePlaceholder": "例:マイKintone",
|
||||
"verifyConnection": "接続を確認",
|
||||
"connectionSuccess": "接続成功",
|
||||
"connectionVerified": "確認済み",
|
||||
"notVerified": "未確認",
|
||||
"apiToken": "APIトークン",
|
||||
"apiTokenPlaceholder": "APIトークンを入力",
|
||||
"authType": "認証方式",
|
||||
"password": "パスワード",
|
||||
"apiTokenAuth": "APIトークン",
|
||||
"username": "ユーザー名",
|
||||
"usernamePlaceholder": "ユーザー名を入力",
|
||||
"passwordPlaceholder": "パスワードを入力",
|
||||
"basicAuth": "Basic認証",
|
||||
"deleteConfirm": "このドメインを削除しますか?",
|
||||
"deleteWarning": "この操作は取り消せません",
|
||||
"lastSync": "最終同期",
|
||||
"syncNow": "今すぐ同期",
|
||||
"syncing": "同期中...",
|
||||
"syncSuccess": "同期成功",
|
||||
"syncFailed": "同期失敗",
|
||||
"domainManagement": "ドメイン管理",
|
||||
"add": "追加",
|
||||
"noDomainConfig": "ドメイン設定がありません",
|
||||
"noDomainSelected": "ドメイン未選択",
|
||||
"edit": "編集",
|
||||
"testConnection": "接続テスト",
|
||||
"connected": "接続済み",
|
||||
"connectionFailed": "接続失敗",
|
||||
"notTested": "未テスト",
|
||||
"confirmDelete": "削除確認",
|
||||
"confirmDeleteDesc": "このドメイン設定を削除しますか?",
|
||||
"name": "名前",
|
||||
"nameOptional": "オプション、空欄の場合はドメインを使用",
|
||||
"kintoneDomain": "Kintoneドメイン",
|
||||
"enterDomain": "ドメインを入力してください",
|
||||
"validDomainRequired": "有効なドメインを入力してください",
|
||||
"enterUsername": "ユーザー名を入力してください",
|
||||
"usernameLoginHint": "Kintoneログインユーザー名",
|
||||
"enterPassword": "パスワードを入力してください",
|
||||
"keepPasswordHint": "空欄で現在のパスワードを維持",
|
||||
"create": "作成",
|
||||
"update": "更新",
|
||||
"domainCreated": "ドメインを作成しました",
|
||||
"domainUpdated": "ドメインを更新しました",
|
||||
"createConnectionFailed": "接続に失敗しました",
|
||||
"domainDuplicate": "このドメインとユーザー名の組み合わせは既に存在します",
|
||||
"createFailed": "作成に失敗しました",
|
||||
"updateFailed": "更新に失敗しました",
|
||||
"expand": "展開",
|
||||
"collapse": "折りたたむ"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"networkError": "ネットワークエラー",
|
||||
"invalidDomain": "無効なドメイン",
|
||||
"connectionFailed": "接続に失敗しました",
|
||||
"saveFailed": "保存に失敗しました",
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"unknownError": "不明なエラー",
|
||||
"timeout": "リクエストがタイムアウトしました",
|
||||
"unauthorized": "認証されていません",
|
||||
"forbidden": "アクセスが禁止されています",
|
||||
"notFound": "リソースが見つかりません",
|
||||
"serverError": "サーバーエラー",
|
||||
"invalidInput": "入力が無効です",
|
||||
"requiredField": "この項目は必須です",
|
||||
"invalidFormat": "形式が無効です",
|
||||
"duplicateEntry": "同じレコードが既に存在します",
|
||||
"operationCancelled": "操作がキャンセルされました",
|
||||
"permissionDenied": "権限がありません",
|
||||
"fileTooLarge": "ファイルが大きすぎます",
|
||||
"unsupportedFormat": "サポートされていないファイル形式です",
|
||||
"storageFull": "ストレージ容量不足",
|
||||
"domainAlreadyExists": "このドメインは既に存在します"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"loading": "読み込み中...",
|
||||
"loadFailed": "読み込み失敗",
|
||||
"fileEmpty": "ファイルの内容が空です",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"downloadSuccess": "ダウンロード成功",
|
||||
"clickOrDragToUpload": "クリックまたはドラッグしてファイルをアップロード",
|
||||
"supportFiles": ".jsと.cssファイルに対応、最大{{size}}MB",
|
||||
"selectedFiles": "{{count}}個のファイルを選択",
|
||||
"confirmClear": "クリア確認",
|
||||
"confirmClearDesc": "すべてのファイルをクリアしますか?",
|
||||
"clearAll": "すべてクリア",
|
||||
"onlyJsCss": ".jsと.cssファイルのみ対応しています",
|
||||
"fileSizeLimit": "ファイルサイズは{{size}}MBを超えられません"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"title": "設定",
|
||||
"language": "言語",
|
||||
"selectLanguage": "言語を選択",
|
||||
"general": "一般設定",
|
||||
"appearance": "外観",
|
||||
"theme": "テーマ",
|
||||
"lightTheme": "ライト",
|
||||
"darkTheme": "ダーク",
|
||||
"systemTheme": "システムに従う",
|
||||
"fontSize": "フォントサイズ",
|
||||
"small": "小",
|
||||
"medium": "中",
|
||||
"large": "大",
|
||||
"autoSave": "自動保存",
|
||||
"autoSaveEnabled": "自動保存が有効です",
|
||||
"autoSaveDisabled": "自動保存が無効です",
|
||||
"cache": "キャッシュ",
|
||||
"clearCache": "キャッシュをクリア",
|
||||
"cacheCleared": "キャッシュをクリアしました",
|
||||
"about": "アプリについて",
|
||||
"version": "バージョン",
|
||||
"checkUpdate": "更新を確認",
|
||||
"checkUpdateFailed": "更新の確認に失敗しました",
|
||||
"noUpdates": "最新バージョンです",
|
||||
"updateAvailable": "新しいバージョンが利用可能です",
|
||||
"downloadUpdate": "更新をダウンロード"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"title": "バージョン履歴",
|
||||
"totalVersions": "{{count}}バージョン",
|
||||
"noVersions": "バージョン履歴なし",
|
||||
"viewCode": "コードを表示",
|
||||
"confirmRollback": "ロールバック確認",
|
||||
"confirmRollbackDesc": "このバージョンにロールバックしますか?",
|
||||
"confirmDelete": "削除確認",
|
||||
"confirmDeleteDesc": "このバージョンを削除しますか?",
|
||||
"sourceUpload": "アップロード",
|
||||
"sourceDownload": "ダウンロード",
|
||||
"sourceRollback": "ロールバック"
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"selectApp": "请选择一个应用",
|
||||
"appNotFound": "未找到应用信息",
|
||||
"noConfig": "暂无配置",
|
||||
"fileUpload": "文件上传",
|
||||
"view": "查看",
|
||||
"downloadAll": "下载全部",
|
||||
"basicInfo": "基本信息",
|
||||
"appId": "应用ID",
|
||||
"appCode": "应用代码",
|
||||
"createdAt": "创建时间",
|
||||
"creator": "创建者",
|
||||
"modifiedAt": "更新时间",
|
||||
"modifier": "更新者",
|
||||
"spaceId": "所属 Space ID",
|
||||
"pcJs": "PC端 JS",
|
||||
"pcCss": "PC端 CSS",
|
||||
"mobileJs": "移动端 JS",
|
||||
"mobileCss": "移动端 CSS",
|
||||
"codeView": "代码查看",
|
||||
"selectFileToView": "请从文件列表中选择要查看的文件",
|
||||
"searchApp": "搜索应用...",
|
||||
"sortByAppId": "应用ID",
|
||||
"sortByName": "名称",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"reload": "重新加载",
|
||||
"loadApps": "加载应用",
|
||||
"loadingApps": "正在加载应用...",
|
||||
"noApps": "暂无应用数据",
|
||||
"noMatchingApps": "没有匹配的应用",
|
||||
"lastLoaded": "上次加载",
|
||||
"totalApps": "共 {{count}} 个应用",
|
||||
"unpin": "取消置顶",
|
||||
"pinApp": "置顶应用",
|
||||
"selectDomainFirst": "请先选择一个 Domain",
|
||||
"loadAppsFailed": "加载应用失败",
|
||||
"backToList": "返回列表",
|
||||
"deploy": "部署",
|
||||
"deploySuccess": "部署成功",
|
||||
"deployFailed": "部署失败",
|
||||
"dropZoneHint": "拖拽 {{fileType}} 文件到此处,或点击选择",
|
||||
"dropFileHere": "松开以添加文件",
|
||||
"fileTypeNotSupported": "仅支持 {{expected}} 文件",
|
||||
"fileSizeExceeded": "文件大小超过 20 MB 限制",
|
||||
"fileAddFailed": "添加文件失败",
|
||||
"fileDeleteFailed": "删除文件失败",
|
||||
"statusAdded": "新增",
|
||||
"statusDeleted": "删除",
|
||||
"statusReordered": "已移动",
|
||||
"restore": "恢复",
|
||||
"fileLimitWarning": "此区块文件数超过30个,请减少文件后再部署。",
|
||||
"deployDisabledReason": "一个或多个区块超过文件数量限制(30)"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"appName": "Kintone JS/CSS Manager",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"confirm": "确认",
|
||||
"collapseSidebar": "收起侧边栏",
|
||||
"expandSidebar": "展开侧边栏",
|
||||
"add": "添加",
|
||||
"close": "关闭",
|
||||
"search": "搜索",
|
||||
"loading": "加载中...",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"warning": "警告",
|
||||
"info": "提示",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"ok": "确定",
|
||||
"back": "返回",
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"refresh": "刷新",
|
||||
"upload": "上传",
|
||||
"download": "下载",
|
||||
"copy": "复制",
|
||||
"paste": "粘贴",
|
||||
"cut": "剪切",
|
||||
"selectAll": "全选",
|
||||
"versionHistory": "版本历史",
|
||||
"settings": "设置",
|
||||
"downloadSuccess": "下载成功",
|
||||
"downloadFailed": "下载失败",
|
||||
"downloadAllSuccess": "已下载到: {{path}}",
|
||||
"refreshSuccess": "刷新成功",
|
||||
"refreshFailed": "刷新失败",
|
||||
"downloadFile": "下载文件",
|
||||
"deleteFile": "删除文件",
|
||||
"unsavedChangesTitle": "未保存的变更",
|
||||
"unsavedChangesAppMessage": "当前应用有未保存的变更 ({{changes}}),切换应用将丢失这些变更。是否继续?",
|
||||
"unsavedChangesDomainMessage": "当前应用有未保存的变更 ({{changes}}),切换域名将丢失这些变更。是否继续?",
|
||||
"pendingChangesAdded": "{{count}} 个新增文件",
|
||||
"pendingChangesDeleted": "{{count}} 个删除文件",
|
||||
"pendingChangesReordered": "{{count}} 个移动文件"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"title": "域名管理",
|
||||
"addDomain": "添加域名",
|
||||
"editDomain": "编辑域名",
|
||||
"deleteDomain": "删除域名",
|
||||
"domainName": "域名名称",
|
||||
"domainUrl": "域名地址",
|
||||
"domainList": "域名列表",
|
||||
"noDomains": "暂无域名",
|
||||
"addFirstDomain": "添加您的第一个域名",
|
||||
"domainPlaceholder": "例如:example.kintone.com",
|
||||
"namePlaceholder": "例如:我的Kintone",
|
||||
"verifyConnection": "验证连接",
|
||||
"connectionSuccess": "连接成功",
|
||||
"connectionVerified": "已验证",
|
||||
"notVerified": "未验证",
|
||||
"apiToken": "API令牌",
|
||||
"apiTokenPlaceholder": "输入API令牌",
|
||||
"authType": "认证方式",
|
||||
"password": "密码",
|
||||
"apiTokenAuth": "API令牌",
|
||||
"username": "用户名",
|
||||
"usernamePlaceholder": "输入用户名",
|
||||
"passwordPlaceholder": "输入密码",
|
||||
"basicAuth": "Basic认证",
|
||||
"deleteConfirm": "确定要删除这个域名吗?",
|
||||
"deleteWarning": "此操作不可撤销",
|
||||
"lastSync": "最后同步",
|
||||
"syncNow": "立即同步",
|
||||
"syncing": "同步中...",
|
||||
"syncSuccess": "同步成功",
|
||||
"syncFailed": "同步失败",
|
||||
"domainManagement": "Domain 管理",
|
||||
"add": "添加",
|
||||
"noDomainConfig": "暂无 Domain 配置",
|
||||
"noDomainSelected": "未选择 Domain",
|
||||
"edit": "编辑",
|
||||
"testConnection": "测试连接",
|
||||
"connected": "已连接",
|
||||
"connectionFailed": "连接失败",
|
||||
"notTested": "未检测",
|
||||
"confirmDelete": "确认删除",
|
||||
"confirmDeleteDesc": "确定要删除此 Domain 配置吗?",
|
||||
"name": "名称",
|
||||
"nameOptional": "可选,留空则使用域名",
|
||||
"kintoneDomain": "Kintone 域名",
|
||||
"enterDomain": "请输入域名",
|
||||
"validDomainRequired": "请输入有效的域名",
|
||||
"enterUsername": "请输入用户名",
|
||||
"usernameLoginHint": "登录 Kintone 的用户名",
|
||||
"enterPassword": "请输入密码",
|
||||
"keepPasswordHint": "留空则保持原密码",
|
||||
"create": "创建",
|
||||
"update": "更新",
|
||||
"domainCreated": "Domain 创建成功",
|
||||
"domainUpdated": "Domain 更新成功",
|
||||
"createConnectionFailed": "创建时连接失败",
|
||||
"domainDuplicate": "该域名和用户名组合已存在",
|
||||
"createFailed": "创建失败",
|
||||
"updateFailed": "更新失败",
|
||||
"expand": "展开",
|
||||
"collapse": "折叠"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"networkError": "网络错误",
|
||||
"invalidDomain": "无效的域名",
|
||||
"connectionFailed": "连接失败",
|
||||
"saveFailed": "保存失败",
|
||||
"loadFailed": "加载失败",
|
||||
"unknownError": "未知错误",
|
||||
"timeout": "请求超时",
|
||||
"unauthorized": "未授权",
|
||||
"forbidden": "禁止访问",
|
||||
"notFound": "资源不存在",
|
||||
"serverError": "服务器错误",
|
||||
"invalidInput": "输入无效",
|
||||
"requiredField": "此字段为必填",
|
||||
"invalidFormat": "格式无效",
|
||||
"duplicateEntry": "已存在相同记录",
|
||||
"operationCancelled": "操作已取消",
|
||||
"permissionDenied": "权限不足",
|
||||
"fileTooLarge": "文件过大",
|
||||
"unsupportedFormat": "不支持的文件格式",
|
||||
"storageFull": "存储空间不足",
|
||||
"domainAlreadyExists": "该 Domain 已存在"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"loading": "加载中...",
|
||||
"loadFailed": "加载失败",
|
||||
"fileEmpty": "文件内容为空",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"downloadSuccess": "下载成功",
|
||||
"clickOrDragToUpload": "点击或拖拽文件到此区域上传",
|
||||
"supportFiles": "支持 .js 和 .css 文件,单个文件最大 {{size}}MB",
|
||||
"selectedFiles": "已选择 {{count}} 个文件",
|
||||
"confirmClear": "确认清空",
|
||||
"confirmClearDesc": "确定要清空所有文件吗?",
|
||||
"clearAll": "清空全部",
|
||||
"onlyJsCss": "只支持 .js 和 .css 文件",
|
||||
"fileSizeLimit": "文件大小不能超过 {{size}}MB"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"title": "设置",
|
||||
"language": "语言",
|
||||
"selectLanguage": "选择语言",
|
||||
"general": "通用设置",
|
||||
"appearance": "外观",
|
||||
"theme": "主题",
|
||||
"lightTheme": "浅色",
|
||||
"darkTheme": "深色",
|
||||
"systemTheme": "跟随系统",
|
||||
"fontSize": "字体大小",
|
||||
"small": "小",
|
||||
"medium": "中",
|
||||
"large": "大",
|
||||
"autoSave": "自动保存",
|
||||
"autoSaveEnabled": "自动保存已启用",
|
||||
"autoSaveDisabled": "自动保存已禁用",
|
||||
"cache": "缓存",
|
||||
"clearCache": "清除缓存",
|
||||
"cacheCleared": "缓存已清除",
|
||||
"about": "关于",
|
||||
"version": "版本",
|
||||
"checkUpdate": "检查更新",
|
||||
"checkUpdateFailed": "检查更新失败",
|
||||
"noUpdates": "已是最新版本",
|
||||
"updateAvailable": "有新版本可用",
|
||||
"downloadUpdate": "下载更新"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"title": "版本历史",
|
||||
"totalVersions": "{{count}} 个版本",
|
||||
"noVersions": "暂无版本历史",
|
||||
"viewCode": "查看代码",
|
||||
"confirmRollback": "确认回滚",
|
||||
"confirmRollbackDesc": "确定要回滚到此版本吗?",
|
||||
"confirmDelete": "确认删除",
|
||||
"confirmDeleteDesc": "确定要删除此版本吗?",
|
||||
"sourceUpload": "上传",
|
||||
"sourceDownload": "下载",
|
||||
"sourceRollback": "回滚"
|
||||
}
|
||||
@@ -1,39 +1,27 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App as AntdApp } from "antd";
|
||||
import { ConfigProvider, ThemeProvider } from "@lobehub/ui";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "./i18n";
|
||||
import App from "./App";
|
||||
import { useThemeStore } from "./stores/themeStore";
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider, App as AntdApp } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { ThemeProvider } from '@lobehub/ui'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
const ThemeApp: React.FC = () => {
|
||||
const { themeMode, setThemeMode } = useThemeStore();
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
themeMode={themeMode}
|
||||
onThemeModeChange={setThemeMode}
|
||||
// customTheme={{primaryColor: 'blue'}}
|
||||
customToken={() => ({
|
||||
colorLink: "#1890ff",
|
||||
})}
|
||||
>
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider motion={motion}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ThemeApp />
|
||||
</I18nextProvider>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<AntdApp>
|
||||
<App />
|
||||
</AntdApp>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,105 +1,72 @@
|
||||
/**
|
||||
* App Store
|
||||
* Manages app browsing state (apps, current selection, pagination)
|
||||
* Persisted to localStorage for offline access
|
||||
* Manages app browsing state (spaces, apps, current selection)
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { AppResponse, AppDetail } from "@shared/types/kintone";
|
||||
import type {
|
||||
KintoneSpace,
|
||||
KintoneApp,
|
||||
AppDetail,
|
||||
} from "@renderer/types/kintone";
|
||||
|
||||
interface AppState {
|
||||
// State
|
||||
apps: AppResponse[];
|
||||
spaces: KintoneSpace[];
|
||||
apps: KintoneApp[];
|
||||
currentSpace: KintoneSpace | null;
|
||||
currentApp: AppDetail | null;
|
||||
selectedAppId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Pagination state
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
|
||||
// Search state
|
||||
searchText: string;
|
||||
|
||||
// Load metadata
|
||||
loadedAt: string | null;
|
||||
|
||||
// Actions
|
||||
setApps: (apps: AppResponse[]) => void;
|
||||
setSpaces: (spaces: KintoneSpace[]) => void;
|
||||
setApps: (apps: KintoneApp[]) => void;
|
||||
setCurrentSpace: (space: KintoneSpace | null) => void;
|
||||
setCurrentApp: (app: AppDetail | null) => void;
|
||||
setSelectedAppId: (id: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
clearError: () => void;
|
||||
setCurrentPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
setSearchText: (text: string) => void;
|
||||
setLoadedAt: (time: string | null) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
export const useAppStore = create<AppState>()((set) => ({
|
||||
// Initial state
|
||||
spaces: [],
|
||||
apps: [],
|
||||
currentSpace: null,
|
||||
currentApp: null,
|
||||
selectedAppId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
searchText: "",
|
||||
loadedAt: null,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
// Actions
|
||||
setSpaces: (spaces) => set({ spaces }),
|
||||
|
||||
// Actions
|
||||
setApps: (apps) =>
|
||||
set({
|
||||
apps,
|
||||
loadedAt: new Date().toISOString(),
|
||||
}),
|
||||
setApps: (apps) => set({ apps }),
|
||||
|
||||
setCurrentApp: (currentApp) => set({ currentApp }),
|
||||
setCurrentSpace: (space) => set({ currentSpace: space }),
|
||||
|
||||
setSelectedAppId: (selectedAppId) => set({ selectedAppId }),
|
||||
setCurrentApp: (app) => set({ currentApp: app }),
|
||||
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setSelectedAppId: (id) => set({ selectedAppId: id }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
setCurrentPage: (currentPage) => set({ currentPage }),
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
setPageSize: (pageSize) =>
|
||||
set({
|
||||
pageSize,
|
||||
currentPage: 1, // Reset to first page when page size changes
|
||||
}),
|
||||
|
||||
setSearchText: (searchText) =>
|
||||
set({
|
||||
searchText,
|
||||
currentPage: 1, // Reset to first page when search changes
|
||||
}),
|
||||
|
||||
setLoadedAt: (loadedAt) => set({ loadedAt }),
|
||||
|
||||
clear: () => set(initialState),
|
||||
clear: () =>
|
||||
set({
|
||||
spaces: [],
|
||||
apps: [],
|
||||
currentSpace: null,
|
||||
currentApp: null,
|
||||
selectedAppId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
{
|
||||
name: "app-storage",
|
||||
// Only persist apps and loadedAt, not transient UI state
|
||||
partialize: (state) => ({
|
||||
apps: state.apps,
|
||||
loadedAt: state.loadedAt,
|
||||
selectedAppId: state.selectedAppId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
}));
|
||||
|
||||
85
src/renderer/src/stores/deployStore.ts
Normal file
85
src/renderer/src/stores/deployStore.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Deploy Store
|
||||
* Manages deployment state and parameters
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { DeployFile, DeployResult } from "@renderer/types/ipc";
|
||||
|
||||
export type DeployStep =
|
||||
| "select"
|
||||
| "configure"
|
||||
| "confirm"
|
||||
| "deploying"
|
||||
| "success"
|
||||
| "error";
|
||||
|
||||
interface DeployState {
|
||||
// State
|
||||
step: DeployStep;
|
||||
files: DeployFile[];
|
||||
targetAppId: string | null;
|
||||
deploying: boolean;
|
||||
result: DeployResult | null;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
setStep: (step: DeployStep) => void;
|
||||
setFiles: (files: DeployFile[]) => void;
|
||||
addFile: (file: DeployFile) => void;
|
||||
removeFile: (index: number) => void;
|
||||
updateFile: (index: number, file: Partial<DeployFile>) => void;
|
||||
setTargetAppId: (appId: string | null) => void;
|
||||
setDeploying: (deploying: boolean) => void;
|
||||
setResult: (result: DeployResult | null) => void;
|
||||
setError: (error: string | null) => void;
|
||||
clear: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
step: "select" as DeployStep,
|
||||
files: [],
|
||||
targetAppId: null,
|
||||
deploying: false,
|
||||
result: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const useDeployStore = create<DeployState>()((set) => ({
|
||||
...initialState,
|
||||
|
||||
// Actions
|
||||
setStep: (step) => set({ step }),
|
||||
|
||||
setFiles: (files) => set({ files }),
|
||||
|
||||
addFile: (file) =>
|
||||
set((state) => ({
|
||||
files: [...state.files, file],
|
||||
})),
|
||||
|
||||
removeFile: (index) =>
|
||||
set((state) => ({
|
||||
files: state.files.filter((_, i) => i !== index),
|
||||
})),
|
||||
|
||||
updateFile: (index, updates) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((file, i) =>
|
||||
i === index ? { ...file, ...updates } : file,
|
||||
),
|
||||
})),
|
||||
|
||||
setTargetAppId: (appId) => set({ targetAppId: appId }),
|
||||
|
||||
setDeploying: (deploying) => set({ deploying }),
|
||||
|
||||
setResult: (result) => set({ result }),
|
||||
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
clear: () => set(initialState),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { useAppStore } from "./appStore";
|
||||
import { useFileChangeStore } from "./fileChangeStore";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
||||
import type { ConnectionStatus } from "@shared/types/domain";
|
||||
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
|
||||
import type { Domain, DomainWithStatus } from "@renderer/types/domain";
|
||||
import type { ConnectionStatus } from "@renderer/types/domain";
|
||||
import type {
|
||||
CreateDomainParams,
|
||||
UpdateDomainParams,
|
||||
} from "@renderer/types/ipc";
|
||||
|
||||
interface DomainState {
|
||||
// State
|
||||
@@ -25,9 +26,12 @@ interface DomainState {
|
||||
addDomain: (domain: Domain) => void;
|
||||
updateDomain: (domain: Domain) => void;
|
||||
removeDomain: (id: string) => void;
|
||||
reorderDomains: (fromIndex: number, toIndex: number) => void;
|
||||
setCurrentDomain: (domain: Domain | null) => void;
|
||||
setConnectionStatus: (id: string, status: ConnectionStatus, error?: string) => void;
|
||||
setConnectionStatus: (
|
||||
id: string,
|
||||
status: ConnectionStatus,
|
||||
error?: string,
|
||||
) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
clearError: () => void;
|
||||
@@ -63,29 +67,27 @@ export const useDomainStore = create<DomainState>()(
|
||||
updateDomain: (domain) =>
|
||||
set((state) => ({
|
||||
domains: state.domains.map((d) => (d.id === domain.id ? domain : d)),
|
||||
currentDomain: state.currentDomain?.id === domain.id ? domain : state.currentDomain,
|
||||
currentDomain:
|
||||
state.currentDomain?.id === domain.id
|
||||
? domain
|
||||
: state.currentDomain,
|
||||
})),
|
||||
|
||||
removeDomain: (id) =>
|
||||
set((state) => ({
|
||||
domains: state.domains.filter((d) => d.id !== id),
|
||||
currentDomain: state.currentDomain?.id === id ? null : state.currentDomain,
|
||||
currentDomain:
|
||||
state.currentDomain?.id === id ? null : state.currentDomain,
|
||||
})),
|
||||
|
||||
reorderDomains: (fromIndex, toIndex) =>
|
||||
set((state) => {
|
||||
const domains = [...state.domains];
|
||||
const [removed] = domains.splice(fromIndex, 1);
|
||||
domains.splice(toIndex, 0, removed);
|
||||
return { domains };
|
||||
}),
|
||||
|
||||
setCurrentDomain: (domain) => set({ currentDomain: domain }),
|
||||
|
||||
setConnectionStatus: (id, status, error) =>
|
||||
set((state) => ({
|
||||
connectionStatuses: { ...state.connectionStatuses, [id]: status },
|
||||
connectionErrors: error ? { ...state.connectionErrors, [id]: error } : state.connectionErrors,
|
||||
connectionErrors: error
|
||||
? { ...state.connectionErrors, [id]: error }
|
||||
: state.connectionErrors,
|
||||
})),
|
||||
|
||||
setLoading: (loading) => set({ loading }),
|
||||
@@ -106,20 +108,14 @@ export const useDomainStore = create<DomainState>()(
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to load domains",
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to load domains",
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createDomain: async (params: CreateDomainParams) => {
|
||||
// Check for duplicate domain
|
||||
const existingDomain = get().domains.find((d) => d.domain === params.domain && d.username === params.username);
|
||||
if (existingDomain) {
|
||||
set({ error: "domainAlreadyExists", loading: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const result = await window.api.createDomain(params);
|
||||
@@ -135,7 +131,10 @@ export const useDomainStore = create<DomainState>()(
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to create domain",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create domain",
|
||||
loading: false,
|
||||
});
|
||||
return false;
|
||||
@@ -148,8 +147,13 @@ export const useDomainStore = create<DomainState>()(
|
||||
const result = await window.api.updateDomain(params);
|
||||
if (result.success) {
|
||||
set((state) => ({
|
||||
domains: state.domains.map((d) => (d.id === result.data.id ? result.data : d)),
|
||||
currentDomain: state.currentDomain?.id === result.data.id ? result.data : state.currentDomain,
|
||||
domains: state.domains.map((d) =>
|
||||
d.id === result.data.id ? result.data : d,
|
||||
),
|
||||
currentDomain:
|
||||
state.currentDomain?.id === result.data.id
|
||||
? result.data
|
||||
: state.currentDomain,
|
||||
loading: false,
|
||||
}));
|
||||
return true;
|
||||
@@ -159,7 +163,10 @@ export const useDomainStore = create<DomainState>()(
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to update domain",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update domain",
|
||||
loading: false,
|
||||
});
|
||||
return false;
|
||||
@@ -173,7 +180,8 @@ export const useDomainStore = create<DomainState>()(
|
||||
if (result.success) {
|
||||
set((state) => ({
|
||||
domains: state.domains.filter((d) => d.id !== id),
|
||||
currentDomain: state.currentDomain?.id === id ? null : state.currentDomain,
|
||||
currentDomain:
|
||||
state.currentDomain?.id === id ? null : state.currentDomain,
|
||||
loading: false,
|
||||
}));
|
||||
return true;
|
||||
@@ -183,7 +191,10 @@ export const useDomainStore = create<DomainState>()(
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "Failed to delete domain",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to delete domain",
|
||||
loading: false,
|
||||
});
|
||||
return false;
|
||||
@@ -191,32 +202,10 @@ export const useDomainStore = create<DomainState>()(
|
||||
},
|
||||
|
||||
switchDomain: async (domain: Domain) => {
|
||||
const appStore = useAppStore.getState();
|
||||
const fileChangeStore = useFileChangeStore.getState();
|
||||
|
||||
// Track the domain ID at request start (closure variable)
|
||||
const requestDomainId = domain.id;
|
||||
|
||||
// Get previous domain ID before switching (to clear pending changes)
|
||||
const previousDomainId = get().currentDomain?.id;
|
||||
|
||||
// 1. reset
|
||||
appStore.setLoading(true);
|
||||
appStore.setApps([]);
|
||||
appStore.setSelectedAppId(null);
|
||||
|
||||
// Clear pending file changes from previous domain
|
||||
if (previousDomainId) {
|
||||
fileChangeStore.clearDomainChanges(previousDomainId);
|
||||
}
|
||||
|
||||
// 2. Set current domain
|
||||
set({ currentDomain: domain });
|
||||
|
||||
// 3. Test connection after switching
|
||||
// Test connection after switching
|
||||
const status = await get().testConnection(domain.id);
|
||||
// Check if we're still on the same domain before updating connection status
|
||||
if (status && get().currentDomain?.id === requestDomainId) {
|
||||
if (status) {
|
||||
set({
|
||||
connectionStatuses: {
|
||||
...get().connectionStatuses,
|
||||
@@ -224,23 +213,6 @@ export const useDomainStore = create<DomainState>()(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Auto-load apps for the new domain
|
||||
try {
|
||||
const result = await window.api.getApps({ domainId: domain.id });
|
||||
// Check if we're still on the same domain before updating apps
|
||||
if (result.success && get().currentDomain?.id === requestDomainId) {
|
||||
appStore.setApps(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail - user can manually reload
|
||||
console.error("Failed to auto-load apps:", error);
|
||||
} finally {
|
||||
// Check before setting loading to false
|
||||
if (get().currentDomain?.id === requestDomainId) {
|
||||
appStore.setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
testConnection: async (id: string) => {
|
||||
@@ -253,7 +225,9 @@ export const useDomainStore = create<DomainState>()(
|
||||
...state.connectionStatuses,
|
||||
[id]: status.connectionStatus,
|
||||
},
|
||||
connectionErrors: status.connectionError ? { ...state.connectionErrors, [id]: status.connectionError } : state.connectionErrors,
|
||||
connectionErrors: status.connectionError
|
||||
? { ...state.connectionErrors, [id]: status.connectionError }
|
||||
: state.connectionErrors,
|
||||
}));
|
||||
return status;
|
||||
} else {
|
||||
@@ -274,7 +248,10 @@ export const useDomainStore = create<DomainState>()(
|
||||
connectionStatuses: { ...state.connectionStatuses, [id]: "error" },
|
||||
connectionErrors: {
|
||||
...state.connectionErrors,
|
||||
[id]: error instanceof Error ? error.message : "Connection test failed",
|
||||
[id]:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Connection test failed",
|
||||
},
|
||||
}));
|
||||
return null;
|
||||
@@ -287,6 +264,6 @@ export const useDomainStore = create<DomainState>()(
|
||||
domains: state.domains,
|
||||
currentDomain: state.currentDomain,
|
||||
}),
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
/**
|
||||
* File Change Store
|
||||
* Manages file change state (added/deleted/unchanged/reordered) per app.
|
||||
* File content is NOT stored here — only metadata and local disk paths.
|
||||
* State is persisted so pending changes survive app restarts.
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { FileStatus } from "@shared/types/ipc";
|
||||
|
||||
export type { FileStatus };
|
||||
|
||||
export interface FileEntry {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileType: "js" | "css";
|
||||
platform: "desktop" | "mobile";
|
||||
status: FileStatus;
|
||||
size?: number;
|
||||
/** For unchanged FILE-type Kintone files */
|
||||
fileKey?: string;
|
||||
/** For unchanged URL-type Kintone files */
|
||||
url?: string;
|
||||
/** For locally added files: absolute path on disk */
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
interface AppFileState {
|
||||
files: FileEntry[];
|
||||
initialized: boolean;
|
||||
/** Original order for each section: key is `${platform}:${fileType}`, value is ordered file IDs */
|
||||
originalSectionOrders: Record<string, string[]>;
|
||||
/** Server revision string from Kintone API, used to detect remote changes */
|
||||
serverRevision: string | null;
|
||||
}
|
||||
|
||||
interface FileChangeState {
|
||||
appFiles: Record<string, AppFileState>;
|
||||
|
||||
/**
|
||||
* Initialize file list from Kintone data for a given app.
|
||||
* No-op if the app is already initialized (has pending changes).
|
||||
* Call clearChanges() first to force re-initialization.
|
||||
*/
|
||||
initializeApp: (domainId: string, appId: string, files: Array<Omit<FileEntry, "status">>) => void;
|
||||
|
||||
/**
|
||||
* Add a new locally-staged file (status: added).
|
||||
*/
|
||||
addFile: (domainId: string, appId: string, entry: FileEntry) => void;
|
||||
|
||||
/**
|
||||
* Mark a file for deletion, or remove an added file from the list.
|
||||
* - unchanged/reordered → deleted
|
||||
* - added → removed from list entirely
|
||||
* - deleted → no-op
|
||||
*/
|
||||
deleteFile: (domainId: string, appId: string, fileId: string) => void;
|
||||
|
||||
/**
|
||||
* Restore a deleted file back to unchanged.
|
||||
* Only applies to files with status: deleted.
|
||||
*/
|
||||
restoreFile: (domainId: string, appId: string, fileId: string) => void;
|
||||
|
||||
/**
|
||||
* Reorder files within a specific (platform, fileType) section.
|
||||
* The dragged file's status will be set to "reordered" (if it was "unchanged").
|
||||
*/
|
||||
reorderSection: (domainId: string, appId: string, platform: "desktop" | "mobile", fileType: "js" | "css", newOrder: string[], draggedFileId: string) => void;
|
||||
|
||||
/**
|
||||
* Clear all pending changes and reset initialized state.
|
||||
* Next call to initializeApp will re-read from Kintone.
|
||||
*/
|
||||
clearChanges: (domainId: string, appId: string) => void;
|
||||
|
||||
/**
|
||||
* Clear all pending changes for all apps under a given domain.
|
||||
* Removes all entries with keys starting with `${domainId}:`.
|
||||
*/
|
||||
clearDomainChanges: (domainId: string) => void;
|
||||
|
||||
/** Get all files for an app (all statuses) */
|
||||
getFiles: (domainId: string, appId: string) => FileEntry[];
|
||||
|
||||
/** Get files for a specific section */
|
||||
getSectionFiles: (domainId: string, appId: string, platform: "desktop" | "mobile", fileType: "js" | "css") => FileEntry[];
|
||||
|
||||
/** Count of added, deleted, and reordered files */
|
||||
getChangeCount: (domainId: string, appId: string) => { added: number; deleted: number; reordered: number };
|
||||
|
||||
/** Check if there are pending changes for an app */
|
||||
hasPendingChanges: (domainId: string, appId: string) => boolean;
|
||||
|
||||
isInitialized: (domainId: string, appId: string) => boolean;
|
||||
|
||||
/** Store the revision string from Kintone API */
|
||||
setServerRevision: (domainId: string, appId: string, revision: string) => void;
|
||||
|
||||
/** Get the stored revision string */
|
||||
getServerRevision: (domainId: string, appId: string) => string | null;
|
||||
|
||||
/** Check if the revision has changed (indicating remote modifications) */
|
||||
hasRemoteChange: (domainId: string, appId: string, currentRevision: string) => boolean;
|
||||
}
|
||||
|
||||
const appKey = (domainId: string, appId: string) => `${domainId}:${appId}`;
|
||||
|
||||
export const useFileChangeStore = create<FileChangeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
appFiles: {},
|
||||
|
||||
initializeApp: (domainId, appId, files) => {
|
||||
const key = appKey(domainId, appId);
|
||||
const existing = get().appFiles[key];
|
||||
if (existing?.initialized) return;
|
||||
|
||||
const entries: FileEntry[] = files.map((f) => ({
|
||||
...f,
|
||||
status: "unchanged" as FileStatus,
|
||||
}));
|
||||
|
||||
// Build original section orders
|
||||
const originalSectionOrders: Record<string, string[]> = {};
|
||||
for (const f of entries) {
|
||||
const sectionKey = `${f.platform}:${f.fileType}`;
|
||||
if (!originalSectionOrders[sectionKey]) {
|
||||
originalSectionOrders[sectionKey] = [];
|
||||
}
|
||||
originalSectionOrders[sectionKey].push(f.id);
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
appFiles: {
|
||||
...state.appFiles,
|
||||
[key]: { files: entries, initialized: true, originalSectionOrders, serverRevision: null },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
addFile: (domainId, appId, entry) => {
|
||||
const key = appKey(domainId, appId);
|
||||
set((state) => {
|
||||
const existing = state.appFiles[key] ?? {
|
||||
files: [],
|
||||
initialized: true,
|
||||
originalSectionOrders: {},
|
||||
serverRevision: null,
|
||||
};
|
||||
return {
|
||||
appFiles: {
|
||||
...state.appFiles,
|
||||
[key]: {
|
||||
...existing,
|
||||
files: [...existing.files, entry],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deleteFile: (domainId, appId, fileId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
set((state) => {
|
||||
const existing = state.appFiles[key];
|
||||
if (!existing) return state;
|
||||
|
||||
const file = existing.files.find((f) => f.id === fileId);
|
||||
if (!file) return state;
|
||||
|
||||
let updatedFiles: FileEntry[];
|
||||
if (file.status === "added") {
|
||||
// Remove added files entirely
|
||||
updatedFiles = existing.files.filter((f) => f.id !== fileId);
|
||||
} else if (file.status === "unchanged" || file.status === "reordered") {
|
||||
// Mark unchanged/reordered files as deleted
|
||||
updatedFiles = existing.files.map((f) => (f.id === fileId ? { ...f, status: "deleted" as FileStatus } : f));
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
appFiles: {
|
||||
...state.appFiles,
|
||||
[key]: { ...existing, files: updatedFiles },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
restoreFile: (domainId, appId, fileId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
set((state) => {
|
||||
const existing = state.appFiles[key];
|
||||
if (!existing) return state;
|
||||
|
||||
return {
|
||||
appFiles: {
|
||||
...state.appFiles,
|
||||
[key]: {
|
||||
...existing,
|
||||
files: existing.files.map((f) => (f.id === fileId && f.status === "deleted" ? { ...f, status: "unchanged" as FileStatus } : f)),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
reorderSection: (domainId, appId, platform, fileType, newOrder, draggedFileId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
set((state) => {
|
||||
const existing = state.appFiles[key];
|
||||
if (!existing) return state;
|
||||
|
||||
// Split files into this section and others
|
||||
const sectionFiles = existing.files.filter((f) => f.platform === platform && f.fileType === fileType);
|
||||
const otherFiles = existing.files.filter((f) => !(f.platform === platform && f.fileType === fileType));
|
||||
|
||||
// Reorder section files according to newOrder
|
||||
const sectionMap = new Map(sectionFiles.map((f) => [f.id, f]));
|
||||
const reordered = newOrder.map((id) => sectionMap.get(id)).filter((f): f is FileEntry => f !== undefined);
|
||||
|
||||
// Append any section files not in newOrder (safety)
|
||||
for (const f of sectionFiles) {
|
||||
if (!newOrder.includes(f.id)) reordered.push(f);
|
||||
}
|
||||
|
||||
// Mark the dragged file as "reordered" if it was "unchanged"
|
||||
const finalSectionFiles = reordered.map((f) => {
|
||||
if (f.id === draggedFileId && f.status === "unchanged") {
|
||||
return { ...f, status: "reordered" as FileStatus };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
|
||||
const finalFiles = [...otherFiles, ...finalSectionFiles];
|
||||
|
||||
return {
|
||||
appFiles: {
|
||||
...state.appFiles,
|
||||
[key]: { ...existing, files: finalFiles },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearChanges: (domainId, appId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
set((state) => ({
|
||||
appFiles: {
|
||||
...state.appFiles,
|
||||
[key]: {
|
||||
files: [],
|
||||
initialized: false,
|
||||
originalSectionOrders: {},
|
||||
serverRevision: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearDomainChanges: (domainId) => {
|
||||
const prefix = `${domainId}:`;
|
||||
set((state) => {
|
||||
const newAppFiles: Record<string, AppFileState> = {};
|
||||
for (const key of Object.keys(state.appFiles)) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
newAppFiles[key] = state.appFiles[key];
|
||||
}
|
||||
}
|
||||
return { appFiles: newAppFiles };
|
||||
});
|
||||
},
|
||||
|
||||
getFiles: (domainId, appId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
return get().appFiles[key]?.files ?? [];
|
||||
},
|
||||
|
||||
getSectionFiles: (domainId, appId, platform, fileType) => {
|
||||
const key = appKey(domainId, appId);
|
||||
const files = get().appFiles[key]?.files ?? [];
|
||||
return files.filter((f) => f.platform === platform && f.fileType === fileType);
|
||||
},
|
||||
|
||||
getChangeCount: (domainId, appId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
const files = get().appFiles[key]?.files ?? [];
|
||||
|
||||
return {
|
||||
added: files.filter((f) => f.status === "added").length,
|
||||
deleted: files.filter((f) => f.status === "deleted").length,
|
||||
reordered: files.filter((f) => f.status === "reordered").length,
|
||||
};
|
||||
},
|
||||
|
||||
hasPendingChanges: (domainId, appId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
const files = get().appFiles[key]?.files ?? [];
|
||||
|
||||
return files.some((f) => f.status === "added" || f.status === "deleted" || f.status === "reordered");
|
||||
},
|
||||
|
||||
isInitialized: (domainId, appId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
return get().appFiles[key]?.initialized ?? false;
|
||||
},
|
||||
|
||||
setServerRevision: (domainId, appId, revision) => {
|
||||
const key = appKey(domainId, appId);
|
||||
set((state) => {
|
||||
const existing = state.appFiles[key] ?? {
|
||||
files: [],
|
||||
initialized: false,
|
||||
originalSectionOrders: {},
|
||||
serverRevision: null,
|
||||
};
|
||||
return {
|
||||
appFiles: {
|
||||
...state.appFiles,
|
||||
[key]: { ...existing, serverRevision: revision },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
getServerRevision: (domainId, appId) => {
|
||||
const key = appKey(domainId, appId);
|
||||
return get().appFiles[key]?.serverRevision ?? null;
|
||||
},
|
||||
|
||||
hasRemoteChange: (domainId, appId, currentRevision) => {
|
||||
const key = appKey(domainId, appId);
|
||||
const storedRevision = get().appFiles[key]?.serverRevision;
|
||||
if (storedRevision == null) return false;
|
||||
return storedRevision !== currentRevision;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "file-change-storage",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -5,11 +5,5 @@
|
||||
|
||||
export { useDomainStore } from "./domainStore";
|
||||
export { useAppStore } from "./appStore";
|
||||
export { useDeployStore } from "./deployStore";
|
||||
export { useVersionStore } from "./versionStore";
|
||||
export { useUIStore } from "./uiStore";
|
||||
export { useSessionStore } from "./sessionStore";
|
||||
export type { ViewMode, SelectedFile } from "./sessionStore";
|
||||
export { useThemeStore } from "./themeStore";
|
||||
export type { ThemeMode } from "./themeStore";
|
||||
export { useFileChangeStore } from "./fileChangeStore";
|
||||
export type { FileEntry, FileStatus } from "./fileChangeStore";
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Locale Store
|
||||
* Manages locale/language preference state with localStorage persistence
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { DEFAULT_LOCALE, type LocaleCode } from "@shared/types/locale";
|
||||
|
||||
interface LocaleState {
|
||||
// State
|
||||
locale: LocaleCode;
|
||||
|
||||
// Actions
|
||||
setLocale: (locale: LocaleCode) => void;
|
||||
}
|
||||
|
||||
export const useLocaleStore = create<LocaleState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
locale: DEFAULT_LOCALE,
|
||||
|
||||
// Actions
|
||||
setLocale: (locale) => set({ locale }),
|
||||
}),
|
||||
{
|
||||
name: "locale-storage",
|
||||
partialize: (state) => ({
|
||||
locale: state.locale,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Session Store
|
||||
* Manages temporary session state that should persist across app restarts
|
||||
* Stored in localStorage - no need for file-based persistence
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type ViewMode = "list" | "code";
|
||||
|
||||
export interface SelectedFile {
|
||||
type: "js" | "css";
|
||||
fileKey?: string;
|
||||
name: string;
|
||||
/** For locally added files: absolute path on disk */
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
// View state
|
||||
viewMode: ViewMode;
|
||||
selectedFile: SelectedFile | null;
|
||||
|
||||
// Actions
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setSelectedFile: (file: SelectedFile | null) => void;
|
||||
resetViewState: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
viewMode: "list" as ViewMode,
|
||||
selectedFile: null as SelectedFile | null,
|
||||
};
|
||||
|
||||
export const useSessionStore = create<SessionState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
|
||||
setSelectedFile: (selectedFile) => set({ selectedFile }),
|
||||
|
||||
resetViewState: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: "session-storage",
|
||||
// Only persist view state, not transient data
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
selectedFile: state.selectedFile,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Theme Store
|
||||
* Manages theme mode preference with localStorage persistence
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "auto";
|
||||
|
||||
interface ThemeState {
|
||||
// State
|
||||
themeMode: ThemeMode;
|
||||
|
||||
// Actions
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
themeMode: "auto",
|
||||
|
||||
// Actions
|
||||
setThemeMode: (mode) => set({ themeMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: "theme-storage",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* UI Store
|
||||
* Manages UI state persistence (sidebar width, collapsed states, etc.)
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface UIState {
|
||||
// Sidebar state
|
||||
sidebarWidth: number;
|
||||
siderCollapsed: boolean;
|
||||
domainExpanded: boolean;
|
||||
|
||||
// Domain customizations
|
||||
domainIconColors: Record<string, string>;
|
||||
|
||||
// App customizations
|
||||
pinnedApps: Record<string, string[]>; // domainId -> appId[]
|
||||
appSortBy: "createdAt" | "modifiedAt" | "name" | "appId";
|
||||
appSortOrder: "asc" | "desc";
|
||||
|
||||
// Actions
|
||||
setSidebarWidth: (width: number) => void;
|
||||
setSiderCollapsed: (collapsed: boolean) => void;
|
||||
setDomainExpanded: (expanded: boolean) => void;
|
||||
setDomainIconColor: (domainId: string, color: string) => void;
|
||||
setPinnedApps: (domainId: string, appIds: string[]) => void;
|
||||
togglePinnedApp: (domainId: string, appId: string) => void;
|
||||
setAppSortBy: (sortBy: "createdAt" | "modifiedAt" | "name" | "appId") => void;
|
||||
setAppSortOrder: (order: "asc" | "desc") => void;
|
||||
}
|
||||
|
||||
const MIN_SIDEBAR_WIDTH = 280;
|
||||
const MAX_SIDEBAR_WIDTH = 500;
|
||||
const DEFAULT_SIDEBAR_WIDTH = 360;
|
||||
|
||||
export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
|
||||
siderCollapsed: false,
|
||||
domainExpanded: true,
|
||||
domainIconColors: {},
|
||||
pinnedApps: {},
|
||||
appSortBy: "appId",
|
||||
appSortOrder: "desc",
|
||||
|
||||
// Actions
|
||||
setSidebarWidth: (width) =>
|
||||
set({
|
||||
sidebarWidth: Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)),
|
||||
}),
|
||||
|
||||
setSiderCollapsed: (collapsed) => set({ siderCollapsed: collapsed }),
|
||||
|
||||
setDomainExpanded: (expanded) => set({ domainExpanded: expanded }),
|
||||
|
||||
setDomainIconColor: (domainId, color) =>
|
||||
set((state) => ({
|
||||
domainIconColors: { ...state.domainIconColors, [domainId]: color },
|
||||
})),
|
||||
|
||||
setPinnedApps: (domainId, appIds) =>
|
||||
set((state) => ({
|
||||
pinnedApps: { ...state.pinnedApps, [domainId]: appIds },
|
||||
})),
|
||||
|
||||
togglePinnedApp: (domainId, appId) =>
|
||||
set((state) => {
|
||||
const currentPinned = state.pinnedApps[domainId] || [];
|
||||
const isPinned = currentPinned.includes(appId);
|
||||
const newPinned = isPinned ? currentPinned.filter((id) => id !== appId) : [appId, ...currentPinned];
|
||||
return {
|
||||
pinnedApps: { ...state.pinnedApps, [domainId]: newPinned },
|
||||
};
|
||||
}),
|
||||
|
||||
setAppSortBy: (sortBy) => set({ appSortBy: sortBy }),
|
||||
|
||||
setAppSortOrder: (order) => set({ appSortOrder: order }),
|
||||
}),
|
||||
{
|
||||
name: "ui-storage",
|
||||
partialize: (state) => ({
|
||||
sidebarWidth: state.sidebarWidth,
|
||||
siderCollapsed: state.siderCollapsed,
|
||||
domainExpanded: state.domainExpanded,
|
||||
domainIconColors: state.domainIconColors,
|
||||
pinnedApps: state.pinnedApps,
|
||||
appSortBy: state.appSortBy,
|
||||
appSortOrder: state.appSortOrder,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Version } from "@shared/types/version";
|
||||
import type { Version } from "@renderer/types/version";
|
||||
|
||||
interface VersionState {
|
||||
// State
|
||||
@@ -45,7 +45,8 @@ export const useVersionStore = create<VersionState>()((set) => ({
|
||||
removeVersion: (id) =>
|
||||
set((state) => ({
|
||||
versions: state.versions.filter((v) => v.id !== id),
|
||||
selectedVersion: state.selectedVersion?.id === id ? null : state.selectedVersion,
|
||||
selectedVersion:
|
||||
state.selectedVersion?.id === id ? null : state.selectedVersion,
|
||||
})),
|
||||
|
||||
setSelectedVersion: (version) => set({ selectedVersion: version }),
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
export interface Domain {
|
||||
id: string; // UUID
|
||||
name: string; // 自定义名称
|
||||
username: string; // 用户名(邮箱)
|
||||
domain: string; // Kintone 域名
|
||||
username: string; // 用户名(邮箱)
|
||||
authType: "password" | "api_token";
|
||||
apiToken?: string; // 可选,当 authType 为 api_token 时
|
||||
createdAt: string; // ISO 8601
|
||||
updatedAt: string; // ISO 8601
|
||||
}
|
||||
157
src/renderer/src/types/ipc.ts
Normal file
157
src/renderer/src/types/ipc.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* IPC communication types
|
||||
* Unified request/response format for all IPC handlers
|
||||
*/
|
||||
|
||||
import type { Domain, DomainWithPassword, DomainWithStatus } from "./domain";
|
||||
import type {
|
||||
KintoneSpace,
|
||||
KintoneApp,
|
||||
AppDetail,
|
||||
FileContent,
|
||||
} from "./kintone";
|
||||
import type { Version, DownloadMetadata, BackupMetadata } from "./version";
|
||||
|
||||
// Unified result type
|
||||
export type Result<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
// ==================== Domain IPC Types ====================
|
||||
|
||||
export interface CreateDomainParams {
|
||||
name: string;
|
||||
domain: string;
|
||||
username: string;
|
||||
password: string;
|
||||
authType: "password" | "api_token";
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDomainParams {
|
||||
id: string;
|
||||
name?: string;
|
||||
domain?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
authType?: "password" | "api_token";
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
export interface TestDomainConnectionParams {
|
||||
domain: string;
|
||||
username: string;
|
||||
authType: "password" | "api_token";
|
||||
password?: string;
|
||||
apiToken?: string;
|
||||
}
|
||||
|
||||
// ==================== Browse IPC Types ====================
|
||||
|
||||
export interface GetSpacesParams {
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
export interface GetAppsParams {
|
||||
domainId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export interface GetAppDetailParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
export interface GetFileContentParams {
|
||||
domainId: string;
|
||||
fileKey: string;
|
||||
}
|
||||
|
||||
// ==================== Deploy IPC Types ====================
|
||||
|
||||
export interface DeployFile {
|
||||
content: string;
|
||||
fileName: string;
|
||||
fileType: "js" | "css";
|
||||
position:
|
||||
| "pc_header"
|
||||
| "pc_body"
|
||||
| "pc_footer"
|
||||
| "mobile_header"
|
||||
| "mobile_body"
|
||||
| "mobile_footer"
|
||||
| "pc_css"
|
||||
| "mobile_css";
|
||||
}
|
||||
|
||||
export interface DeployParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
files: DeployFile[];
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
backupPath?: string;
|
||||
backupMetadata?: BackupMetadata;
|
||||
}
|
||||
|
||||
// ==================== Download IPC Types ====================
|
||||
|
||||
export interface DownloadParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
fileTypes?: ("pc_js" | "pc_css" | "mobile_js" | "mobile_css")[];
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
metadata?: DownloadMetadata;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ==================== Version IPC Types ====================
|
||||
|
||||
export interface GetVersionsParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
export interface RollbackParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
versionId: string;
|
||||
}
|
||||
|
||||
// ==================== IPC API Interface ====================
|
||||
|
||||
export interface ElectronAPI {
|
||||
// Domain management
|
||||
getDomains: () => Promise<Result<Domain[]>>;
|
||||
createDomain: (params: CreateDomainParams) => Promise<Result<Domain>>;
|
||||
updateDomain: (params: UpdateDomainParams) => Promise<Result<Domain>>;
|
||||
deleteDomain: (id: string) => Promise<Result<void>>;
|
||||
testConnection: (id: string) => Promise<Result<DomainWithStatus>>;
|
||||
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
|
||||
|
||||
// Browse
|
||||
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>;
|
||||
getApps: (params: GetAppsParams) => Promise<Result<KintoneApp[]>>;
|
||||
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
|
||||
getFileContent: (
|
||||
params: GetFileContentParams,
|
||||
) => Promise<Result<FileContent>>;
|
||||
|
||||
// Deploy
|
||||
deploy: (params: DeployParams) => Promise<DeployResult>;
|
||||
|
||||
// Download
|
||||
download: (params: DownloadParams) => Promise<DownloadResult>;
|
||||
|
||||
// Version management
|
||||
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;
|
||||
deleteVersion: (id: string) => Promise<Result<void>>;
|
||||
rollback: (params: RollbackParams) => Promise<DeployResult>;
|
||||
}
|
||||
112
src/renderer/src/types/kintone.ts
Normal file
112
src/renderer/src/types/kintone.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Kintone API response types
|
||||
* Based on REQUIREMENTS.md:331-345
|
||||
*/
|
||||
|
||||
// Space types
|
||||
export interface KintoneSpace {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
createdAt?: string;
|
||||
creator?: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
// App types
|
||||
export interface KintoneApp {
|
||||
appId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
spaceId?: string;
|
||||
createdAt: string;
|
||||
creator?: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
modifiedAt?: string;
|
||||
modifier?: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
// File configuration types
|
||||
export interface JSFileConfig {
|
||||
type: "FILE" | "URL";
|
||||
file?: {
|
||||
fileKey: string;
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface CSSFileConfig {
|
||||
type: "FILE" | "URL";
|
||||
file?: {
|
||||
fileKey: string;
|
||||
name: string;
|
||||
size: number;
|
||||
};
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// App customization config
|
||||
export interface AppCustomizationConfig {
|
||||
javascript?: {
|
||||
pc?: JSFileConfig[];
|
||||
mobile?: JSFileConfig[];
|
||||
};
|
||||
stylesheet?: {
|
||||
pc?: CSSFileConfig[];
|
||||
mobile?: CSSFileConfig[];
|
||||
};
|
||||
plugins?: AppPlugin[];
|
||||
}
|
||||
|
||||
export interface AppPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// App detail response
|
||||
export interface AppDetail {
|
||||
appId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
spaceId?: string;
|
||||
spaceName?: string;
|
||||
createdAt: string;
|
||||
creator: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
modifiedAt: string;
|
||||
modifier: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
customization?: AppCustomizationConfig;
|
||||
}
|
||||
|
||||
// File content
|
||||
export interface FileContent {
|
||||
fileKey: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
content?: string; // Base64 encoded or text
|
||||
}
|
||||
|
||||
// API Error
|
||||
export interface KintoneApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
id: string;
|
||||
errors?: Record<string, { messages: string[] }>;
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* IPC communication types
|
||||
* Unified request/response format for all IPC handlers
|
||||
*/
|
||||
|
||||
import type { Domain, DomainWithStatus } from "./domain";
|
||||
import type { AppResponse, AppDetail, FileContent } from "./kintone";
|
||||
import type { Version, DownloadMetadata, BackupMetadata } from "./version";
|
||||
|
||||
// Unified result type
|
||||
export type Result<T> = { success: true; data: T } | { success: false; error: string };
|
||||
|
||||
// ==================== Domain IPC Types ====================
|
||||
|
||||
export interface CreateDomainParams {
|
||||
name: string;
|
||||
domain: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UpdateDomainParams {
|
||||
id: string;
|
||||
name?: string;
|
||||
domain?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface TestDomainConnectionParams {
|
||||
domain: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// ==================== Browse IPC Types ====================
|
||||
export interface GetAppsParams {
|
||||
domainId: string;
|
||||
spaceId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface GetAppDetailParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
export interface GetFileContentParams {
|
||||
domainId: string;
|
||||
fileKey: string;
|
||||
}
|
||||
|
||||
export interface GetLocalFileContentParams {
|
||||
storagePath: string;
|
||||
}
|
||||
|
||||
export interface LocalFileContent {
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
content: string; // Base64 encoded
|
||||
}
|
||||
|
||||
// ==================== Deploy IPC Types ====================
|
||||
|
||||
/**
|
||||
* File for deployment - used in UI before actual deployment
|
||||
* This is a renderer-side type for managing files to be deployed
|
||||
*/
|
||||
export interface DeployFile {
|
||||
content: string;
|
||||
fileName: string;
|
||||
fileType: "js" | "css";
|
||||
position: string;
|
||||
}
|
||||
|
||||
export type FileStatus = "unchanged" | "added" | "deleted" | "reordered";
|
||||
|
||||
export interface DeployFileEntry {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileType: "js" | "css";
|
||||
platform: "desktop" | "mobile";
|
||||
status: FileStatus;
|
||||
/** For unchanged FILE-type files: the Kintone file key */
|
||||
fileKey?: string;
|
||||
/** For unchanged URL-type files: the URL */
|
||||
url?: string;
|
||||
/** For added files: absolute path to file on disk */
|
||||
storagePath?: string;
|
||||
}
|
||||
|
||||
export interface DeployParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
files: DeployFileEntry[];
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
backupPath?: string;
|
||||
backupMetadata?: BackupMetadata;
|
||||
}
|
||||
|
||||
// ==================== File Storage IPC Types ====================
|
||||
|
||||
export interface FileSaveParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
platform: "desktop" | "mobile";
|
||||
fileType: "js" | "css";
|
||||
/** Caller-generated UUID used to name the stored file */
|
||||
fileId: string;
|
||||
/** Absolute path to the source file (from drag/drop or file picker) */
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface FileSaveResult {
|
||||
storagePath: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface FileDeleteParams {
|
||||
storagePath: string;
|
||||
}
|
||||
|
||||
// ==================== Download IPC Types ====================
|
||||
|
||||
export interface DownloadParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
fileTypes?: ("pc_js" | "pc_css" | "mobile_js" | "mobile_css")[];
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
metadata?: DownloadMetadata;
|
||||
error?: string;
|
||||
}
|
||||
export interface DownloadAllZipResult {
|
||||
success: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
export interface DownloadAllZipParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
savePath: string;
|
||||
}
|
||||
|
||||
// ==================== Version IPC Types ====================
|
||||
|
||||
export interface GetVersionsParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
}
|
||||
|
||||
export interface RollbackParams {
|
||||
domainId: string;
|
||||
appId: string;
|
||||
versionId: string;
|
||||
}
|
||||
// ==================== Locale IPC Types ====================
|
||||
|
||||
export interface SetLocaleParams {
|
||||
locale: import("./locale").LocaleCode;
|
||||
}
|
||||
|
||||
// ==================== Dialog IPC Types ====================
|
||||
|
||||
export interface ShowSaveDialogParams {
|
||||
defaultPath?: string;
|
||||
}
|
||||
|
||||
export interface SaveFileContentParams {
|
||||
filePath: string;
|
||||
content: string; // Base64 encoded
|
||||
}
|
||||
// ==================== App Version & Update IPC Types ====================
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
releaseDate?: string;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
export interface CheckUpdateResult {
|
||||
hasUpdate: boolean;
|
||||
updateInfo?: UpdateInfo;
|
||||
}
|
||||
|
||||
// ==================== IPC API Interface ====================
|
||||
|
||||
export interface ElectronAPI {
|
||||
// Domain management
|
||||
getDomains: () => Promise<Result<Domain[]>>;
|
||||
createDomain: (params: CreateDomainParams) => Promise<Result<Domain>>;
|
||||
updateDomain: (params: UpdateDomainParams) => Promise<Result<Domain>>;
|
||||
deleteDomain: (id: string) => Promise<Result<void>>;
|
||||
testConnection: (id: string) => Promise<Result<DomainWithStatus>>;
|
||||
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
|
||||
|
||||
// Browse
|
||||
getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>;
|
||||
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
|
||||
getFileContent: (params: GetFileContentParams) => Promise<Result<FileContent>>;
|
||||
getLocalFileContent: (params: GetLocalFileContentParams) => Promise<Result<LocalFileContent>>;
|
||||
|
||||
// Deploy
|
||||
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
|
||||
|
||||
// File storage
|
||||
saveFile: (params: FileSaveParams) => Promise<Result<FileSaveResult>>;
|
||||
deleteFile: (params: FileDeleteParams) => Promise<Result<void>>;
|
||||
|
||||
// Download
|
||||
download: (params: DownloadParams) => Promise<DownloadResult>;
|
||||
downloadAllZip: (params: DownloadAllZipParams) => Promise<DownloadAllZipResult>;
|
||||
|
||||
// Version management
|
||||
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;
|
||||
deleteVersion: (id: string) => Promise<Result<void>>;
|
||||
rollback: (params: RollbackParams) => Promise<DeployResult>;
|
||||
|
||||
// Dialog
|
||||
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
||||
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Kintone API response types
|
||||
* Using SDK types where possible, custom types only for business-layer aggregation
|
||||
*/
|
||||
|
||||
import type { KintoneRestAPIClient } from "@kintone/rest-api-client";
|
||||
|
||||
/**
|
||||
* API Error - simplified from SDK's KintoneRestAPIError
|
||||
*/
|
||||
export interface KintoneApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
id: string;
|
||||
errors?: Record<string, { messages: string[] }>;
|
||||
}
|
||||
|
||||
// ============== SDK Type Extraction ==============
|
||||
type KintoneClient = KintoneRestAPIClient;
|
||||
|
||||
/** App response from getApp/getApps */
|
||||
export type AppResponse = Awaited<ReturnType<KintoneClient["app"]["getApp"]>>;
|
||||
|
||||
/** App customization request - parameters for updateAppCustomize */
|
||||
export type AppCustomizeParameter = Parameters<KintoneClient["app"]["updateAppCustomize"]>[number];
|
||||
|
||||
/** App customization response */
|
||||
export type AppCustomizeResponse = Awaited<ReturnType<KintoneClient["app"]["getAppCustomize"]>>;
|
||||
|
||||
// ============== Custom Business Types ==============
|
||||
|
||||
/**
|
||||
* App detail - combines app info + customization
|
||||
* This is a business-layer type that aggregates data from multiple API calls
|
||||
*/
|
||||
export interface AppDetail extends AppResponse {
|
||||
customization?: AppCustomizeResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* File content - file metadata + content
|
||||
* SDK's downloadFile only returns ArrayBuffer, we add metadata
|
||||
*/
|
||||
export interface FileContent {
|
||||
fileKey: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
content?: string; // Base64 encoded or text
|
||||
}
|
||||
|
||||
/**
|
||||
* File config for customization
|
||||
* Using SDK's type directly from AppCustomizeResponse
|
||||
*
|
||||
* FILE type response includes: { type: "FILE", file: { fileKey: string, name: string, contentType: string, size: string } }
|
||||
* URL type response includes: { type: "URL", url: string }
|
||||
*
|
||||
* Use getDisplayName() utility to get user-friendly display names.
|
||||
*/
|
||||
export type FileConfigParameter = NonNullable<NonNullable<AppCustomizeParameter["desktop"]>["js"]>[number];
|
||||
export type FileConfigResponse = NonNullable<AppCustomizeResponse["desktop"]>["js"][number];
|
||||
|
||||
// ============== Type Utilities ==============
|
||||
type ExtractUrlType<T> = T extends { type: "URL" } ? T : never;
|
||||
export type UrlResourceParameter = ExtractUrlType<FileConfigParameter>;
|
||||
export type UrlResourceResponse = ExtractUrlType<FileConfigResponse>;
|
||||
|
||||
type ExtractFileType<T> = T extends { type: "FILE" } ? T : never;
|
||||
export type FileResourceParameter = ExtractFileType<FileConfigParameter>;
|
||||
export type FileResourceResponse = ExtractFileType<FileConfigResponse>;
|
||||
|
||||
// ============== Type Guards ==============
|
||||
|
||||
/**
|
||||
* Check if resource is URL type - works with both Response and Parameter types
|
||||
* TypeScript will automatically narrow the type based on usage
|
||||
*/
|
||||
export function isUrlResource(resource: FileConfigResponse | FileConfigParameter): resource is UrlResourceParameter | UrlResourceResponse {
|
||||
return resource.type === "URL" && !!resource.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if resource is FILE type - works with both Response and Parameter types
|
||||
* TypeScript will automatically narrow the type based on usage
|
||||
*/
|
||||
export function isFileResource(resource: FileConfigResponse | FileConfigParameter): resource is FileResourceParameter | FileResourceResponse {
|
||||
return resource.type === "FILE" && !!resource.file?.fileKey;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Locale type definitions
|
||||
* Supported locales: zh-CN, ja-JP, en-US
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported locale codes
|
||||
*/
|
||||
export type LocaleCode = "zh-CN" | "ja-JP" | "en-US";
|
||||
|
||||
/**
|
||||
* Locale configuration
|
||||
*/
|
||||
export interface LocaleConfig {
|
||||
/** Locale identifier */
|
||||
code: LocaleCode;
|
||||
/** Display name in English (e.g., "Chinese Simplified") */
|
||||
name: string;
|
||||
/** Native display name (e.g., "简体中文") */
|
||||
nativeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default locale code
|
||||
*/
|
||||
export const DEFAULT_LOCALE: LocaleCode = "ja-JP";
|
||||
/**
|
||||
* Available locale configurations
|
||||
*/
|
||||
export const LOCALES: LocaleConfig[] = [
|
||||
{
|
||||
code: "ja-JP",
|
||||
name: "Japanese",
|
||||
nativeName: "日本語",
|
||||
},
|
||||
{
|
||||
code: "zh-CN",
|
||||
name: "Chinese Simplified",
|
||||
nativeName: "简体中文",
|
||||
},
|
||||
{
|
||||
code: "en-US",
|
||||
name: "English",
|
||||
nativeName: "English",
|
||||
},
|
||||
];
|
||||
@@ -1,37 +0,0 @@
|
||||
import { isFileResource, isUrlResource, type FileConfigResponse } from "@shared/types/kintone";
|
||||
|
||||
/**
|
||||
* Get user-friendly display name for a file config
|
||||
*/
|
||||
export function getDisplayName(file: FileConfigResponse, fileType: "js" | "css", index: number): string {
|
||||
if (isUrlResource(file)) {
|
||||
return extractFilenameFromUrl(file.url) || `URL ${index + 1}`;
|
||||
}
|
||||
|
||||
if (isFileResource(file)) {
|
||||
return file.file.name;
|
||||
}
|
||||
|
||||
return `Unknown File ${index + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from URL
|
||||
*/
|
||||
export function extractFilenameFromUrl(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
return segments.length > 0 ? segments[segments.length - 1] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fileKey from file config (only for FILE type)
|
||||
*/
|
||||
export function getFileKey(file: FileConfigResponse): string | undefined {
|
||||
return isFileResource(file) ? file.file?.fileKey : undefined;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { AppCustomizeResponse, FileConfigResponse } from "@shared/types/kintone";
|
||||
import { isFileResource, isUrlResource } from "@shared/types/kintone";
|
||||
import type { FileEntry } from "@renderer/stores/fileChangeStore";
|
||||
import { getDisplayName, getFileKey } from "./fileDisplay";
|
||||
|
||||
/**
|
||||
* Transform Kintone customize data into FileEntry array format
|
||||
* Used to initialize file change store from Kintone API response
|
||||
*/
|
||||
export function transformCustomizeToFiles(customize: AppCustomizeResponse | undefined): Array<Omit<FileEntry, "status">> {
|
||||
if (!customize) return [];
|
||||
|
||||
const files: Array<Omit<FileEntry, "status">> = [];
|
||||
|
||||
// Desktop JS files
|
||||
if (customize.desktop?.js) {
|
||||
files.push(...customize.desktop.js.map((file, index) => transformFileConfig(file, "js", "desktop", index)));
|
||||
}
|
||||
|
||||
// Desktop CSS files
|
||||
if (customize.desktop?.css) {
|
||||
files.push(...customize.desktop.css.map((file, index) => transformFileConfig(file, "css", "desktop", index)));
|
||||
}
|
||||
|
||||
// Mobile JS files
|
||||
if (customize.mobile?.js) {
|
||||
files.push(...customize.mobile.js.map((file, index) => transformFileConfig(file, "js", "mobile", index)));
|
||||
}
|
||||
|
||||
// Mobile CSS files
|
||||
if (customize.mobile?.css) {
|
||||
files.push(...customize.mobile.css.map((file, index) => transformFileConfig(file, "css", "mobile", index)));
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single file config into FileEntry format
|
||||
*/
|
||||
function transformFileConfig(file: FileConfigResponse, fileType: "js" | "css", platform: "desktop" | "mobile", index: number): Omit<FileEntry, "status"> {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
fileName: getDisplayName(file, fileType, index),
|
||||
fileType,
|
||||
platform,
|
||||
size: isFileResource(file) ? parseInt(file.file.size ?? "0", 10) || undefined : undefined,
|
||||
fileKey: getFileKey(file),
|
||||
url: isUrlResource(file) ? file.url : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,14 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": [
|
||||
"electron.vite.config.ts",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/renderer/**/*",
|
||||
"src/shared/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.d.ts"
|
||||
],
|
||||
"include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@main/*": ["src/main/*"],
|
||||
"@preload/*": ["src/preload/*"],
|
||||
"@renderer/*": ["src/renderer/src/*"],
|
||||
"@shared/*": ["src/shared/*"]
|
||||
"@renderer/*": ["src/renderer/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": ["src/renderer/src/**/*", "src/shared/**/*"],
|
||||
"include": [
|
||||
"src/renderer/src/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@renderer/*": ["src/renderer/src/*"],
|
||||
"@shared/*": ["src/shared/*"]
|
||||
"@renderer/*": ["src/renderer/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user