Compare commits
1 Commits
cf903872b8
...
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
|
# AGENTS.md
|
||||||
|
|
||||||
Kintone Customize Manager - Electron + React 应用,用于管理 Kintone 自定义资源。
|
Kintone Customize Manager 项目开发指南。
|
||||||
|
|
||||||
## 1. 构建命令
|
## 1. 构建命令
|
||||||
|
|
||||||
|
### 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 开发(HMR + 热重载)
|
# 启动开发服务器(HMR + 热重载)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# 类型检查
|
# 类型检查
|
||||||
npx tsc --noEmit
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
# 构建
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建生产版本
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# 打包
|
# 打包应用
|
||||||
npm run package:win # Windows
|
npm run package:win # Windows
|
||||||
npm run package:mac # macOS
|
npm run package:mac # macOS
|
||||||
npm run package:linux # Linux
|
npm run package:linux # Linux
|
||||||
|
|
||||||
# 代码质量
|
|
||||||
npm run lint # ESLint 检查
|
|
||||||
npm run format # Prettier 格式化
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. 项目架构
|
### 代码质量
|
||||||
|
|
||||||
```
|
```bash
|
||||||
src/
|
# ESLint 检查
|
||||||
├── main/ # Electron 主进程
|
npm run lint
|
||||||
│ ├── index.ts # 入口,创建窗口
|
|
||||||
│ ├── ipc-handlers.ts # IPC 处理器(所有通信入口)
|
# 格式化代码
|
||||||
│ ├── storage.ts # 文件存储 + 密码加密
|
npm run format
|
||||||
│ └── 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 翻译文件
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 数据流
|
## 2. 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
Renderer (React) → Preload API → IPC → Main Process → Storage/Kintone API
|
kintone-customize-manager/
|
||||||
↓
|
├── src/
|
||||||
Result<T> 返回
|
│ ├── 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. 路径别名
|
## 3. 路径别名
|
||||||
|
|
||||||
| 别名 | 路径 |
|
| 别名 | 路径 |
|
||||||
| ------------- | -------------------- |
|
|------|------|
|
||||||
| `@renderer/*` | `src/renderer/src/*` |
|
| `@renderer/*` | `src/renderer/src/*` |
|
||||||
| `@main/*` | `src/main/*` |
|
| `@main/*` | `src/main/*` |
|
||||||
| `@preload/*` | `src/preload/*` |
|
| `@preload/*` | `src/preload/*` |
|
||||||
| `@shared/*` | `src/shared/*` |
|
|
||||||
|
使用示例:
|
||||||
|
```typescript
|
||||||
|
import { useStore } from '@renderer/stores'
|
||||||
|
import { ipcHandler } from '@main/ipc-handlers'
|
||||||
|
```
|
||||||
|
|
||||||
## 4. 代码风格
|
## 4. 代码风格
|
||||||
|
|
||||||
@@ -70,139 +83,283 @@ Renderer (React) → Preload API → IPC → Main Process → Storage/Kintone AP
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 1. Node.js 内置模块
|
// 1. Node.js 内置模块
|
||||||
import { join } from "path";
|
import { join } from 'path'
|
||||||
import { app, BrowserWindow } from "electron";
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
|
||||||
// 2. 第三方库
|
// 2. 第三方库
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Button, Layout } from "antd";
|
import { Button, Layout } from 'antd'
|
||||||
|
|
||||||
// 3. 项目内部模块(别名)
|
// 3. 项目内部模块(使用别名)
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from '@renderer/stores'
|
||||||
|
import { formatDate } from '@renderer/utils'
|
||||||
|
|
||||||
// 4. 相对导入
|
// 4. 相对导入
|
||||||
import "./styles.css";
|
import './styles.css'
|
||||||
```
|
```
|
||||||
|
|
||||||
### 命名规范
|
### 命名规范
|
||||||
|
|
||||||
- 组件文件/名: `PascalCase` (e.g., `DomainManager.tsx`)
|
| 类型 | 规范 | 示例 |
|
||||||
- 工具函数文件: `camelCase` (e.g., `formatDate.ts`)
|
|------|------|------|
|
||||||
- Store 文件: `camelCase + Store` (e.g., `domainStore.ts`)
|
| 组件文件 | PascalCase | `DomainManager.tsx` |
|
||||||
- 函数/变量: `camelCase` (e.g., `handleSubmit`)
|
| 工具函数文件 | camelCase | `formatDate.ts` |
|
||||||
- 常量: `UPPER_SNAKE_CASE` (e.g., `MAX_FILE_SIZE`)
|
| Store 文件 | camelCase + Store | `domainStore.ts` |
|
||||||
- 类型/接口: `PascalCase` (e.g., `DomainConfig`)
|
| 类型文件 | camelCase | `types.ts` |
|
||||||
|
| 组件名 | PascalCase | `DomainManager` |
|
||||||
|
| 函数/变量 | camelCase | `handleSubmit` |
|
||||||
|
| 常量 | UPPER_SNAKE_CASE | `MAX_FILE_SIZE` |
|
||||||
|
| 类型/接口 | PascalCase | `DomainConfig` |
|
||||||
|
|
||||||
### TypeScript 规范
|
### TypeScript 规范
|
||||||
|
|
||||||
- 显式类型定义,避免 `any`
|
```typescript
|
||||||
- 使用字面量联合类型(如 `authType: "password" | "api_token"`)
|
// 显式类型定义
|
||||||
- 异步函数返回 `Promise<T>`
|
interface DomainConfig {
|
||||||
- 使用类型守卫处理 `unknown`
|
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 组件规范
|
### React 组件规范
|
||||||
|
|
||||||
- Hooks 放在组件顶部
|
```typescript
|
||||||
- 事件处理函数使用 `useCallback`
|
// 函数组件优先
|
||||||
- 使用 TypeScript 显式定义 props 类型
|
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 规范
|
### Zustand Store 规范
|
||||||
|
|
||||||
- 使用 `persist` 中间件持久化状态
|
|
||||||
- 定义接口明确 state 和 actions 类型
|
|
||||||
|
|
||||||
## 5. IPC 通信规范
|
|
||||||
|
|
||||||
```typescript
|
```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>`
|
## 5. 错误处理
|
||||||
- Preload 通过 `contextBridge.exposeInMainWorld` 暴露 API
|
|
||||||
- 所有 IPC handlers 集中在 `src/main/ipc-handlers.ts`
|
|
||||||
|
|
||||||
## 6. UI 组件规范
|
### 主进程错误处理
|
||||||
|
|
||||||
**UI Kit 优先使用 LobeHub UI** (`@lobehub/ui`),其次使用 Ant Design 6 + antd-style:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Button } from "@lobehub/ui";
|
// IPC 处理错误
|
||||||
import { createStyles } from "antd-style";
|
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`
|
container: css`
|
||||||
padding: ${token.paddingLG}px;
|
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`
|
<ConfigProvider locale={zhCN}>
|
||||||
- 翻译文件位置: `src/renderer/src/locales/{locale}/{namespace}.json`
|
<App />
|
||||||
- 使用 `react-i18next` 进行翻译
|
</ConfigProvider>
|
||||||
- Ant Design 默认使用日文: `import jaJP from 'antd/locale/ja_JP'`
|
```
|
||||||
|
|
||||||
## 8. 安全规范
|
## 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
|
```bash
|
||||||
# 使用 fnm wrapper(推荐)
|
# 方式一:使用 wrapper 脚本
|
||||||
~/.config/opencode/node-fnm-wrapper.sh npm run dev
|
~/.config/opencode/node-fnm-wrapper.sh npm run dev
|
||||||
|
|
||||||
# 或手动加载 fnm
|
# 方式二:手动加载
|
||||||
eval "$(fnm env --use-on-cd)" && npm run dev
|
eval "$(fnm env --use-on-cd)" && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## 11. 技术栈约束
|
## 10. 注意事项
|
||||||
|
|
||||||
1. **React 19**: 使用 `@types/react@^19.0.0`
|
1. **ESM Only**: LobeHub UI 仅支持 ESM,确保 `tsconfig.json` 中 `"module": "ESNext"`
|
||||||
2. **CSS 方案**: 使用 `antd-style`,禁止 Tailwind
|
2. **React 19**: 必须使用 `@types/react@^19.0.0` 和 `@types/react-dom@^19.0.0`
|
||||||
3. **禁止 `as any`**: 使用类型守卫或 `unknown`
|
3. **CSS 方案**: 使用 `antd-style`,不使用 Tailwind CSS
|
||||||
4. **函数组件优先**: 禁止 class 组件
|
4. **Context Isolation**: 必须启用 `contextIsolation: true`
|
||||||
|
5. **禁止类型断言**: 避免使用 `as any`,优先使用类型守卫
|
||||||
## 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
|
|
||||||
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 # 渲染进程入口
|
│ │ ├─ main.tsx # 渲染进程入口
|
||||||
│ │ ├─ App.tsx
|
│ │ ├─ App.tsx
|
||||||
│ │ ├─ components/ # React 组件
|
│ │ ├─ components/ # React 组件
|
||||||
│ │ │ ├─ AppList/
|
│ │ │ ├─ DomainManager/
|
||||||
|
│ │ │ ├─ SpaceTree/
|
||||||
│ │ │ ├─ AppDetail/
|
│ │ │ ├─ AppDetail/
|
||||||
│ │ │ ├─ FileUploader/
|
│ │ │ ├─ FileUploader/
|
||||||
│ │ │ ├─ CodeViewer/
|
│ │ │ ├─ CodeViewer/
|
||||||
@@ -251,7 +252,8 @@ kintone-customize-manager/
|
|||||||
- 显示 Domain 名称和域名
|
- 显示 Domain 名称和域名
|
||||||
|
|
||||||
**FR-DOMAIN-005**: 切换 Domain
|
**FR-DOMAIN-005**: 切换 Domain
|
||||||
- 切换后自动加载该 Domain 下的 App 列表
|
- 用户可从 Domain 列表快速切换当前工作的 Domain
|
||||||
|
- 切换后自动加载该 Domain 下的 Space 和 App 列表
|
||||||
|
|
||||||
**FR-DOMAIN-006**: 密码加密存储
|
**FR-DOMAIN-006**: 密码加密存储
|
||||||
- 使用 keytar 将密码加密存储到系统密钥链
|
- 使用 keytar 将密码加密存储到系统密钥链
|
||||||
@@ -285,45 +287,40 @@ interface Domain {
|
|||||||
|
|
||||||
### 3.2 资源浏览
|
### 3.2 资源浏览
|
||||||
|
|
||||||
浏览当前 Domain 下的所有 App,查看应用的自定义资源配置。
|
#### 3.2.1 功能描述
|
||||||
|
|
||||||
**FR-BROWSE-001**: 获取 App 列表
|
浏览当前 Domain 下的所有 Space 和 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 名称搜索
|
|
||||||
- 搜索结果实时过滤
|
|
||||||
|
|
||||||
**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 名称
|
- App 名称
|
||||||
- App ID
|
- App ID
|
||||||
- 所属 Space ID(如果 App 隶属于某个 Space)
|
- 所属 Space
|
||||||
- 创建时间
|
- 创建时间
|
||||||
- 最后更新时间
|
- 最后更新时间
|
||||||
|
|
||||||
**FR-BROWSE-005**: 查看自定义资源配置
|
**FR-BROWSE-004**: 查看自定义资源配置
|
||||||
- 查看 PC 端的 JavaScript 文件配置
|
- 查看 PC 端的 JavaScript 文件配置
|
||||||
- 查看 PC 端的 CSS 文件配置
|
- 查看 PC 端的 CSS 文件配置
|
||||||
- 查看移动端的 JavaScript 文件配置
|
- 查看移动端的 JavaScript 文件配置
|
||||||
- 查看移动端的 CSS 文件配置
|
- 查看移动端的 CSS 文件配置
|
||||||
- 查看已安装的 Plugin 列表(只读,后续版本支持管理)
|
- 查看已安装的 Plugin 列表(只读,后续版本支持管理)
|
||||||
|
|
||||||
**FR-BROWSE-006**: 查看文件详情
|
**FR-BROWSE-005**: 查看文件详情
|
||||||
- 文件名
|
- 文件名
|
||||||
- 文件类型(JS/CSS)
|
- 文件类型(JS/CSS)
|
||||||
- 部署位置(PC/移动端)
|
- 部署位置(PC/移动端)
|
||||||
@@ -334,12 +331,11 @@ NK|
|
|||||||
#### 3.2.3 Kintone API 端点
|
#### 3.2.3 Kintone API 端点
|
||||||
|
|
||||||
```
|
```
|
||||||
# 获取 App 列表(支持分页)
|
# 获取 Space 列表
|
||||||
GET /k/v1/apps.json?limit={limit}&offset={offset}
|
GET /k/v1/space.json
|
||||||
# 限制:limit 最大 100,offset 从 0 开始
|
|
||||||
# 注意:API 不支持排序,需客户端排序
|
# 获取 App 列表(按 Space)
|
||||||
# 注意:API 不返回 totalCount,需通过返回数量判断是否还有更多
|
GET /k/v1/apps.json?space={spaceId}
|
||||||
# 注意:spaceId 已包含在响应中(null 表示不属于任何 Space)
|
|
||||||
|
|
||||||
# 获取 App 配置
|
# 获取 App 配置
|
||||||
GET /k/v1/app.json?app={appId}
|
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}
|
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 下载已部署代码
|
### 3.3 下载已部署代码
|
||||||
@@ -924,7 +889,8 @@ autoUpdater.on('update-downloaded', (info) => {
|
|||||||
|
|
||||||
| 术语 | 说明 |
|
| 术语 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| Space | Kintone 空间,应用的容器(注:API 不支持获取所有 Space) |
|
| Domain | Kintone 实例,如 company.kintone.com |
|
||||||
|
| Space | Kintone 空间,应用的容器 |
|
||||||
| App | Kintone 应用 |
|
| App | Kintone 应用 |
|
||||||
| FileKey | Kintone 文件的唯一标识 |
|
| FileKey | Kintone 文件的唯一标识 |
|
||||||
| IPC | Electron 进程间通信 |
|
| 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.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({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@shared': resolve('src/shared'),
|
|
||||||
'@main': resolve('src/main')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@shared': resolve('src/shared'),
|
|
||||||
'@preload': resolve('src/preload')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
renderer: {
|
renderer: {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@renderer': resolve('src/renderer/src'),
|
'@renderer': resolve('src/renderer/src')
|
||||||
'@shared': resolve('src/shared')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [react()]
|
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/merge": "^6.9.0",
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"@codemirror/view": "^6.36.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/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^3.0.0",
|
"@electron-toolkit/utils": "^3.0.0",
|
||||||
"@kintone/rest-api-client": "^6.1.2",
|
"@lobehub/ui": "^5.5.0",
|
||||||
"@lobehub/ui": "^5.5.1",
|
|
||||||
"@types/adm-zip": "^0.5.7",
|
|
||||||
"@uiw/react-codemirror": "^4.23.0",
|
"@uiw/react-codemirror": "^4.23.0",
|
||||||
"adm-zip": "^0.5.16",
|
|
||||||
"antd": "^6.1.0",
|
"antd": "^6.1.0",
|
||||||
"antd-style": "^4.1.0",
|
"antd-style": "^4.1.0",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
"i18next": "^25.8.18",
|
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"motion": "^12.0.0",
|
"motion": "^12.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-i18next": "^16.5.8",
|
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -51,8 +41,6 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^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",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"electron": "^30.5.1",
|
"electron": "^30.5.1",
|
||||||
"electron-builder": "^26.0.0",
|
"electron-builder": "^26.0.0",
|
||||||
@@ -62,7 +50,6 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
"prettier": "^3.2.0",
|
"prettier": "^3.2.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"typescript-eslint": "^8.57.0",
|
|
||||||
"vite": "^5.4.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" />
|
/// <reference types="electron-vite/node" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly MAIN_VITE_API_URL: string;
|
readonly MAIN_VITE_API_URL: string
|
||||||
readonly MAIN_VITE_DEBUG: string;
|
readonly MAIN_VITE_DEBUG: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
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();
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,11 @@ import { app, shell, BrowserWindow, ipcMain } from "electron";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||||
import { registerIpcHandlers } from "./ipc-handlers";
|
import { registerIpcHandlers } from "./ipc-handlers";
|
||||||
import { initializeStorage, isSecureStorageAvailable, getStorageBackend } from "./storage";
|
import {
|
||||||
|
initializeStorage,
|
||||||
|
isSecureStorageAvailable,
|
||||||
|
getStorageBackend,
|
||||||
|
} from "./storage";
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@@ -15,6 +19,7 @@ function createWindow(): void {
|
|||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
titleBarStyle: "hiddenInset",
|
titleBarStyle: "hiddenInset",
|
||||||
trafficLightPosition: { x: 15, y: 10 },
|
trafficLightPosition: { x: 15, y: 10 },
|
||||||
|
...(process.platform === "linux" ? { icon } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, "../preload/index.js"),
|
preload: join(__dirname, "../preload/index.js"),
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
@@ -27,25 +32,11 @@ function createWindow(): void {
|
|||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 阻止 Alt 键显示默认菜单栏
|
|
||||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
|
||||||
if (input.key === "Alt" && input.type === "keyDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
shell.openExternal(details.url);
|
shell.openExternal(details.url);
|
||||||
return { action: "deny" };
|
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.
|
// HMR for renderer base on electron-vite cli.
|
||||||
// Load the remote URL for development or the local html file for production.
|
// Load the remote URL for development or the local html file for production.
|
||||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||||
@@ -67,7 +58,9 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
// Check secure storage availability
|
// Check secure storage availability
|
||||||
if (!isSecureStorageAvailable()) {
|
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.");
|
console.warn("Passwords will not be securely encrypted on this system.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +74,9 @@ app.whenReady().then(() => {
|
|||||||
optimizer.watchWindowShortcuts(window);
|
optimizer.watchWindowShortcuts(window);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// IPC test (keep for debugging)
|
||||||
|
ipcMain.on("ping", () => console.log("pong"));
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
app.on("activate", function () {
|
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";
|
* Kintone REST API Client
|
||||||
import type { DomainWithPassword } from "@shared/types/domain";
|
* Handles authentication and API calls to Kintone
|
||||||
import { type AppResponse, type AppDetail, type FileContent, type KintoneApiError, AppCustomizeParameter } from "@shared/types/kintone";
|
* Based on REQUIREMENTS.md:331-345, 502-522
|
||||||
import { getErrorMessage } from "./errors";
|
*/
|
||||||
|
|
||||||
|
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
|
* Custom error class for Kintone API errors
|
||||||
@@ -12,7 +24,11 @@ export class KintoneError extends Error {
|
|||||||
public readonly id?: string;
|
public readonly id?: string;
|
||||||
public readonly statusCode?: number;
|
public readonly statusCode?: number;
|
||||||
|
|
||||||
constructor(message: string, apiError?: KintoneApiError, statusCode?: number) {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
apiError?: KintoneApiError,
|
||||||
|
statusCode?: number,
|
||||||
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "KintoneError";
|
this.name = "KintoneError";
|
||||||
this.code = apiError?.code;
|
this.code = apiError?.code;
|
||||||
@@ -25,163 +41,468 @@ export class KintoneError extends Error {
|
|||||||
* Kintone REST API Client
|
* Kintone REST API Client
|
||||||
*/
|
*/
|
||||||
export class KintoneClient {
|
export class KintoneClient {
|
||||||
private client: KintoneRestAPIClient;
|
private baseUrl: string;
|
||||||
|
private headers: Headers;
|
||||||
private domain: string;
|
private domain: string;
|
||||||
|
|
||||||
constructor(domainConfig: DomainWithPassword) {
|
constructor(domainConfig: DomainWithPassword) {
|
||||||
this.domain = domainConfig.domain;
|
this.domain = domainConfig.domain;
|
||||||
|
this.baseUrl = `https://${domainConfig.domain}/k/v1/`;
|
||||||
this.client = new KintoneRestAPIClient({
|
this.headers = new Headers({
|
||||||
baseUrl: `https://${domainConfig.domain}`,
|
"Content-Type": "application/json",
|
||||||
auth: {
|
|
||||||
username: domainConfig.username,
|
|
||||||
password: domainConfig.password,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
* Make an API request with timeout
|
||||||
const apiError = error as KintoneRestAPIError;
|
*/
|
||||||
return new KintoneError(apiError.message, { code: apiError.code, message: apiError.message, id: apiError.id }, apiError.status);
|
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 {
|
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) {
|
} 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 ====================
|
// ==================== App APIs ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all apps with pagination support
|
* Get list of apps, optionally filtered by space
|
||||||
* Fetches all apps by making multiple requests if needed
|
* GET /k/v1/apps.json?space={spaceId}
|
||||||
*/
|
*/
|
||||||
async getApps(options?: { limit?: number; offset?: number }): Promise<AppResponse[]> {
|
async getApps(spaceId?: string): Promise<KintoneApp[]> {
|
||||||
return this.withErrorHandling(async () => {
|
interface AppsResponse {
|
||||||
// If pagination options provided, use them directly
|
apps: Array<{
|
||||||
if (options?.limit !== undefined || options?.offset !== undefined) {
|
appId: string;
|
||||||
const params: { limit?: number; offset?: number } = {};
|
name: string;
|
||||||
if (options.limit) params.limit = options.limit;
|
code?: string;
|
||||||
if (options.offset) params.offset = options.offset;
|
spaceId?: string;
|
||||||
const response = await this.client.app.getApps(params);
|
createdAt: string;
|
||||||
return response.apps;
|
creator?: { code: string; name: string };
|
||||||
}
|
modifiedAt?: string;
|
||||||
|
modifier?: { code: string; name: string };
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise, fetch all apps (pagination handled internally)
|
let endpoint = "apps.json";
|
||||||
const allApps: AppResponse[] = [];
|
if (spaceId) {
|
||||||
const limit = 100; // Max allowed by Kintone API
|
endpoint += `?space=${spaceId}`;
|
||||||
let offset = 0;
|
}
|
||||||
let hasMore = true;
|
|
||||||
|
|
||||||
while (hasMore) {
|
const response = await this.request<AppsResponse>(endpoint);
|
||||||
const response = await this.client.app.getApps({ limit, offset });
|
|
||||||
allApps.push(...response.apps);
|
|
||||||
|
|
||||||
// If we got fewer than limit, we've reached the end
|
return response.apps.map((app) => ({
|
||||||
if (response.apps.length < limit) {
|
appId: app.appId,
|
||||||
hasMore = false;
|
name: app.name,
|
||||||
} else {
|
code: app.code,
|
||||||
offset += limit;
|
spaceId: app.spaceId,
|
||||||
}
|
createdAt: app.createdAt,
|
||||||
}
|
creator: app.creator,
|
||||||
|
modifiedAt: app.modifiedAt,
|
||||||
return allApps;
|
modifier: app.modifier,
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app details including customization config
|
||||||
|
* GET /k/v1/app.json?app={appId}
|
||||||
|
*/
|
||||||
async getAppDetail(appId: string): Promise<AppDetail> {
|
async getAppDetail(appId: string): Promise<AppDetail> {
|
||||||
return this.withErrorHandling(async () => {
|
interface AppResponse {
|
||||||
const [appInfo, customizeInfo] = await Promise.all([
|
appId: string;
|
||||||
this.client.app.getApp({ id: appId }),
|
name: string;
|
||||||
this.client.app.getAppCustomize({ app: appId, preview: true }),
|
code?: string;
|
||||||
]);
|
description?: string;
|
||||||
|
spaceId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
creator: { code: string; name: string };
|
||||||
|
modifiedAt: string;
|
||||||
|
modifier: { code: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
interface AppCustomizeResponse {
|
||||||
...appInfo,
|
appId: string;
|
||||||
customization: customizeInfo,
|
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 ====================
|
// ==================== File APIs ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file content from Kintone
|
||||||
|
* GET /k/v1/file.json?fileKey={fileKey}
|
||||||
|
*/
|
||||||
async getFileContent(fileKey: string): Promise<FileContent> {
|
async getFileContent(fileKey: string): Promise<FileContent> {
|
||||||
return this.withErrorHandling(async () => {
|
const controller = new AbortController();
|
||||||
const data = await this.client.file.downloadFile({ fileKey });
|
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
||||||
const buffer = Buffer.from(data);
|
|
||||||
const content = buffer.toString("base64");
|
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 {
|
return {
|
||||||
fileKey,
|
fileKey,
|
||||||
name: fileKey,
|
name: fileKey, // Kintone doesn't return filename in file API
|
||||||
size: buffer.byteLength,
|
size: arrayBuffer.byteLength,
|
||||||
mimeType: "application/octet-stream",
|
mimeType: contentType,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
});
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(content: string | Buffer, fileName: string, _mimeType?: string): Promise<{ fileKey: string }> {
|
/**
|
||||||
return this.withErrorHandling(async () => {
|
* Upload a file to Kintone
|
||||||
const response = await this.client.file.uploadFile({
|
* POST /k/v1/file.json (multipart/form-data)
|
||||||
file: { name: fileName, data: content },
|
*/
|
||||||
|
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 ====================
|
// ==================== Deploy APIs ====================
|
||||||
|
|
||||||
async updateAppCustomize(appId: string, config: Omit<AppCustomizeParameter, "app">): Promise<void> {
|
/**
|
||||||
return this.withErrorHandling(async () => {
|
* Update app customization config
|
||||||
await this.client.app.updateAppCustomize({ ...config, app: appId });
|
* 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> {
|
async deployApp(appId: string): Promise<void> {
|
||||||
return this.withErrorHandling(async () => {
|
await this.request("preview/app/deploy.json", {
|
||||||
await this.client.app.deployApp({ apps: [{ app: appId }] });
|
method: "POST",
|
||||||
|
body: JSON.stringify({ apps: [{ app: appId }] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeployStatus(appId: string): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
|
/**
|
||||||
return this.withErrorHandling(async () => {
|
* Get deploy status
|
||||||
const response = await this.client.app.getDeployStatus({ apps: [appId] });
|
* GET /k/v1/preview/app/deploy.json?app={appId}
|
||||||
return response.apps[0]?.status || "FAIL";
|
*/
|
||||||
});
|
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 ====================
|
// ==================== Utility Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Kintone
|
||||||
|
*/
|
||||||
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// Use limit=1 to minimize data transfer for faster connection testing
|
await this.getApps();
|
||||||
await this.client.app.getApps({ limit: 1 });
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof KintoneError ? error.message : getErrorMessage("connectionFailed"),
|
error:
|
||||||
|
error instanceof KintoneError ? error.message : "Connection failed",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the domain name
|
||||||
|
*/
|
||||||
getDomain(): string {
|
getDomain(): string {
|
||||||
return this.domain;
|
return this.domain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export factory function for convenience
|
||||||
export function createKintoneClient(domain: DomainWithPassword): KintoneClient {
|
export function createKintoneClient(domain: DomainWithPassword): KintoneClient {
|
||||||
return new KintoneClient(domain);
|
return new KintoneClient(domain);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
import { app, safeStorage } from "electron";
|
import { app, safeStorage } from "electron";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type { Domain, DomainWithPassword } from "@shared/types/domain";
|
import type { Domain, DomainWithPassword } from "@renderer/types/domain";
|
||||||
import type { Version, DownloadMetadata, BackupMetadata } from "@shared/types/version";
|
import type {
|
||||||
import type { LocaleCode } from "@shared/types/locale";
|
Version,
|
||||||
import { DEFAULT_LOCALE } from "@shared/types/locale";
|
DownloadMetadata,
|
||||||
|
BackupMetadata,
|
||||||
|
} from "@renderer/types/version";
|
||||||
|
|
||||||
// ==================== Path Helpers ====================
|
// ==================== Path Helpers ====================
|
||||||
|
|
||||||
@@ -41,28 +43,6 @@ function ensureDir(dirPath: string): void {
|
|||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
domains: Domain[];
|
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 {
|
interface SecureStore {
|
||||||
@@ -103,14 +83,14 @@ function writeJsonFile<T>(filePath: string, data: T): void {
|
|||||||
export function isSecureStorageAvailable(): boolean {
|
export function isSecureStorageAvailable(): boolean {
|
||||||
try {
|
try {
|
||||||
// Check if the method exists (added in Electron 30+)
|
// Check if the method exists (added in Electron 30+)
|
||||||
if (typeof safeStorage.getSelectedStorageBackend === "function") {
|
if (typeof safeStorage.getSelectedStorageBackend === 'function') {
|
||||||
const backend = safeStorage.getSelectedStorageBackend();
|
const backend = safeStorage.getSelectedStorageBackend()
|
||||||
return backend !== "basic_text";
|
return backend !== 'basic_text'
|
||||||
}
|
}
|
||||||
// Fallback: check if encryption is available
|
// Fallback: check if encryption is available
|
||||||
return safeStorage.isEncryptionAvailable();
|
return safeStorage.isEncryptionAvailable()
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +99,12 @@ export function isSecureStorageAvailable(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function getStorageBackend(): string {
|
export function getStorageBackend(): string {
|
||||||
try {
|
try {
|
||||||
if (typeof safeStorage.getSelectedStorageBackend === "function") {
|
if (typeof safeStorage.getSelectedStorageBackend === 'function') {
|
||||||
return safeStorage.getSelectedStorageBackend();
|
return safeStorage.getSelectedStorageBackend()
|
||||||
}
|
}
|
||||||
return "unknown";
|
return 'unknown'
|
||||||
} catch {
|
} catch {
|
||||||
return "unknown";
|
return 'unknown'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +115,9 @@ export function encryptPassword(password: string): Buffer {
|
|||||||
try {
|
try {
|
||||||
return safeStorage.encryptString(password);
|
return safeStorage.encryptString(password);
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
return safeStorage.decryptString(encrypted);
|
return safeStorage.decryptString(encrypted);
|
||||||
} catch (error) {
|
} 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
|
* 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 configPath = getConfigPath();
|
||||||
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
||||||
const existingIndex = config.domains.findIndex((d) => d.id === domain.id);
|
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
|
* 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 configPath = getConfigPath();
|
||||||
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
const config = readJsonFile<AppConfig>(configPath, { domains: [] });
|
||||||
const domain = config.domains.find((d) => d.id === id);
|
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
|
* Save a version to local storage
|
||||||
*/
|
*/
|
||||||
export async function saveVersion(version: Version): Promise<void> {
|
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);
|
ensureDir(versionDir);
|
||||||
|
|
||||||
@@ -252,7 +247,10 @@ export async function saveVersion(version: Version): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* List versions for a specific app
|
* 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 versions: Version[] = [];
|
||||||
const baseDir = getStoragePath("versions", domainId, appId);
|
const baseDir = getStoragePath("versions", domainId, appId);
|
||||||
|
|
||||||
@@ -282,7 +280,9 @@ export async function listVersions(domainId: string, appId: string): Promise<Ver
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by createdAt descending
|
// 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
|
* 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 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);
|
ensureDir(downloadDir);
|
||||||
|
|
||||||
@@ -360,9 +368,18 @@ export function getDownloadPath(domainId: string, appId?: string): string {
|
|||||||
/**
|
/**
|
||||||
* Save backup files with metadata
|
* 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 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);
|
ensureDir(backupDir);
|
||||||
|
|
||||||
@@ -380,39 +397,6 @@ export async function saveBackup(metadata: BackupMetadata, files: Map<string, Bu
|
|||||||
return backupDir;
|
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 ====================
|
// ==================== Storage Info ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -441,5 +425,4 @@ export function initializeStorage(): void {
|
|||||||
ensureDir(getStorageBase());
|
ensureDir(getStorageBase());
|
||||||
ensureDir(getStoragePath("downloads"));
|
ensureDir(getStoragePath("downloads"));
|
||||||
ensureDir(getStoragePath("versions"));
|
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,
|
CreateDomainParams,
|
||||||
UpdateDomainParams,
|
UpdateDomainParams,
|
||||||
TestDomainConnectionParams,
|
TestDomainConnectionParams,
|
||||||
|
GetSpacesParams,
|
||||||
GetAppsParams,
|
GetAppsParams,
|
||||||
GetAppDetailParams,
|
GetAppDetailParams,
|
||||||
GetFileContentParams,
|
GetFileContentParams,
|
||||||
GetLocalFileContentParams,
|
|
||||||
LocalFileContent,
|
|
||||||
DeployParams,
|
DeployParams,
|
||||||
DeployResult,
|
DeployResult,
|
||||||
DownloadParams,
|
DownloadParams,
|
||||||
DownloadResult,
|
DownloadResult,
|
||||||
DownloadAllZipParams,
|
GetVersionsParams,
|
||||||
DownloadAllZipResult,
|
|
||||||
RollbackParams,
|
RollbackParams,
|
||||||
SetLocaleParams,
|
} from "@renderer/types/ipc";
|
||||||
ShowSaveDialogParams,
|
import type { Domain, DomainWithStatus } from "@renderer/types/domain";
|
||||||
SaveFileContentParams,
|
import type {
|
||||||
CheckUpdateResult,
|
KintoneSpace,
|
||||||
FileSaveParams,
|
KintoneApp,
|
||||||
FileSaveResult,
|
AppDetail,
|
||||||
FileDeleteParams,
|
FileContent,
|
||||||
} from "@shared/types/ipc";
|
} from "@renderer/types/kintone";
|
||||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
import type { Version } from "@renderer/types/version";
|
||||||
import type { AppResponse, AppDetail, FileContent, KintoneSpace } from "@shared/types/kintone";
|
|
||||||
import type { Version } from "@shared/types/version";
|
|
||||||
import type { LocaleCode } from "@shared/types/locale";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI;
|
electron: ElectronAPI;
|
||||||
api: SelfAPI;
|
api: ElectronAPI;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelfAPI {
|
export interface ElectronAPI {
|
||||||
// Platform detection
|
// Platform detection
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
|
|
||||||
@@ -49,44 +44,21 @@ export interface SelfAPI {
|
|||||||
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
|
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
|
||||||
|
|
||||||
// ==================== Browse ====================
|
// ==================== 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>>;
|
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
|
||||||
getFileContent: (params: GetFileContentParams) => Promise<Result<FileContent>>;
|
getFileContent: (
|
||||||
getLocalFileContent: (params: GetLocalFileContentParams) => Promise<Result<LocalFileContent>>;
|
params: GetFileContentParams,
|
||||||
|
) => Promise<Result<FileContent>>;
|
||||||
|
|
||||||
// ==================== Deploy ====================
|
// ==================== Deploy ====================
|
||||||
deploy: (params: DeployParams) => Promise<Result<DeployResult>>;
|
deploy: (params: DeployParams) => Promise<DeployResult>;
|
||||||
|
|
||||||
// ==================== File Storage ====================
|
|
||||||
saveFile: (params: FileSaveParams) => Promise<Result<FileSaveResult>>;
|
|
||||||
deleteFile: (params: FileDeleteParams) => Promise<Result<void>>;
|
|
||||||
|
|
||||||
// ==================== Download ====================
|
// ==================== Download ====================
|
||||||
download: (params: DownloadParams) => Promise<DownloadResult>;
|
download: (params: DownloadParams) => Promise<DownloadResult>;
|
||||||
downloadAllZip: (params: DownloadAllZipParams) => Promise<Result<DownloadAllZipResult>>;
|
|
||||||
|
|
||||||
// ==================== Version Management ====================
|
// ==================== Version Management ====================
|
||||||
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;
|
getVersions: (params: GetVersionsParams) => Promise<Result<Version[]>>;
|
||||||
deleteVersion: (id: string) => Promise<Result<void>>;
|
deleteVersion: (id: string) => Promise<Result<void>>;
|
||||||
rollback: (params: RollbackParams) => Promise<DeployResult>;
|
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 { 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
|
// Custom APIs for renderer - bridges to IPC handlers
|
||||||
const api: SelfAPI = {
|
const api: ElectronAPI = {
|
||||||
|
// Platform detection
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|
||||||
// Domain management
|
// ==================== Domain Management ====================
|
||||||
getDomains: () => ipcRenderer.invoke("getDomains"),
|
getDomains: () => ipcRenderer.invoke("getDomains"),
|
||||||
createDomain: (params) => ipcRenderer.invoke("createDomain", params),
|
createDomain: (params) => ipcRenderer.invoke("createDomain", params),
|
||||||
updateDomain: (params) => ipcRenderer.invoke("updateDomain", params),
|
updateDomain: (params) => ipcRenderer.invoke("updateDomain", params),
|
||||||
@@ -14,42 +15,22 @@ const api: SelfAPI = {
|
|||||||
testConnection: (id) => ipcRenderer.invoke("testConnection", id),
|
testConnection: (id) => ipcRenderer.invoke("testConnection", id),
|
||||||
testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params),
|
testDomainConnection: (params) => ipcRenderer.invoke("testDomainConnection", params),
|
||||||
|
|
||||||
// Browse
|
// ==================== Browse ====================
|
||||||
|
getSpaces: (params) => ipcRenderer.invoke("getSpaces", params),
|
||||||
getApps: (params) => ipcRenderer.invoke("getApps", params),
|
getApps: (params) => ipcRenderer.invoke("getApps", params),
|
||||||
getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params),
|
getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params),
|
||||||
getFileContent: (params) => ipcRenderer.invoke("getFileContent", params),
|
getFileContent: (params) => ipcRenderer.invoke("getFileContent", params),
|
||||||
getLocalFileContent: (params) => ipcRenderer.invoke("getLocalFileContent", params),
|
|
||||||
|
|
||||||
// Deploy
|
// ==================== Deploy ====================
|
||||||
deploy: (params) => ipcRenderer.invoke("deploy", params),
|
deploy: (params) => ipcRenderer.invoke("deploy", params),
|
||||||
|
|
||||||
// File storage
|
// ==================== Download ====================
|
||||||
saveFile: (params) => ipcRenderer.invoke("file:save", params),
|
|
||||||
deleteFile: (params) => ipcRenderer.invoke("file:delete", params),
|
|
||||||
|
|
||||||
// Download
|
|
||||||
download: (params) => ipcRenderer.invoke("download", params),
|
download: (params) => ipcRenderer.invoke("download", params),
|
||||||
downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params),
|
|
||||||
|
|
||||||
// Version management
|
// ==================== Version Management ====================
|
||||||
getVersions: (params) => ipcRenderer.invoke("getVersions", params),
|
getVersions: (params) => ipcRenderer.invoke("getVersions", params),
|
||||||
deleteVersion: (id) => ipcRenderer.invoke("deleteVersion", id),
|
deleteVersion: (id) => ipcRenderer.invoke("deleteVersion", id),
|
||||||
rollback: (params) => ipcRenderer.invoke("rollback", params),
|
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
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
@@ -60,11 +41,11 @@ if (process.contextIsolated) {
|
|||||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||||
contextBridge.exposeInMainWorld("api", api);
|
contextBridge.exposeInMainWorld("api", api);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Preload] Failed to expose API:", error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore - window is available in non-isolated context
|
// @ts-ignore (define in dts)
|
||||||
window.electron = electronAPI;
|
window.electron = electronAPI;
|
||||||
// @ts-ignore
|
// @ts-ignore (define in dts)
|
||||||
window.api = api;
|
window.api = api;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,29 +4,35 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import {
|
||||||
import { Layout, Typography, Space, Modal } from "antd";
|
Layout,
|
||||||
|
Typography,
|
||||||
import { Button, Tooltip } from "@lobehub/ui";
|
theme,
|
||||||
|
ConfigProvider,
|
||||||
import { Cloud, History, PanelLeftClose, PanelLeftOpen, Settings as SettingsIcon } from "lucide-react";
|
App as AntApp,
|
||||||
|
Tabs,
|
||||||
import { createStyles, useTheme } from "antd-style";
|
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 { useDomainStore } from "@renderer/stores";
|
||||||
import { useUIStore } from "@renderer/stores";
|
|
||||||
import { DomainManager } from "@renderer/components/DomainManager";
|
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 { AppDetail } from "@renderer/components/AppDetail";
|
||||||
import { Settings } from "@renderer/components/Settings";
|
import { DeployDialog } from "@renderer/components/DeployDialog";
|
||||||
const { Header, Content, Sider } = Layout;
|
|
||||||
const { Title } = Typography;
|
|
||||||
|
|
||||||
// Domain section heights
|
const { Header, Content, Sider } = Layout;
|
||||||
const DOMAIN_SECTION_COLLAPSED = 68; // 增加高度,避免按钮覆盖文字
|
const { Title, Text } = Typography;
|
||||||
const DOMAIN_SECTION_EXPANDED = 260;
|
|
||||||
const DEFAULT_SIDER_WIDTH = 320;
|
|
||||||
const MIN_SIDER_WIDTH = 280;
|
|
||||||
const MAX_SIDER_WIDTH = 500;
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
layout: css`
|
layout: css`
|
||||||
@@ -42,18 +48,14 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
background: ${token.colorBgContainer};
|
background: ${token.colorBgContainer};
|
||||||
border-right: 1px solid ${token.colorBorderSecondary};
|
border-right: 1px solid ${token.colorBorderSecondary};
|
||||||
`,
|
`,
|
||||||
siderCollapsed: css`
|
|
||||||
width: 0 !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
overflow: hidden;
|
|
||||||
`,
|
|
||||||
logo: css`
|
logo: css`
|
||||||
height: 32px;
|
height: 48px;
|
||||||
margin: ${token.paddingXS}px ${token.padding}px ${token.paddingXXS}px;
|
margin: 8px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||||
`,
|
`,
|
||||||
logoText: css`
|
logoText: css`
|
||||||
color: ${token.colorText};
|
color: ${token.colorText};
|
||||||
@@ -61,16 +63,12 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
`,
|
`,
|
||||||
siderContent: css`
|
siderContent: css`
|
||||||
height: calc(100vh - 44px);
|
height: calc(100vh - 64px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
`,
|
`,
|
||||||
mainLayout: css`
|
mainLayout: css`
|
||||||
margin-left: ${DEFAULT_SIDER_WIDTH}px;
|
margin-left: 280px;
|
||||||
transition: margin-left 0.2s;
|
|
||||||
`,
|
|
||||||
mainLayoutCollapsed: css`
|
|
||||||
margin-left: 0;
|
|
||||||
transition: margin-left 0.2s;
|
transition: margin-left 0.2s;
|
||||||
`,
|
`,
|
||||||
header: css`
|
header: css`
|
||||||
@@ -92,179 +90,116 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
`,
|
`,
|
||||||
|
leftPanel: css`
|
||||||
|
width: 300px;
|
||||||
|
border-right: 1px solid ${token.colorBorderSecondary};
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
`,
|
||||||
rightPanel: css`
|
rightPanel: css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`,
|
`,
|
||||||
domainSection: css`
|
domainSection: css`
|
||||||
|
height: 200px;
|
||||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||||
padding-bottom: ${token.paddingXS}px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: height 0.2s ease-in-out;
|
|
||||||
`,
|
`,
|
||||||
appSection: css`
|
spaceSection: css`
|
||||||
|
height: calc(100% - 200px);
|
||||||
overflow: hidden;
|
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 App: React.FC = () => {
|
||||||
const { t } = useTranslation("common");
|
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const token = useTheme();
|
const {
|
||||||
|
token: { colorBgContainer },
|
||||||
// Prevent Electron from navigating to file:// URLs when files are dropped
|
} = theme.useToken();
|
||||||
// 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 { currentDomain } = useDomainStore();
|
const { currentDomain } = useDomainStore();
|
||||||
const { sidebarWidth, siderCollapsed, domainExpanded, setSidebarWidth, setSiderCollapsed, setDomainExpanded } = useUIStore();
|
const [deployDialogOpen, setDeployDialogOpen] = React.useState(false);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className={styles.layout}>
|
<ConfigProvider locale={zhCN}>
|
||||||
{/* Left Sider - Domain List & App List */}
|
<AntApp>
|
||||||
<Sider
|
<Layout className={styles.layout}>
|
||||||
width={sidebarWidth}
|
{/* Left Sider - Domain List & Space Tree */}
|
||||||
className={`${styles.sider} ${siderCollapsed ? styles.siderCollapsed : ""}`}
|
<Sider width={280} className={styles.sider}>
|
||||||
style={{ width: siderCollapsed ? 0 : sidebarWidth }}
|
|
||||||
>
|
|
||||||
{!siderCollapsed && (
|
|
||||||
<>
|
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<CloudServerOutlined style={{ fontSize: 24, color: "#1890ff" }} />
|
||||||
<Cloud size={24} style={{ color: token.colorPrimary }} />
|
<span className={styles.logoText}>Kintone Manager</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.siderContent}>
|
<div className={styles.siderContent}>
|
||||||
<div className={styles.domainSection} style={{ height: domainSectionHeight }}>
|
<div className={styles.domainSection}>
|
||||||
<DomainManager collapsed={!domainExpanded} onToggleCollapse={() => setDomainExpanded(!domainExpanded)} />
|
<DomainManager />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.appSection} style={{ height: `calc(100% - ${domainSectionHeight}px)` }}>
|
<div className={styles.spaceSection}>
|
||||||
<AppList />
|
<SpaceTree />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Resize handle */}
|
</Sider>
|
||||||
<div
|
|
||||||
className={styles.resizeHandle}
|
|
||||||
onMouseDown={handleResizeStart}
|
|
||||||
style={{
|
|
||||||
background: isResizing ? token.colorPrimary : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Sider>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Layout className={`${styles.mainLayout} ${siderCollapsed ? styles.mainLayoutCollapsed : ""}`} style={{ marginLeft: siderCollapsed ? 0 : sidebarWidth }}>
|
<Layout className={styles.mainLayout}>
|
||||||
<Header className={styles.header}>
|
<Header className={styles.header}>
|
||||||
<div className={styles.headerLeft}>
|
<Title level={5} style={{ margin: 0 }}>
|
||||||
{siderCollapsed && (
|
{currentDomain
|
||||||
<Tooltip title={t("expandSidebar")} mouseEnterDelay={0.5}>
|
? currentDomain.name
|
||||||
<Button type="text" icon={<PanelLeftOpen size={16} />} onClick={toggleSider} size="small" />
|
: "Kintone Customize Manager"}
|
||||||
</Tooltip>
|
</Title>
|
||||||
)}
|
<Space>
|
||||||
<Title level={5} style={{ margin: 0 }}>
|
<Button
|
||||||
{currentDomain ? currentDomain.name : "Kintone Customize Manager"}
|
type="primary"
|
||||||
</Title>
|
icon={<CloudUploadOutlined />}
|
||||||
</div>
|
onClick={() => setDeployDialogOpen(true)}
|
||||||
<Space>
|
disabled={!currentDomain}
|
||||||
<Button icon={<History size={16} />} disabled={!currentDomain}>
|
>
|
||||||
{t("versionHistory")}
|
部署文件
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip title={t("settings")}>
|
<Button icon={<HistoryOutlined />} disabled={!currentDomain}>
|
||||||
<Button icon={<SettingsIcon size={16} />} onClick={() => setSettingsOpen(true)} />
|
版本历史
|
||||||
</Tooltip>
|
</Button>
|
||||||
</Space>
|
<Dropdown
|
||||||
</Header>
|
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}>
|
<Content className={styles.content}>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<div className={styles.rightPanel}>
|
<div className={styles.rightPanel}>
|
||||||
<AppDetail />
|
<AppDetail />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
{/* Settings Modal */}
|
{/* Deploy Dialog */}
|
||||||
<Modal title={t("settings")} open={settingsOpen} onCancel={() => setSettingsOpen(false)} footer={null} width={480} mask={{ closable: false }}>
|
<DeployDialog
|
||||||
<Settings />
|
open={deployDialogOpen}
|
||||||
</Modal>
|
onClose={() => setDeployDialogOpen(false)}
|
||||||
</Layout>
|
/>
|
||||||
|
</Layout>
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* AppDetail Component
|
* AppDetail Component
|
||||||
* Displays app configuration details with file management and deploy functionality.
|
* Displays app configuration details
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import {
|
||||||
import { Spin, Tag, Space, App as AntApp, Tooltip } from "antd";
|
Card,
|
||||||
import { Button, Empty } from "@lobehub/ui";
|
Descriptions,
|
||||||
import { LayoutGrid, Download, History, Rocket, Monitor, Smartphone, ArrowLeft, RefreshCw } from "lucide-react";
|
Tabs,
|
||||||
import { createStyles, useTheme } from "antd-style";
|
Empty,
|
||||||
import { useAppStore, useDomainStore, useSessionStore, useFileChangeStore } from "@renderer/stores";
|
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 { 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 }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
container: css`
|
container: css`
|
||||||
@@ -23,9 +34,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
background: ${token.colorBgContainer};
|
background: ${token.colorBgContainer};
|
||||||
`,
|
`,
|
||||||
header: css`
|
header: css`
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: ${token.paddingMD}px ${token.paddingLG}px;
|
padding: ${token.paddingMD}px ${token.paddingLG}px;
|
||||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||||
`,
|
`,
|
||||||
@@ -33,6 +41,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: ${token.paddingSM}px;
|
gap: ${token.paddingSM}px;
|
||||||
|
margin-bottom: ${token.marginSM}px;
|
||||||
`,
|
`,
|
||||||
appName: css`
|
appName: css`
|
||||||
font-size: ${token.fontSizeHeading5}px;
|
font-size: ${token.fontSizeHeading5}px;
|
||||||
@@ -43,7 +52,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: ${token.paddingMD}px;
|
padding: ${token.paddingMD}px;
|
||||||
padding-top: ${token.paddingXS}px;
|
|
||||||
`,
|
`,
|
||||||
loading: css`
|
loading: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -51,291 +59,88 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 300px;
|
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`
|
emptySection: css`
|
||||||
height: 100%;
|
|
||||||
padding: ${token.paddingLG}px;
|
padding: ${token.paddingLG}px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${token.colorTextSecondary};
|
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 AppDetail: React.FC = () => {
|
||||||
const { t } = useTranslation(["app", "common"]);
|
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const token = useTheme();
|
|
||||||
const { currentDomain } = useDomainStore();
|
const { currentDomain } = useDomainStore();
|
||||||
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } = useAppStore();
|
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
|
||||||
const { viewMode, selectedFile, setViewMode, setSelectedFile } = useSessionStore();
|
useAppStore();
|
||||||
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;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await window.api.getAppDetail({
|
|
||||||
domainId: currentDomain.id,
|
|
||||||
appId: selectedAppId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if we're still on the same app and component is mounted before updating
|
|
||||||
if (result.success) {
|
|
||||||
setCurrentApp(result.data);
|
|
||||||
// Call the callback if provided
|
|
||||||
if (onSuccessCallback) {
|
|
||||||
await onSuccessCallback();
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load app detail:", error);
|
|
||||||
return undefined;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[currentDomain, selectedAppId, setCurrentApp, setLoading]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load app detail when selected
|
// Load app detail when selected
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentDomain && selectedAppId) {
|
if (currentDomain && selectedAppId) {
|
||||||
loadAppDetail();
|
loadAppDetail();
|
||||||
}
|
}
|
||||||
}, [currentDomain, selectedAppId, loadAppDetail]);
|
}, [currentDomain, selectedAppId]);
|
||||||
|
|
||||||
// Initialize file change store from Kintone data
|
const loadAppDetail = async () => {
|
||||||
useEffect(() => {
|
if (!currentDomain || !selectedAppId) return;
|
||||||
if (!currentApp || !currentDomain || !selectedAppId) return;
|
|
||||||
|
|
||||||
const customize = currentApp.customization;
|
setLoading(true);
|
||||||
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);
|
|
||||||
try {
|
try {
|
||||||
// Clear changes before reloading from Kintone
|
const result = await window.api.getAppDetail({
|
||||||
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({
|
|
||||||
domainId: currentDomain.id,
|
domainId: currentDomain.id,
|
||||||
appId: selectedAppId,
|
appId: selectedAppId,
|
||||||
savePath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
message.success(t("downloadAllSuccess", { path: result.data?.path, ns: "common" }));
|
setCurrentApp(result.data);
|
||||||
} 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"));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : t("deployFailed"));
|
console.error("Failed to load app detail:", error);
|
||||||
} finally {
|
} 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) {
|
if (!currentDomain || !selectedAppId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.emptySection}>
|
<div className={styles.emptySection}>
|
||||||
<Empty description={t("selectApp")} />
|
<Empty
|
||||||
|
description="请选择一个应用"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -353,125 +158,175 @@ const AppDetail: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.emptySection}>
|
<div className={styles.emptySection}>
|
||||||
<Empty description={t("appNotFound")} />
|
<Empty
|
||||||
|
description="未找到应用信息"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeCount = currentDomain && selectedAppId ? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId) : { added: 0, deleted: 0, reordered: 0 };
|
const renderFileList = (
|
||||||
const hasChanges = changeCount.added > 0 || changeCount.deleted > 0 || changeCount.reordered > 0;
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<LayoutGrid size={24} style={{ color: token.colorLink }} />
|
<AppstoreOutlined style={{ fontSize: 24, color: "#1890ff" }} />
|
||||||
<h3 className={styles.appName}>{currentApp.name}</h3>
|
<h3 className={styles.appName}>{currentApp.name}</h3>
|
||||||
<Tag>ID: {currentApp.appId}</Tag>
|
<Tag color="blue">{currentApp.appId}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<RefreshCw size={16} />} loading={refreshing} onClick={handleRefresh}>
|
<Button icon={<HistoryOutlined />}>版本历史</Button>
|
||||||
{t("refresh", { ns: "common" })}
|
<Button type="primary" icon={<DownloadOutlined />}>
|
||||||
|
下载全部
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<History size={16} />}>{t("versionHistory", { ns: "common" })}</Button>
|
|
||||||
<Button icon={<Download size={16} />} loading={downloadingAll} onClick={handleDownloadAll}>
|
|
||||||
{t("downloadAll")}
|
|
||||||
</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>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{viewMode === "list" ? (
|
<Tabs
|
||||||
<>
|
activeKey={activeTab}
|
||||||
<FileSection
|
onChange={setActiveTab}
|
||||||
title={t("pcJs")}
|
items={[
|
||||||
icon={<Monitor size={14} />}
|
{
|
||||||
platform="desktop"
|
key: "info",
|
||||||
fileType="js"
|
label: "基本信息",
|
||||||
domainId={currentDomain.id}
|
children: (
|
||||||
appId={selectedAppId}
|
<Descriptions column={2} bordered size="small">
|
||||||
downloadingKey={downloadingKey}
|
<Descriptions.Item label="应用ID">
|
||||||
onView={handleFileClick}
|
{currentApp.appId}
|
||||||
onDownload={handleDownloadFile}
|
</Descriptions.Item>
|
||||||
onOverLimitChange={handleOverLimitChange("desktop-js")}
|
<Descriptions.Item label="应用代码">
|
||||||
/>
|
{currentApp.code || "-"}
|
||||||
<FileSection
|
</Descriptions.Item>
|
||||||
title={t("pcCss")}
|
<Descriptions.Item label="创建时间">
|
||||||
icon={<Monitor size={14} />}
|
{currentApp.createdAt}
|
||||||
platform="desktop"
|
</Descriptions.Item>
|
||||||
fileType="css"
|
<Descriptions.Item label="创建者">
|
||||||
domainId={currentDomain.id}
|
{currentApp.creator?.name}
|
||||||
appId={selectedAppId}
|
</Descriptions.Item>
|
||||||
downloadingKey={downloadingKey}
|
<Descriptions.Item label="更新时间">
|
||||||
onView={handleFileClick}
|
{currentApp.modifiedAt}
|
||||||
onDownload={handleDownloadFile}
|
</Descriptions.Item>
|
||||||
onOverLimitChange={handleOverLimitChange("desktop-css")}
|
<Descriptions.Item label="更新者">
|
||||||
/>
|
{currentApp.modifier?.name}
|
||||||
<FileSection
|
</Descriptions.Item>
|
||||||
title={t("mobileJs")}
|
<Descriptions.Item label="所属Space" span={2}>
|
||||||
icon={<Smartphone size={14} />}
|
{currentApp.spaceName || currentApp.spaceId || "-"}
|
||||||
platform="mobile"
|
</Descriptions.Item>
|
||||||
fileType="js"
|
</Descriptions>
|
||||||
domainId={currentDomain.id}
|
),
|
||||||
appId={selectedAppId}
|
},
|
||||||
downloadingKey={downloadingKey}
|
{
|
||||||
onView={handleFileClick}
|
key: "pc-js",
|
||||||
onDownload={handleDownloadFile}
|
label: "PC端 JS",
|
||||||
onOverLimitChange={handleOverLimitChange("mobile-js")}
|
children: renderFileList(
|
||||||
/>
|
currentApp.customization?.javascript?.pc,
|
||||||
<FileSection
|
"js",
|
||||||
title={t("mobileCss")}
|
),
|
||||||
icon={<Smartphone size={14} />}
|
},
|
||||||
platform="mobile"
|
{
|
||||||
fileType="css"
|
key: "pc-css",
|
||||||
domainId={currentDomain.id}
|
label: "PC端 CSS",
|
||||||
appId={selectedAppId}
|
children: renderFileList(
|
||||||
downloadingKey={downloadingKey}
|
currentApp.customization?.stylesheet?.pc,
|
||||||
onView={handleFileClick}
|
"css",
|
||||||
onDownload={handleDownloadFile}
|
),
|
||||||
onOverLimitChange={handleOverLimitChange("mobile-css")}
|
},
|
||||||
/>
|
{
|
||||||
</>
|
key: "mobile-js",
|
||||||
) : (
|
label: "移动端 JS",
|
||||||
<div className={styles.codeView}>
|
children: renderFileList(
|
||||||
<Button type="text" icon={<ArrowLeft size={16} />} onClick={handleBackToList} className={styles.backButton}>
|
currentApp.customization?.javascript?.mobile,
|
||||||
{t("backToList")}
|
"js",
|
||||||
</Button>
|
),
|
||||||
{selectedFile && (
|
},
|
||||||
<CodeViewer fileKey={selectedFile.fileKey} fileName={selectedFile.name} fileType={selectedFile.type} storagePath={selectedFile.storagePath} />
|
{
|
||||||
)}
|
key: "mobile-css",
|
||||||
</div>
|
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>
|
||||||
</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,286 +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;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await window.api.getApps({
|
|
||||||
domainId: currentDomain.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
setApps(result.data);
|
|
||||||
} else {
|
|
||||||
setError(result.error || t("loadAppsFailed"));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : t("loadAppsFailed"));
|
|
||||||
} finally {
|
|
||||||
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 React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Spin, Empty, Alert, Button, Space, message } from "antd";
|
||||||
import { Spin, Alert, Space, message } from "antd";
|
import {
|
||||||
import { Button, Empty } from "@lobehub/ui";
|
CopyOutlined,
|
||||||
import { Copy, Download } from "lucide-react";
|
DownloadOutlined,
|
||||||
import { createStyles, useTheme } from "antd-style";
|
FullscreenOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import { createStyles } from "antd-style";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
import { javascript } from "@codemirror/lang-javascript";
|
import { javascript } from "@codemirror/lang-javascript";
|
||||||
import { css } from "@codemirror/lang-css";
|
import { css } from "@codemirror/lang-css";
|
||||||
@@ -51,82 +53,58 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
interface CodeViewerProps {
|
interface CodeViewerProps {
|
||||||
fileKey?: string;
|
fileKey: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileType: "js" | "css";
|
fileType: "js" | "css";
|
||||||
/** For locally added files: absolute path on disk */
|
|
||||||
storagePath?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, storagePath }) => {
|
const CodeViewer: React.FC<CodeViewerProps> = ({
|
||||||
const { t } = useTranslation("file");
|
fileKey,
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
}) => {
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const { appearance } = useTheme();
|
|
||||||
const themeMode = appearance === "dark" ? "dark" : ("light" as const);
|
|
||||||
const { currentDomain } = useDomainStore();
|
const { currentDomain } = useDomainStore();
|
||||||
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [content, setContent] = React.useState<string>("");
|
const [content, setContent] = React.useState<string>("");
|
||||||
const [language, setLanguage] = React.useState<"js" | "css">(fileType);
|
const [language, setLanguage] = React.useState<"js" | "css">(fileType);
|
||||||
const [downloading, setDownloading] = React.useState(false);
|
|
||||||
|
|
||||||
// Load file content
|
// Load file content
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentDomain && (fileKey || storagePath)) {
|
if (currentDomain && fileKey) {
|
||||||
loadFileContent();
|
loadFileContent();
|
||||||
}
|
}
|
||||||
}, [currentDomain, fileKey, storagePath]);
|
}, [currentDomain, fileKey]);
|
||||||
|
|
||||||
const loadFileContent = async () => {
|
const loadFileContent = async () => {
|
||||||
|
if (!currentDomain) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prefer local file (storagePath) for added files
|
const result = await window.api.getFileContent({
|
||||||
if (storagePath) {
|
domainId: currentDomain.id,
|
||||||
const result = await window.api.getLocalFileContent({ storagePath });
|
fileKey,
|
||||||
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Decode base64 content properly for UTF-8
|
// Decode base64 content
|
||||||
const base64 = result.data.content || "";
|
const decoded = atob(result.data.content || "");
|
||||||
const binaryString = atob(base64);
|
setContent(decoded);
|
||||||
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) {
|
// Detect language from file name
|
||||||
// Decode base64 content properly for UTF-8 (including Japanese characters)
|
if (fileName.endsWith(".css")) {
|
||||||
const base64 = result.data.content || "";
|
setLanguage("css");
|
||||||
const binaryString = atob(base64);
|
} else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
|
||||||
// Decode as UTF-8 to properly handle Japanese and other multi-byte characters
|
setLanguage("js");
|
||||||
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 {
|
} else {
|
||||||
setError(result.error || "Failed to load file content");
|
setLanguage(fileType);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setError("No file source available");
|
setError(result.error || "Failed to load file content");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Unknown error");
|
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 = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(content);
|
navigator.clipboard.writeText(content);
|
||||||
message.success(t("copiedToClipboard"));
|
message.success("已复制到剪贴板");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = () => {
|
||||||
if (!currentDomain || downloading) return;
|
const blob = new Blob([content], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
// Check if fileName already has extension
|
const a = document.createElement("a");
|
||||||
const hasExt = /\.(js|css)$/i.test(fileName);
|
a.href = url;
|
||||||
const finalFileName = hasExt ? fileName : `${fileName}.${fileType}`;
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
setDownloading(true);
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
try {
|
URL.revokeObjectURL(url);
|
||||||
// 1. Show save dialog
|
message.success("下载成功");
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.loading}>
|
<div className={styles.loading}>
|
||||||
<Spin size="large" description={t("loading")} />
|
<Spin size="large" tip="加载中..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -217,12 +144,12 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
|||||||
<div className={styles.error}>
|
<div className={styles.error}>
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
message={t("loadFailed")}
|
message="加载失败"
|
||||||
description={error}
|
description={error}
|
||||||
showIcon
|
showIcon
|
||||||
action={
|
action={
|
||||||
<Button size="small" onClick={loadFileContent}>
|
<Button size="small" onClick={loadFileContent}>
|
||||||
{t("retry", { ns: "deploy" })}
|
重试
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -233,7 +160,10 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
|||||||
if (!content) {
|
if (!content) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Empty description={t("fileEmpty")} />
|
<Empty
|
||||||
|
description="文件内容为空"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,11 +173,21 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
|||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.fileName}>{fileName}</span>
|
<span className={styles.fileName}>{fileName}</span>
|
||||||
<Space size="small">
|
<Space size="small">
|
||||||
<Button type="text" size="small" icon={<Copy size={16} />} onClick={handleCopy}>
|
<Button
|
||||||
{t("copy", { ns: "common" })}
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
复制
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="text" size="small" icon={<Download size={16} />} loading={downloading} onClick={handleDownload}>
|
<Button
|
||||||
{t("download", { ns: "common" })}
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
下载
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,7 +198,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, st
|
|||||||
height="100%"
|
height="100%"
|
||||||
extensions={[language === "js" ? javascript() : css()]}
|
extensions={[language === "js" ? javascript() : css()]}
|
||||||
editable={false}
|
editable={false}
|
||||||
theme={themeMode}
|
theme="light"
|
||||||
basicSetup={{
|
basicSetup={{
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
highlightActiveLineGutter: false,
|
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 React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Modal, Form, Input, Select, Button, Space, message } from "antd";
|
||||||
import { Button, Input, InputPassword, Modal } from "@lobehub/ui";
|
import { createStyles } from "antd-style";
|
||||||
import { Form } from "antd";
|
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from "@renderer/stores";
|
||||||
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
|
import type { Domain } from "@renderer/types/domain";
|
||||||
import { CheckCircle2, XCircle } from "lucide-react";
|
import type {
|
||||||
import { useTheme } from "antd-style";
|
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 {
|
interface DomainFormProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -18,213 +32,49 @@ interface DomainFormProps {
|
|||||||
domainId: string | null;
|
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 DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
||||||
const { t } = useTranslation("domain");
|
const { styles } = useStyles();
|
||||||
const token = useTheme();
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { domains, createDomain, updateDomainById } = useDomainStore();
|
const { domains, createDomain, updateDomainById, loading } = useDomainStore();
|
||||||
|
|
||||||
const isEdit = !!domainId;
|
const isEdit = !!domainId;
|
||||||
const editingDomain = domainId ? domains.find((d) => d.id === domainId) : null;
|
const editingDomain = domainId
|
||||||
|
? domains.find((d) => d.id === domainId)
|
||||||
// Test connection state
|
: null;
|
||||||
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);
|
|
||||||
|
|
||||||
// Reset form when dialog opens
|
// Reset form when dialog opens
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
// Reset states
|
|
||||||
setTestResult(null);
|
|
||||||
setCreateError(null);
|
|
||||||
setSubmitting(false);
|
|
||||||
setTesting(false);
|
|
||||||
|
|
||||||
if (editingDomain) {
|
if (editingDomain) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: editingDomain.name,
|
name: editingDomain.name,
|
||||||
domain: editingDomain.domain,
|
domain: editingDomain.domain,
|
||||||
username: editingDomain.username,
|
username: editingDomain.username,
|
||||||
|
authType: editingDomain.authType,
|
||||||
|
apiToken: editingDomain.apiToken || "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.setFieldsValue({
|
form.resetFields();
|
||||||
name: "",
|
form.setFieldsValue({ authType: "password" });
|
||||||
domain: "https://alicorn.cybozu.com",
|
|
||||||
username: "maxz",
|
|
||||||
password: "7ld7i8vd",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [open, editingDomain, form]);
|
}, [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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const processedDomain = processDomain(values.domain);
|
|
||||||
const name = values.name?.trim() || processedDomain;
|
|
||||||
|
|
||||||
// Clear previous error
|
// Process domain: remove protocol prefix and trailing slashes
|
||||||
setCreateError(null);
|
let processedDomain = values.domain.trim();
|
||||||
|
if (processedDomain.startsWith("https://")) {
|
||||||
// For new domains, test connection first
|
processedDomain = processedDomain.slice(8);
|
||||||
if (!isEdit) {
|
} else if (processedDomain.startsWith("http://")) {
|
||||||
setSubmitting(true);
|
processedDomain = processedDomain.slice(7);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
processedDomain = processedDomain.replace(/\/+$/, "");
|
||||||
|
|
||||||
|
// Use domain as name if name is empty
|
||||||
|
const name = values.name?.trim() || processedDomain;
|
||||||
|
|
||||||
if (isEdit && editingDomain) {
|
if (isEdit && editingDomain) {
|
||||||
const params: UpdateDomainParams = {
|
const params: UpdateDomainParams = {
|
||||||
@@ -232,96 +82,132 @@ const DomainForm: React.FC<DomainFormProps> = ({ open, onClose, domainId }) => {
|
|||||||
name,
|
name,
|
||||||
domain: processedDomain,
|
domain: processedDomain,
|
||||||
username: values.username,
|
username: values.username,
|
||||||
|
authType: values.authType,
|
||||||
|
apiToken:
|
||||||
|
values.authType === "api_token" ? values.apiToken : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only include password if provided
|
||||||
if (values.password) {
|
if (values.password) {
|
||||||
params.password = values.password;
|
params.password = values.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await updateDomainById(params);
|
const success = await updateDomainById(params);
|
||||||
setSubmitting(false);
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
message.success("Domain 更新成功");
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
showError("unknown", t("updateFailed"));
|
message.error("更新失败");
|
||||||
}
|
}
|
||||||
} else {
|
} 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 = {
|
const params: CreateDomainParams = {
|
||||||
name,
|
name,
|
||||||
domain: processedDomain,
|
domain: processedDomain,
|
||||||
username: values.username,
|
username: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
|
authType: values.authType,
|
||||||
|
apiToken:
|
||||||
|
values.authType === "api_token" ? values.apiToken : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const success = await createDomain(params);
|
const success = await createDomain(params);
|
||||||
setSubmitting(false);
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
message.success("Domain 创建成功");
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
showError("unknown", t("createFailed"));
|
message.error("创建失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitting(false);
|
|
||||||
console.error("Form validation failed:", error);
|
console.error("Form validation failed:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const authType = Form.useWatch("authType", form);
|
||||||
* Render test button with result icon inside
|
const [testing, setTesting] = React.useState(false);
|
||||||
*/
|
|
||||||
const renderTestButton = () => {
|
|
||||||
const getIcon = () => {
|
|
||||||
if (!testResult) return undefined;
|
|
||||||
return testResult.success ? <CheckCircle2 size={16} color={token.colorSuccess} /> : <XCircle size={16} color={token.colorError} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Test connection with current form values
|
||||||
<Button onClick={handleTestConnection} loading={testing} icon={getIcon()}>
|
const handleTestConnection = async () => {
|
||||||
{t("testConnection")}
|
try {
|
||||||
</Button>
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={isEdit ? t("editDomain") : t("addDomain")}
|
title={isEdit ? "编辑 Domain" : "添加 Domain"}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={520}
|
width={520}
|
||||||
destroyOnHidden
|
destroyOnClose
|
||||||
mask={{ closable: false }}
|
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onValuesChange={handleFieldChange}>
|
<Form
|
||||||
<Form.Item name="name" label={t("name")} style={{ marginTop: 8 }}>
|
form={form}
|
||||||
<Input placeholder={t("nameOptional")} />
|
layout="vertical"
|
||||||
|
className={styles.form}
|
||||||
|
initialValues={{ authType: "password" }}
|
||||||
|
>
|
||||||
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input placeholder="可选,留空则使用域名" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="domain"
|
name="domain"
|
||||||
label={t("kintoneDomain")}
|
label="Kintone 域名"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: t("enterDomain") },
|
{ required: true, message: "请输入域名" },
|
||||||
{
|
{
|
||||||
validator: (_, value) => {
|
validator: (_, value) => {
|
||||||
if (!value) return Promise.resolve();
|
if (!value) return Promise.resolve();
|
||||||
const processed = processDomain(value);
|
// Allow https:// or http:// prefix
|
||||||
if (validateDomainFormat(processed)) {
|
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.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" />
|
<Input placeholder="https://company.kintone.com" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="username" label={t("username")} rules={[{ required: true, message: t("enterUsername") }]}>
|
<Form.Item
|
||||||
<Input placeholder={t("usernameLoginHint")} />
|
name="username"
|
||||||
|
label="用户名"
|
||||||
|
rules={[{ required: true, message: "请输入用户名" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="登录 Kintone 的用户名" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item name="password" label={t("password")} rules={isEdit ? [] : [{ required: true, message: t("enterPassword") }]}>
|
<Form.Item
|
||||||
<InputPassword placeholder={isEdit ? t("keepPasswordHint") : t("enterPassword")} />
|
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>
|
||||||
|
|
||||||
<Form.Item style={{ marginTop: 32, marginBottom: 0 }}>
|
{authType === "password" && (
|
||||||
<div
|
<Form.Item
|
||||||
style={{
|
name="password"
|
||||||
display: "flex",
|
label="密码"
|
||||||
justifyContent: "space-between",
|
rules={isEdit ? [] : [{ required: true, message: "请输入密码" }]}
|
||||||
gap: 8,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Left side: Cancel button and error message */}
|
<Input.Password
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
placeholder={isEdit ? "留空则保持原密码" : "请输入密码"}
|
||||||
<Button onClick={onClose}>{t("cancel", { ns: "common" })}</Button>
|
/>
|
||||||
</div>
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Right side: Test button and Create/Update button */}
|
{authType === "api_token" && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<Form.Item
|
||||||
{createError && <span style={{ color: token.colorError, fontSize: 14 }}>{createError.message}</span>}
|
name="apiToken"
|
||||||
{renderTestButton()}
|
label="API Token"
|
||||||
<Button type="primary" onClick={handleSubmit} loading={submitting}>
|
rules={[{ required: true, message: "请输入 API Token" }]}
|
||||||
{isEdit ? t("update") : t("create")}
|
>
|
||||||
</Button>
|
<Input placeholder="从 Kintone 设置中获取 API Token" />
|
||||||
</div>
|
</Form.Item>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
<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.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,92 +1,59 @@
|
|||||||
/**
|
/**
|
||||||
* DomainList Component
|
* DomainList Component
|
||||||
* Displays list of domains with drag-to-reorder functionality
|
* Displays list of domains with connection status
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { List, Avatar, Tag, Button, Popconfirm, Space, Tooltip } from "antd";
|
||||||
import { Popconfirm, Space } from "antd";
|
import {
|
||||||
import { Button, SortableList, Tooltip } from "@lobehub/ui";
|
CloudServerOutlined,
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from "@renderer/stores";
|
||||||
import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck";
|
import type { Domain } from "@renderer/types/domain";
|
||||||
import type { Domain } from "@shared/types/domain";
|
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
itemWrapper: css`
|
item: css`
|
||||||
width: 100%;
|
padding: ${token.paddingMD}px;
|
||||||
padding: ${token.paddingXS}px ${token.paddingSM}px;
|
|
||||||
border-radius: ${token.borderRadiusLG}px;
|
border-radius: ${token.borderRadiusLG}px;
|
||||||
background: ${token.colorBgContainer};
|
background: ${token.colorBgContainer};
|
||||||
border: 1px solid ${token.colorBorderSecondary};
|
border: 1px solid ${token.colorBorderSecondary};
|
||||||
|
margin-bottom: ${token.marginSM}px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: ${token.colorPrimary};
|
border-color: ${token.colorPrimary};
|
||||||
box-shadow: ${token.boxShadowSecondary};
|
box-shadow: ${token.boxShadowSecondary};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .domain-item-actions {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
|
|
||||||
selectedItem: css`
|
selectedItem: css`
|
||||||
border-color: ${token.colorPrimary};
|
border-color: ${token.colorPrimary};
|
||||||
background: ${token.colorPrimaryBg};
|
background: ${token.colorPrimaryBg};
|
||||||
`,
|
`,
|
||||||
|
|
||||||
domainInfo: css`
|
domainInfo: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: ${token.paddingSM}px;
|
gap: ${token.paddingSM}px;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
`,
|
`,
|
||||||
|
|
||||||
domainName: css`
|
domainName: css`
|
||||||
font-weight: ${token.fontWeightStrong};
|
font-weight: ${token.fontWeightStrong};
|
||||||
font-size: ${token.fontSizeLG}px;
|
font-size: ${token.fontSizeLG}px;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
`,
|
`,
|
||||||
|
|
||||||
domainUrl: css`
|
domainUrl: css`
|
||||||
color: ${token.colorTextSecondary};
|
color: ${token.colorTextSecondary};
|
||||||
font-size: ${token.fontSizeSM}px;
|
font-size: ${token.fontSizeSM}px;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
`,
|
`,
|
||||||
|
|
||||||
actions: css`
|
actions: css`
|
||||||
position: absolute;
|
|
||||||
right: ${token.paddingXXS}px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: ${token.paddingXS}px;
|
gap: ${token.paddingXS}px;
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
background: ${token.colorBgContainer};
|
|
||||||
border-radius: ${token.borderRadiusSM}px;
|
|
||||||
padding: 2px;
|
|
||||||
box-shadow: ${token.boxShadowSecondary};
|
|
||||||
`,
|
`,
|
||||||
|
statusTag: css`
|
||||||
itemContent: css`
|
margin-left: ${token.paddingSM}px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: ${token.paddingXS}px;
|
|
||||||
position: relative;
|
|
||||||
`,
|
|
||||||
|
|
||||||
domainText: css`
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
`,
|
`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -95,20 +62,17 @@ interface DomainListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||||
const { t } = useTranslation("domain");
|
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } = useDomainStore();
|
const {
|
||||||
const { checkAndConfirmDomainSwitch } = usePendingChangesCheck();
|
domains,
|
||||||
|
currentDomain,
|
||||||
|
connectionStatuses,
|
||||||
|
switchDomain,
|
||||||
|
deleteDomain,
|
||||||
|
testConnection,
|
||||||
|
} = useDomainStore();
|
||||||
|
|
||||||
const handleSelect = async (domain: Domain) => {
|
const handleSelect = (domain: Domain) => {
|
||||||
if (currentDomain?.id === domain.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Check for pending changes before switching domain
|
|
||||||
const confirmed = await checkAndConfirmDomainSwitch(domain.id);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switchDomain(domain);
|
switchDomain(domain);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,80 +80,115 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
|||||||
await deleteDomain(id);
|
await deleteDomain(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle reorder - convert SortableListItem[] back to reorder action
|
const getStatusIcon = (id: string) => {
|
||||||
const handleSortChange = (newItems: { id: string }[]) => {
|
const status = connectionStatuses[id];
|
||||||
const newOrder = newItems.map((item) => item.id);
|
switch (status) {
|
||||||
const oldOrder = domains.map((d) => d.id);
|
case "connected":
|
||||||
|
return <CheckCircleOutlined style={{ color: "#52c41a" }} />;
|
||||||
// Find the element that was moved: its position changed from old to new
|
case "error":
|
||||||
// When item at oldIndex moves to newIndex, oldOrder[newIndex] !== newOrder[newIndex]
|
return <CloseCircleOutlined style={{ color: "#ff4d4f" }} />;
|
||||||
for (let i = 0; i < newOrder.length; i++) {
|
default:
|
||||||
if (oldOrder[i] !== newOrder[i]) {
|
return <QuestionCircleOutlined style={{ color: "#faad14" }} />;
|
||||||
// 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 renderItem = (item: { id: string }) => {
|
const getStatusTag = (id: string) => {
|
||||||
const domain = domains.find((d) => d.id === item.id);
|
const status = connectionStatuses[id];
|
||||||
if (!domain) return null;
|
switch (status) {
|
||||||
|
case "connected":
|
||||||
const isSelected = currentDomain?.id === domain.id;
|
return <Tag color="success">已连接</Tag>;
|
||||||
|
case "error":
|
||||||
return (
|
return <Tag color="error">连接失败</Tag>;
|
||||||
<SortableList.Item id={domain.id}>
|
default:
|
||||||
<div className={`${styles.itemWrapper} ${isSelected ? styles.selectedItem : ""}`} onClick={() => handleSelect(domain)}>
|
return <Tag color="warning">未检测</Tag>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
export default DomainList;
|
||||||
|
|||||||
@@ -1,61 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* DomainManager Component
|
* DomainManager Component
|
||||||
* Main container for domain management
|
* Main container for domain management
|
||||||
* Supports collapsed/expanded view
|
|
||||||
* Expand/collapse triggered by clicking header chevron
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Spin } from "antd";
|
import { Button, Empty, Spin } from "antd";
|
||||||
import { Button, Tooltip, Avatar, Empty, Block } from "@lobehub/ui";
|
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Plus, Building, ChevronUp, ChevronDown } from "lucide-react";
|
|
||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from "@renderer/stores";
|
||||||
import DomainList from "./DomainList";
|
import DomainList from "./DomainList";
|
||||||
import DomainForm from "./DomainForm";
|
import DomainForm from "./DomainForm";
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
wrapper: css`
|
|
||||||
height: 100%;
|
|
||||||
margin: 0 ${token.paddingSM}px;
|
|
||||||
`,
|
|
||||||
container: css`
|
container: css`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: ${token.colorFillSecondary};
|
padding: ${token.paddingLG}px;
|
||||||
border-radius: ${token.borderRadiusLG}px;
|
background: ${token.colorBgContainer};
|
||||||
padding: ${token.paddingSM}px;
|
|
||||||
`,
|
`,
|
||||||
header: css`
|
header: css`
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: ${token.paddingXXS}px ${token.paddingSM}px;
|
|
||||||
`,
|
|
||||||
headerLeft: css`
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
margin-bottom: ${token.marginLG}px;
|
||||||
cursor: pointer;
|
padding-bottom: ${token.paddingMD}px;
|
||||||
padding-right: ${token.paddingSM}px;
|
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||||
`,
|
|
||||||
headerRight: css`
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: ${token.paddingXS}px;
|
|
||||||
`,
|
`,
|
||||||
title: css`
|
title: css`
|
||||||
font-size: ${token.fontSize}px;
|
font-size: ${token.fontSizeHeading4}px;
|
||||||
font-weight: ${token.fontWeightStrong};
|
font-weight: ${token.fontWeightStrong};
|
||||||
color: ${token.colorText};
|
color: ${token.colorText};
|
||||||
margin: 0;
|
margin: 0;
|
||||||
`,
|
`,
|
||||||
|
actions: css`
|
||||||
|
display: flex;
|
||||||
|
gap: ${token.paddingXS}px;
|
||||||
|
`,
|
||||||
content: css`
|
content: css`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0 ${token.paddingXXS}px;
|
|
||||||
`,
|
`,
|
||||||
loading: css`
|
loading: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -63,57 +47,11 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 200px;
|
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 {
|
const DomainManager: React.FC = () => {
|
||||||
collapsed?: boolean;
|
|
||||||
onToggleCollapse?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DomainManager: React.FC<DomainManagerProps> = ({ collapsed = false, onToggleCollapse }) => {
|
|
||||||
const { t } = useTranslation("domain");
|
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const { domains, loading, loadDomains, currentDomain } = useDomainStore();
|
const { domains, loading, loadDomains } = useDomainStore();
|
||||||
const [formOpen, setFormOpen] = React.useState(false);
|
const [formOpen, setFormOpen] = React.useState(false);
|
||||||
const [editingDomain, setEditingDomain] = React.useState<string | null>(null);
|
const [editingDomain, setEditingDomain] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -136,83 +74,53 @@ const DomainManager: React.FC<DomainManagerProps> = ({ collapsed = false, onTogg
|
|||||||
setEditingDomain(null);
|
setEditingDomain(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Collapsed view - show current domain only
|
const handleRefresh = () => {
|
||||||
if (collapsed) {
|
loadDomains();
|
||||||
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" href={"https://" + currentDomain.domain}>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
<DomainForm open={formOpen} onClose={handleCloseForm} domainId={editingDomain} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded view - full list
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.container}>
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.header}>
|
||||||
<div className={styles.container}>
|
<h2 className={styles.title}>Domain 管理</h2>
|
||||||
<div className={styles.header}>
|
<div className={styles.actions}>
|
||||||
<Tooltip title={t("collapse")} placement="topRight">
|
<Button
|
||||||
<div className={styles.headerLeft} onClick={onToggleCollapse}>
|
icon={<ReloadOutlined />}
|
||||||
<h3 className={styles.title}>{t("domainManagement")}</h3>
|
onClick={handleRefresh}
|
||||||
<ChevronUp size={16} style={{ opacity: 0.5 }} />
|
loading={loading}
|
||||||
</div>
|
>
|
||||||
</Tooltip>
|
刷新
|
||||||
<div className={styles.headerRight}>
|
</Button>
|
||||||
<Tooltip title={t("addDomain")}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||||
<Button type="primary" size="small" icon={<Plus size={16} />} onClick={handleAdd} />
|
添加 Domain
|
||||||
</Tooltip>
|
</Button>
|
||||||
</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>
|
</div>
|
||||||
</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 React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Upload, Button, List, Space, Tag, message, Popconfirm } from "antd";
|
||||||
import { Upload, List, Space, Tag, message, Popconfirm } from "antd";
|
import {
|
||||||
import { Button } from "@lobehub/ui";
|
InboxOutlined,
|
||||||
import { Inbox, Trash2, File, Code } from "lucide-react";
|
DeleteOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
CodeOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
import { createStyles } from "antd-style";
|
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;
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
@@ -58,20 +63,19 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
maxFileSize = 10 * 1024 * 1024, // 10MB
|
maxFileSize = 10 * 1024 * 1024, // 10MB
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation("file");
|
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
|
|
||||||
const handleBeforeUpload = (file: File) => {
|
const handleBeforeUpload: UploadProps["beforeUpload"] = (file) => {
|
||||||
// Check file type
|
// Check file type
|
||||||
const isJsOrCss = file.name.endsWith(".js") || file.name.endsWith(".css");
|
const isJsOrCss = file.name.endsWith(".js") || file.name.endsWith(".css");
|
||||||
if (!isJsOrCss) {
|
if (!isJsOrCss) {
|
||||||
message.error(t("onlyJsCss"));
|
message.error("只支持 .js 和 .css 文件");
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size
|
// Check file size
|
||||||
if (file.size > maxFileSize) {
|
if (file.size > maxFileSize) {
|
||||||
message.error(t("fileSizeLimit", { size: maxFileSize / 1024 / 1024 }));
|
message.error(`文件大小不能超过 ${maxFileSize / 1024 / 1024}MB`);
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,12 +120,20 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<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">
|
<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>
|
||||||
<p className="ant-upload-text">{t("clickOrDragToUpload")}</p>
|
|
||||||
<p className="ant-upload-hint">{t("supportFiles", { size: maxFileSize / 1024 / 1024 })}</p>
|
|
||||||
</Dragger>
|
</Dragger>
|
||||||
|
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
@@ -134,16 +146,16 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{t("selectedFiles", { count: files.length })}</span>
|
<span>已选择 {files.length} 个文件</span>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t("confirmClear")}
|
title="确认清空"
|
||||||
description={t("confirmClearDesc")}
|
description="确定要清空所有文件吗?"
|
||||||
onConfirm={handleClear}
|
onConfirm={handleClear}
|
||||||
okText={t("clearAll")}
|
okText="清空"
|
||||||
cancelText={t("cancel", { ns: "common" })}
|
cancelText="取消"
|
||||||
>
|
>
|
||||||
<Button size="small" danger>
|
<Button size="small" danger>
|
||||||
{t("clearAll")}
|
清空全部
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,18 +165,31 @@ const FileUploader: React.FC<FileUploaderProps> = ({
|
|||||||
renderItem={(file, index) => (
|
renderItem={(file, index) => (
|
||||||
<div className={styles.fileItem}>
|
<div className={styles.fileItem}>
|
||||||
<div className={styles.fileInfo}>
|
<div className={styles.fileInfo}>
|
||||||
<File size={20} style={{ color: getFileTypeColor(file.fileType) }} />
|
<FileOutlined
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
color: getFileTypeColor(file.fileType),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.fileName}>{file.fileName}</div>
|
<div className={styles.fileName}>{file.fileName}</div>
|
||||||
<div className={styles.fileSize}>
|
<div className={styles.fileSize}>
|
||||||
{formatFileSize(new Blob([file.content]).size)}
|
{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()}
|
{file.fileType.toUpperCase()}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="text" danger icon={<Trash2 size={16} />} onClick={() => handleRemove(index)} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
/>
|
||||||
</div>
|
</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 React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import {
|
||||||
import { List, Tag, Space, Spin, Popconfirm, Typography } from "antd";
|
List,
|
||||||
import { Button, Tooltip, Empty, Avatar } from "@lobehub/ui";
|
Avatar,
|
||||||
|
Tag,
|
||||||
import { History, Download, Trash2, Undo2, Code, FileText } from "lucide-react";
|
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 { createStyles } from "antd-style";
|
||||||
import { useVersionStore } from "@renderer/stores";
|
import { useVersionStore } from "@renderer/stores";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from "@renderer/stores";
|
||||||
import { useAppStore } 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;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -86,11 +102,11 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const VersionHistory: React.FC = () => {
|
const VersionHistory: React.FC = () => {
|
||||||
const { t } = useTranslation("version");
|
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
const { currentDomain } = useDomainStore();
|
const { currentDomain } = useDomainStore();
|
||||||
const { currentApp } = useAppStore();
|
const { currentApp } = useAppStore();
|
||||||
const { versions, loading, setVersions, setLoading, removeVersion } = useVersionStore();
|
const { versions, loading, setVersions, setLoading, removeVersion } =
|
||||||
|
useVersionStore();
|
||||||
|
|
||||||
// Load versions when app changes
|
// Load versions when app changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -165,9 +181,9 @@ const VersionHistory: React.FC = () => {
|
|||||||
|
|
||||||
const getSourceTag = (source: Version["source"]) => {
|
const getSourceTag = (source: Version["source"]) => {
|
||||||
const config = {
|
const config = {
|
||||||
upload: { color: "blue", text: t("sourceUpload") },
|
upload: { color: "blue", text: "上传" },
|
||||||
download: { color: "green", text: t("sourceDownload") },
|
download: { color: "green", text: "下载" },
|
||||||
rollback: { color: "orange", text: t("sourceRollback") },
|
rollback: { color: "orange", text: "回滚" },
|
||||||
};
|
};
|
||||||
return config[source] || { color: "default", text: source };
|
return config[source] || { color: "default", text: source };
|
||||||
};
|
};
|
||||||
@@ -176,7 +192,10 @@ const VersionHistory: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div style={{ padding: 24, textAlign: "center" }}>
|
<div style={{ padding: 24, textAlign: "center" }}>
|
||||||
<Empty description={t("selectApp", { ns: "app" })} />
|
<Empty
|
||||||
|
description="请选择一个应用"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -194,18 +213,25 @@ const VersionHistory: React.FC = () => {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
<History size={20} />
|
<HistoryOutlined style={{ fontSize: 20 }} />
|
||||||
<Text strong>{t("title")}</Text>
|
<Text strong>版本历史</Text>
|
||||||
<Tag>{t("totalVersions", { count: versions.length })}</Tag>
|
<Tag>{versions.length} 个版本</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Button icon={<Download size={16} />} onClick={loadVersions} loading={loading}>
|
<Button
|
||||||
{t("refresh", { ns: "common" })}
|
icon={<DownloadOutlined />}
|
||||||
|
onClick={loadVersions}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
{versions.length === 0 ? (
|
{versions.length === 0 ? (
|
||||||
<Empty description={t("noVersions")} />
|
<Empty
|
||||||
|
description="暂无版本历史"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<List
|
<List
|
||||||
dataSource={versions}
|
dataSource={versions}
|
||||||
@@ -215,9 +241,16 @@ const VersionHistory: React.FC = () => {
|
|||||||
<div className={styles.versionItem}>
|
<div className={styles.versionItem}>
|
||||||
<div className={styles.versionInfo}>
|
<div className={styles.versionInfo}>
|
||||||
<Avatar
|
<Avatar
|
||||||
icon={version.fileType === "js" ? <Code size={16} /> : <FileText size={16} />}
|
icon={
|
||||||
|
version.fileType === "js" ? (
|
||||||
|
<CodeOutlined />
|
||||||
|
) : (
|
||||||
|
<FileTextOutlined />
|
||||||
|
)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: version.fileType === "js" ? "#f7df1e" : "#264de4",
|
backgroundColor:
|
||||||
|
version.fileType === "js" ? "#f7df1e" : "#264de4",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className={styles.versionDetails}>
|
<div className={styles.versionDetails}>
|
||||||
@@ -225,48 +258,75 @@ const VersionHistory: React.FC = () => {
|
|||||||
<div className={styles.versionMeta}>
|
<div className={styles.versionMeta}>
|
||||||
<Tag color={sourceTag.color}>{sourceTag.text}</Tag>
|
<Tag color={sourceTag.color}>{sourceTag.text}</Tag>
|
||||||
<Tag>{version.fileType.toUpperCase()}</Tag>
|
<Tag>{version.fileType.toUpperCase()}</Tag>
|
||||||
<Text type="secondary">{formatFileSize(version.size)}</Text>
|
<Text type="secondary">
|
||||||
<Text type="secondary">{formatDate(version.createdAt)}</Text>
|
{formatFileSize(version.size)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
{formatDate(version.createdAt)}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{version.tags && version.tags.length > 0 && (
|
{version.tags && version.tags.length > 0 && (
|
||||||
<div className={styles.tags}>
|
<div className={styles.tags}>
|
||||||
{version.tags.map((tag, i) => (
|
{version.tags.map((tag, i) => (
|
||||||
<Tag key={i} color="processing">
|
<Tag
|
||||||
|
key={i}
|
||||||
|
icon={<TagOutlined />}
|
||||||
|
color="processing"
|
||||||
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{version.notes && <Text type="secondary">{version.notes}</Text>}
|
{version.notes && (
|
||||||
|
<Text type="secondary">{version.notes}</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={t("viewCode")}>
|
<Tooltip title="查看代码">
|
||||||
<Button type="text" size="small" icon={<Code size={16} />} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CodeOutlined />}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t("download", { ns: "common" })}>
|
<Tooltip title="下载">
|
||||||
<Button type="text" size="small" icon={<Download size={16} />} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={t("confirmRollback")}>
|
<Tooltip title="回滚到此版本">
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t("confirmRollback")}
|
title="确认回滚"
|
||||||
description={t("confirmRollbackDesc")}
|
description="确定要回滚到此版本吗?"
|
||||||
onConfirm={() => handleRollback(version)}
|
onConfirm={() => handleRollback(version)}
|
||||||
okText={t("sourceRollback")}
|
okText="回滚"
|
||||||
cancelText={t("cancel", { ns: "common" })}
|
cancelText="取消"
|
||||||
>
|
>
|
||||||
<Button type="text" size="small" icon={<Undo2 size={16} />} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<RollbackOutlined />}
|
||||||
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={t("confirmDelete")}
|
title="确认删除"
|
||||||
description={t("confirmDeleteDesc")}
|
description="确定要删除此版本吗?"
|
||||||
onConfirm={() => handleDelete(version.id)}
|
onConfirm={() => handleDelete(version.id)}
|
||||||
okText={t("delete", { ns: "common" })}
|
okText="删除"
|
||||||
cancelText={t("cancel", { ns: "common" })}
|
cancelText="取消"
|
||||||
>
|
>
|
||||||
<Button type="text" size="small" danger icon={<Trash2 size={16} />} />
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</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" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly MAIN_VITE_API_URL: string;
|
readonly MAIN_VITE_API_URL: string
|
||||||
readonly MAIN_VITE_DEBUG: string;
|
readonly MAIN_VITE_DEBUG: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family:
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
"Segoe UI Symbol", "Noto Color Emoji";
|
'Noto Color Emoji';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* macOS style window controls area */
|
/* macOS style window controls area */
|
||||||
@@ -47,4 +47,4 @@ body,
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #5a5a5a;
|
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 React from 'react'
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from 'react-dom/client'
|
||||||
import { App as AntdApp } from "antd";
|
import { ConfigProvider, App as AntdApp } from 'antd'
|
||||||
import { ConfigProvider, ThemeProvider } from "@lobehub/ui";
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { ThemeProvider } from '@lobehub/ui'
|
||||||
import i18n from "./i18n";
|
import App from './App'
|
||||||
import App from "./App";
|
import './index.css'
|
||||||
import { useThemeStore } from "./stores/themeStore";
|
|
||||||
|
|
||||||
import { motion } from "motion/react";
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|
||||||
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(
|
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ConfigProvider motion={motion}>
|
<ConfigProvider
|
||||||
<I18nextProvider i18n={i18n}>
|
locale={zhCN}
|
||||||
<ThemeApp />
|
theme={{
|
||||||
</I18nextProvider>
|
token: {
|
||||||
|
colorPrimary: '#1677ff',
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AntdApp>
|
||||||
|
<App />
|
||||||
|
</AntdApp>
|
||||||
|
</ThemeProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>,
|
||||||
);
|
)
|
||||||
@@ -1,105 +1,72 @@
|
|||||||
/**
|
/**
|
||||||
* App Store
|
* App Store
|
||||||
* Manages app browsing state (apps, current selection, pagination)
|
* Manages app browsing state (spaces, apps, current selection)
|
||||||
* Persisted to localStorage for offline access
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import type {
|
||||||
import type { AppResponse, AppDetail } from "@shared/types/kintone";
|
KintoneSpace,
|
||||||
|
KintoneApp,
|
||||||
|
AppDetail,
|
||||||
|
} from "@renderer/types/kintone";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// State
|
// State
|
||||||
apps: AppResponse[];
|
spaces: KintoneSpace[];
|
||||||
|
apps: KintoneApp[];
|
||||||
|
currentSpace: KintoneSpace | null;
|
||||||
currentApp: AppDetail | null;
|
currentApp: AppDetail | null;
|
||||||
selectedAppId: string | null;
|
selectedAppId: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
currentPage: number;
|
|
||||||
pageSize: number;
|
|
||||||
|
|
||||||
// Search state
|
|
||||||
searchText: string;
|
|
||||||
|
|
||||||
// Load metadata
|
|
||||||
loadedAt: string | null;
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setApps: (apps: AppResponse[]) => void;
|
setSpaces: (spaces: KintoneSpace[]) => void;
|
||||||
|
setApps: (apps: KintoneApp[]) => void;
|
||||||
|
setCurrentSpace: (space: KintoneSpace | null) => void;
|
||||||
setCurrentApp: (app: AppDetail | null) => void;
|
setCurrentApp: (app: AppDetail | null) => void;
|
||||||
setSelectedAppId: (id: string | null) => void;
|
setSelectedAppId: (id: string | null) => void;
|
||||||
setLoading: (loading: boolean) => void;
|
setLoading: (loading: boolean) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
setCurrentPage: (page: number) => void;
|
|
||||||
setPageSize: (size: number) => void;
|
|
||||||
setSearchText: (text: string) => void;
|
|
||||||
setLoadedAt: (time: string | null) => void;
|
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState = {
|
export const useAppStore = create<AppState>()((set) => ({
|
||||||
|
// Initial state
|
||||||
|
spaces: [],
|
||||||
apps: [],
|
apps: [],
|
||||||
|
currentSpace: null,
|
||||||
currentApp: null,
|
currentApp: null,
|
||||||
selectedAppId: null,
|
selectedAppId: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
currentPage: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
searchText: "",
|
|
||||||
loadedAt: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAppStore = create<AppState>()(
|
// Actions
|
||||||
persist(
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
(set) => ({
|
|
||||||
...initialState,
|
|
||||||
|
|
||||||
// Actions
|
setApps: (apps) => set({ apps }),
|
||||||
setApps: (apps) =>
|
|
||||||
set({
|
|
||||||
apps,
|
|
||||||
loadedAt: new Date().toISOString(),
|
|
||||||
}),
|
|
||||||
|
|
||||||
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) =>
|
clear: () =>
|
||||||
set({
|
set({
|
||||||
pageSize,
|
spaces: [],
|
||||||
currentPage: 1, // Reset to first page when page size changes
|
apps: [],
|
||||||
}),
|
currentSpace: null,
|
||||||
|
currentApp: null,
|
||||||
setSearchText: (searchText) =>
|
selectedAppId: null,
|
||||||
set({
|
loading: false,
|
||||||
searchText,
|
error: null,
|
||||||
currentPage: 1, // Reset to first page when search changes
|
|
||||||
}),
|
|
||||||
|
|
||||||
setLoadedAt: (loadedAt) => set({ loadedAt }),
|
|
||||||
|
|
||||||
clear: () => set(initialState),
|
|
||||||
}),
|
}),
|
||||||
{
|
}));
|
||||||
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 { create } from "zustand";
|
||||||
import { useAppStore } from "./appStore";
|
|
||||||
import { useFileChangeStore } from "./fileChangeStore";
|
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
import type { Domain, DomainWithStatus } from "@renderer/types/domain";
|
||||||
import type { ConnectionStatus } from "@shared/types/domain";
|
import type { ConnectionStatus } from "@renderer/types/domain";
|
||||||
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
|
import type {
|
||||||
|
CreateDomainParams,
|
||||||
|
UpdateDomainParams,
|
||||||
|
} from "@renderer/types/ipc";
|
||||||
|
|
||||||
interface DomainState {
|
interface DomainState {
|
||||||
// State
|
// State
|
||||||
@@ -25,9 +26,12 @@ interface DomainState {
|
|||||||
addDomain: (domain: Domain) => void;
|
addDomain: (domain: Domain) => void;
|
||||||
updateDomain: (domain: Domain) => void;
|
updateDomain: (domain: Domain) => void;
|
||||||
removeDomain: (id: string) => void;
|
removeDomain: (id: string) => void;
|
||||||
reorderDomains: (fromIndex: number, toIndex: number) => void;
|
|
||||||
setCurrentDomain: (domain: Domain | null) => 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;
|
setLoading: (loading: boolean) => void;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
@@ -63,29 +67,27 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
updateDomain: (domain) =>
|
updateDomain: (domain) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
domains: state.domains.map((d) => (d.id === domain.id ? domain : d)),
|
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) =>
|
removeDomain: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
domains: state.domains.filter((d) => d.id !== id),
|
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 }),
|
setCurrentDomain: (domain) => set({ currentDomain: domain }),
|
||||||
|
|
||||||
setConnectionStatus: (id, status, error) =>
|
setConnectionStatus: (id, status, error) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
connectionStatuses: { ...state.connectionStatuses, [id]: status },
|
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 }),
|
setLoading: (loading) => set({ loading }),
|
||||||
@@ -106,20 +108,14 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to load domains",
|
error:
|
||||||
|
error instanceof Error ? error.message : "Failed to load domains",
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
createDomain: async (params: CreateDomainParams) => {
|
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 });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const result = await window.api.createDomain(params);
|
const result = await window.api.createDomain(params);
|
||||||
@@ -135,7 +131,10 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to create domain",
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to create domain",
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -148,8 +147,13 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
const result = await window.api.updateDomain(params);
|
const result = await window.api.updateDomain(params);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
domains: state.domains.map((d) => (d.id === result.data.id ? result.data : d)),
|
domains: state.domains.map((d) =>
|
||||||
currentDomain: state.currentDomain?.id === result.data.id ? result.data : state.currentDomain,
|
d.id === result.data.id ? result.data : d,
|
||||||
|
),
|
||||||
|
currentDomain:
|
||||||
|
state.currentDomain?.id === result.data.id
|
||||||
|
? result.data
|
||||||
|
: state.currentDomain,
|
||||||
loading: false,
|
loading: false,
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
@@ -159,7 +163,10 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to update domain",
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to update domain",
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -173,7 +180,8 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
domains: state.domains.filter((d) => d.id !== id),
|
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,
|
loading: false,
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
@@ -183,7 +191,10 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error instanceof Error ? error.message : "Failed to delete domain",
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to delete domain",
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
@@ -191,32 +202,10 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
switchDomain: async (domain: Domain) => {
|
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 });
|
set({ currentDomain: domain });
|
||||||
|
// Test connection after switching
|
||||||
// 3. Test connection after switching
|
|
||||||
const status = await get().testConnection(domain.id);
|
const status = await get().testConnection(domain.id);
|
||||||
// Check if we're still on the same domain before updating connection status
|
if (status) {
|
||||||
if (status && get().currentDomain?.id === requestDomainId) {
|
|
||||||
set({
|
set({
|
||||||
connectionStatuses: {
|
connectionStatuses: {
|
||||||
...get().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) => {
|
testConnection: async (id: string) => {
|
||||||
@@ -253,7 +225,9 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
...state.connectionStatuses,
|
...state.connectionStatuses,
|
||||||
[id]: status.connectionStatus,
|
[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;
|
return status;
|
||||||
} else {
|
} else {
|
||||||
@@ -274,7 +248,10 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
connectionStatuses: { ...state.connectionStatuses, [id]: "error" },
|
connectionStatuses: { ...state.connectionStatuses, [id]: "error" },
|
||||||
connectionErrors: {
|
connectionErrors: {
|
||||||
...state.connectionErrors,
|
...state.connectionErrors,
|
||||||
[id]: error instanceof Error ? error.message : "Connection test failed",
|
[id]:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Connection test failed",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return null;
|
return null;
|
||||||
@@ -287,6 +264,6 @@ export const useDomainStore = create<DomainState>()(
|
|||||||
domains: state.domains,
|
domains: state.domains,
|
||||||
currentDomain: state.currentDomain,
|
currentDomain: state.currentDomain,
|
||||||
}),
|
}),
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,303 +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[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
addFile: (domainId, appId, entry) => {
|
|
||||||
const key = appKey(domainId, appId);
|
|
||||||
set((state) => {
|
|
||||||
const existing = state.appFiles[key] ?? {
|
|
||||||
files: [],
|
|
||||||
initialized: true,
|
|
||||||
originalSectionOrders: {},
|
|
||||||
};
|
|
||||||
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: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "file-change-storage",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -5,11 +5,5 @@
|
|||||||
|
|
||||||
export { useDomainStore } from "./domainStore";
|
export { useDomainStore } from "./domainStore";
|
||||||
export { useAppStore } from "./appStore";
|
export { useAppStore } from "./appStore";
|
||||||
|
export { useDeployStore } from "./deployStore";
|
||||||
export { useVersionStore } from "./versionStore";
|
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 { create } from "zustand";
|
||||||
import type { Version } from "@shared/types/version";
|
import type { Version } from "@renderer/types/version";
|
||||||
|
|
||||||
interface VersionState {
|
interface VersionState {
|
||||||
// State
|
// State
|
||||||
@@ -45,7 +45,8 @@ export const useVersionStore = create<VersionState>()((set) => ({
|
|||||||
removeVersion: (id) =>
|
removeVersion: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
versions: state.versions.filter((v) => v.id !== id),
|
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 }),
|
setSelectedVersion: (version) => set({ selectedVersion: version }),
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
export interface Domain {
|
export interface Domain {
|
||||||
id: string; // UUID
|
id: string; // UUID
|
||||||
name: string; // 自定义名称
|
name: string; // 自定义名称
|
||||||
username: string; // 用户名(邮箱)
|
|
||||||
domain: string; // Kintone 域名
|
domain: string; // Kintone 域名
|
||||||
|
username: string; // 用户名(邮箱)
|
||||||
|
authType: "password" | "api_token";
|
||||||
|
apiToken?: string; // 可选,当 authType 为 api_token 时
|
||||||
createdAt: string; // ISO 8601
|
createdAt: string; // ISO 8601
|
||||||
updatedAt: 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",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||||
"include": [
|
"include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"],
|
||||||
"electron.vite.config.ts",
|
|
||||||
"src/main/**/*",
|
|
||||||
"src/preload/**/*",
|
|
||||||
"src/renderer/**/*",
|
|
||||||
"src/shared/**/*"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"**/*.d.ts"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"skipLibCheck": true,
|
"composite": true,
|
||||||
"types": ["electron-vite/node"],
|
"types": ["electron-vite/node"],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@main/*": ["src/main/*"],
|
"@main/*": ["src/main/*"],
|
||||||
"@preload/*": ["src/preload/*"],
|
"@preload/*": ["src/preload/*"],
|
||||||
"@renderer/*": ["src/renderer/src/*"],
|
"@renderer/*": ["src/renderer/src/*"]
|
||||||
"@shared/*": ["src/shared/*"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||||
"include": ["src/renderer/src/**/*", "src/shared/**/*"],
|
"include": [
|
||||||
|
"src/renderer/src/**/*"
|
||||||
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@renderer/*": ["src/renderer/src/*"],
|
"@renderer/*": ["src/renderer/src/*"]
|
||||||
"@shared/*": ["src/shared/*"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user