Compare commits

..

48 Commits

Author SHA1 Message Date
cf903872b8 Update UX 2026-03-18 11:54:30 +08:00
d981cd4d8b Fix dropzone UI 2026-03-18 10:53:13 +08:00
d03d1e75c2 fix divider css 2026-03-18 10:42:25 +08:00
5a470c146a add mouse back btn 2026-03-18 10:23:04 +08:00
e0d0cac91c remove test 2026-03-18 10:18:39 +08:00
c3a333e2ed UI improve 2026-03-18 09:21:23 +08:00
47d3bd0124 chore: adjust printWidth to 160 and format code 2026-03-18 00:25:52 +08:00
3de55b83f0 chore: add dev configs (prettier, editorconfig, vscode settings) 2026-03-18 00:21:47 +08:00
fca824beea add order hint 2026-03-18 00:14:38 +08:00
0f9f1a94fa remove wrong deploy btn 2026-03-17 22:16:13 +08:00
23a4e1e8cb update reload funtion 2026-03-17 17:51:43 +08:00
9a6e6b8ecb upload multi files 2026-03-17 16:56:34 +08:00
d01d7636c8 add refresh btn 2026-03-17 16:50:45 +08:00
9fddc9836e fix deploy 2026-03-17 16:05:22 +08:00
dd5f16ef65 ui fix 2026-03-17 13:38:24 +08:00
95acfd2b3b refactor(DomainManager): improve expand/collapse UI with card style and chevron toggles
- Collapsed state: card style, ChevronDown button, no hover effect
- Expanded state: card style, ChevronUp in header
- Selected domain: 3px left indicator bar with primary color
- Remove bottom toggle areas
- Add expand/collapse translation keys
2026-03-17 09:50:19 +08:00
8b096fcf53 feat: add download all as zip feature
- Add adm-zip dependency for zip compression
- Add DownloadAllZipParams/Result IPC types
- Implement registerDownloadAllZip handler in main process
- Update frontend download flow with save dialog
- ZIP includes pc/, mobile/ folders and metadata.json
2026-03-17 08:50:27 +08:00
a34401ce7a update theme 2026-03-16 23:52:47 +08:00
a4fe857956 feat(theme): add dark mode support with Light/Dark/Auto modes 2026-03-16 22:06:13 +08:00
b62ce11e23 add store and UI fix 2026-03-16 17:42:00 +08:00
53541f8b46 fix UI 2026-03-16 17:13:22 +08:00
970d6d9538 fix load app, download, upgrade 2026-03-16 15:53:59 +08:00
b34720fccf fix UI 2026-03-16 14:29:11 +08:00
e823e703ea chore: set Japanese as default locale and remove i18n-cli
- Remove @lobehub/i18n-cli package and .i18nrc.js config
- Delete locales/default/ directory (translation source)
- Change DEFAULT_LOCALE and fallbackLng to ja-JP
- Reorder LOCALES array with Japanese first
- Simplify language selector to horizontal solid button style
2026-03-16 11:44:29 +08:00
43820127f4 feat(ui): refactor AppDetail layout and add download with save dialog
- Remove Tabs component, merge JS/CSS sections into single scrollable list
- Add sub-page navigation for code viewing with back button
- Put app name and buttons on same row to save vertical space
- Reduce DomainForm spacing (marginMD -> marginSM)
- Use FileCode/FileText icons to indicate JS/CSS file types
- Add save dialog for single file download with loading state
- Add IPC handlers: showSaveDialog, saveFileContent
- Fix duplicate file extension issue in download filenames
- Add i18n keys: backToList, downloadSuccess, downloadFailed, downloadAllSuccess
2026-03-16 00:07:49 +08:00
345f0b344c fix UI 2026-03-15 20:31:44 +08:00
7b1fd11bf3 chore: remove .sisyphus task artifacts 2026-03-15 14:52:42 +08:00
d1903b1fe7 feat(ui): migrate Ant Design components to LobeHub UI
- Replace Button, Avatar, Tooltip, Empty, Select, Modal, Form with LobeHub UI
- Migrate icons from @ant-design/icons to lucide-react
- Keep Settings icon using SettingOutlined from @ant-design/icons
- Update all components to use LobeHub UI styling patterns
2026-03-15 14:52:13 +08:00
078bfd10ee update filename 2026-03-15 13:17:38 +08:00
f7ad51b9ec i18n and UI fix 2026-03-15 12:46:35 +08:00
43289845fc update UI 2026-03-13 22:37:02 +08:00
23edd0faab update 2026-03-13 18:15:01 +08:00
4d92085957 remove api token auth 2026-03-13 15:46:19 +08:00
8ff555f9e4 update 2026-03-13 15:25:44 +08:00
4ec09661cd add test 2026-03-13 12:03:02 +08:00
f53f43a6b9 update main ts 2026-03-12 23:03:01 +08:00
4a8cd53617 fix css 2026-03-12 18:00:41 +08:00
78f31d44ec fix ts 2026-03-12 17:42:00 +08:00
914ca64c10 fix UI 2026-03-12 17:13:13 +08:00
97af24ab2b refactor(preload): use Proxy for auto IPC routing
Replace manual IPC method mapping with Proxy-based approach.
Method names now automatically route to IPC channels,
eliminating the need to update this file when adding new APIs.

- Uses Proxy to intercept property access
- Static properties (platform) handled separately
- Supports variable argument counts via rest parameters
2026-03-12 16:15:47 +08:00
4ae451fc73 fix ts 2026-03-12 15:59:54 +08:00
51ccd265ba remove spaces 2026-03-12 15:45:40 +08:00
cb6d3f7ee0 fix ts problem 2026-03-12 15:11:41 +08:00
7be6a3ff6a chore: add ESLint v9 config with typescript-eslint
- Add eslint.config.mjs with typescript-eslint integration
- Install typescript-eslint, @typescript-eslint/eslint-plugin, @typescript-eslint/parser
- Fix unused params warning in ipc-handlers.ts
2026-03-12 13:57:38 +08:00
0945d7f0b2 refactor(types): move shared types from renderer to shared directory
- Move type definitions from src/renderer/src/types/ to src/shared/types/
- Add @shared/* path alias to tsconfig.node.json and tsconfig.web.json
- Update all imports from @renderer/types/* to @shared/types/*
- Update AGENTS.md with new directory structure and path alias

This fixes architecture violation where main/preload processes imported
from renderer directory. Types are now properly shared across all processes.
2026-03-12 13:01:58 +08:00
1e9a01b6c1 use rest-api-client 2026-03-12 12:27:16 +08:00
d66dae74e8 fix ts type error 2026-03-12 12:24:34 +08:00
c903733f2c feat(domain): improve domain form UX
- Make name field optional (defaults to domain if empty)
- Simplify domain placeholder and error messages
- Add test connection button for credential validation before save
2026-03-12 12:07:33 +08:00
93 changed files with 7446 additions and 4159 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
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

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
out/
dist/
release/
node_modules/
*.min.js
*.min.css

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 160,
"semi": true,
"singleQuote": false
}

View File

@@ -1,19 +0,0 @@
{
"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"
}
}

View File

@@ -1,57 +0,0 @@
# 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/

View File

@@ -1,857 +0,0 @@
# 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 Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "editorconfig.editorconfig", "lokalise.i18n-ally"]
}

14
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"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
View File

@@ -1,81 +1,68 @@
# AGENTS.md # AGENTS.md
Kintone Customize Manager 项目开发指南 Kintone Customize Manager - Electron + React 应用,用于管理 Kintone 自定义资源
## 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
# ESLint 检查
npm run lint
# 格式化代码
npm run format
```
## 2. 项目结构
``` ```
kintone-customize-manager/ src/
├── src/ ├── main/ # Electron 主进程
│ ├── main/ # Electron 主进程 │ ├── index.ts # 入口,创建窗口
│ ├── index.ts # 主进程入口 │ ├── ipc-handlers.ts # IPC 处理器(所有通信入口
│ ├── ipc-handlers.ts # IPC 通信处理 │ ├── storage.ts # 文件存储 + 密码加密
│ ├── storage.ts # 文件系统操作 └── kintone-api.ts # Kintone REST API 封装
├── kintone-api.ts # Kintone API 封装 ├── preload/ # Preload 脚本
│ ├── updater.ts # 自动更新逻辑 │ ├── index.ts # 暴露 API 到渲染进程
│ └── config.ts # 配置管理 │ └── index.d.ts # 类型声明
│ ├── preload/ # Preload 脚本 ├── shared/ # 跨进程共享代码
│ └── index.ts # 暴露 API 到渲染进程 │ └── types/ # 共享类型定义
└── renderer/ # React 渲染进程 └── renderer/ # React 渲染进程
└── src/ └── src/
├── main.tsx # React 入口 ├── main.tsx # React 入口
├── App.tsx # 根组件 ├── App.tsx # 根组件
├── components/ # React 组件 ├── components/ # React 组件
├── hooks/ # 自定义 Hooks ├── stores/ # Zustand Stores
── stores/ # Zustand Stores ── locales/ # i18n 翻译文件
│ ├── utils/ # 工具函数 ```
│ └── types/ # TypeScript 类型
├── resources/ # 应用资源(图标等) ### 数据流
└── build/ # 构建配置
```
Renderer (React) → Preload API → IPC → Main Process → Storage/Kintone API
Result<T> 返回
``` ```
## 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. 代码风格
@@ -83,283 +70,139 @@ import { ipcHandler } from '@main/ipc-handlers'
```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`)
| 组件文件 | PascalCase | `DomainManager.tsx` | - Store 文件: `camelCase + Store` (e.g., `domainStore.ts`)
| 工具函数文件 | camelCase | `formatDate.ts` | - 函数/变量: `camelCase` (e.g., `handleSubmit`)
| Store 文件 | camelCase + Store | `domainStore.ts` | - 常量: `UPPER_SNAKE_CASE` (e.g., `MAX_FILE_SIZE`)
| 类型文件 | camelCase | `types.ts` | - 类型/接口: `PascalCase` (e.g., `DomainConfig`)
| 组件名 | PascalCase | `DomainManager` |
| 函数/变量 | camelCase | `handleSubmit` |
| 常量 | UPPER_SNAKE_CASE | `MAX_FILE_SIZE` |
| 类型/接口 | PascalCase | `DomainConfig` |
### TypeScript 规范 ### TypeScript 规范
```typescript - 显式类型定义,避免 `any`
// 显式类型定义 - 使用字面量联合类型(如 `authType: "password" | "api_token"`
interface DomainConfig { - 异步函数返回 `Promise<T>`
id: string - 使用类型守卫处理 `unknown`
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 组件规范
```typescript - Hooks 放在组件顶部
// 函数组件优先 - 事件处理函数使用 `useCallback`
interface DomainListProps { - 使用 TypeScript 显式定义 props 类型
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
import { create } from 'zustand' type Result<T> = { success: true; data: T } | { success: false; error: string };
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' }
)
)
``` ```
## 5. 错误处理 - IPC 调用使用 `invoke` 返回 `Result<T>`
- 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
// IPC 处理错误 import { Button } from "@lobehub/ui";
ipcMain.handle('fetch-domains', async () => { import { createStyles } from "antd-style";
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
```typescript ## 7. 国际化 (i18n)
// 使用中文默认
import zhCN from 'antd/locale/zh_CN'
<ConfigProvider locale={zhCN}> - 支持语言: `en-US`, `ja-JP`, `zh-CN`
<App /> - 翻译文件位置: `src/renderer/src/locales/{locale}/{namespace}.json`
</ConfigProvider> - 使用 `react-i18next` 进行翻译
``` - Ant Design 默认使用日文: `import jaJP from 'antd/locale/ja_JP'`
## 8. 安全规范 ## 8. 安全规范
### 密码存储 - 密码使用 `electron``safeStorage` 加密存储
- WebPreferences 必须:`contextIsolation: true`, `nodeIntegration: false`, `sandbox: false`
```typescript ## 9. 错误处理
// 使用 safeStorage 加密存储
import { safeStorage } from 'electron'
// 加密 - 所有 IPC 返回 `Result<T>` 格式
const encrypted = safeStorage.encryptString(password) - 渲染进程检查 `result.success` 处理错误
// 解密 ## 10. fnm 环境配置
const decrypted = safeStorage.decryptString(encrypted)
```
### CSP 配置 所有 npm/npx 命令需加载 fnm 环境:
```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
# 方式一:使用 wrapper 脚本 # 使用 fnm 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
``` ```
## 10. 注意事项 ## 11. 技术栈约束
1. **ESM Only**: LobeHub UI 仅支持 ESM确保 `tsconfig.json``"module": "ESNext"` 1. **React 19**: 使用 `@types/react@^19.0.0`
2. **React 19**: 必须使用 `@types/react@^19.0.0``@types/react-dom@^19.0.0` 2. **CSS 方案**: 使用 `antd-style`,禁止 Tailwind
3. **CSS 方案**: 使用 `antd-style`,不使用 Tailwind CSS 3. **禁止 `as any`**: 使用类型守卫或 `unknown`
4. **Context Isolation**: 必须启用 `contextIsolation: true` 4. **函数组件优先**: 禁止 class 组件
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 Normal file
View File

@@ -0,0 +1,136 @@
# 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 是实时有效的
- 只要文件附加到 CustomizationfileKey 就永久有效
- 参考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`

View File

@@ -178,8 +178,7 @@ kintone-customize-manager/
│ │ ├─ main.tsx # 渲染进程入口 │ │ ├─ main.tsx # 渲染进程入口
│ │ ├─ App.tsx │ │ ├─ App.tsx
│ │ ├─ components/ # React 组件 │ │ ├─ components/ # React 组件
│ │ │ ├─ DomainManager/ │ │ │ ├─ AppList/
│ │ │ ├─ SpaceTree/
│ │ │ ├─ AppDetail/ │ │ │ ├─ AppDetail/
│ │ │ ├─ FileUploader/ │ │ │ ├─ FileUploader/
│ │ │ ├─ CodeViewer/ │ │ │ ├─ CodeViewer/
@@ -252,8 +251,7 @@ kintone-customize-manager/
- 显示 Domain 名称和域名 - 显示 Domain 名称和域名
**FR-DOMAIN-005**: 切换 Domain **FR-DOMAIN-005**: 切换 Domain
- 用户可从 Domain 列表快速切换当前工作的 Domain - 切换后自动加载该 Domain 下的 App 列表
- 切换后自动加载该 Domain 下的 Space 和 App 列表
**FR-DOMAIN-006**: 密码加密存储 **FR-DOMAIN-006**: 密码加密存储
- 使用 keytar 将密码加密存储到系统密钥链 - 使用 keytar 将密码加密存储到系统密钥链
@@ -287,40 +285,45 @@ interface Domain {
### 3.2 资源浏览 ### 3.2 资源浏览
#### 3.2.1 功能描述 浏览当前 Domain 下的所有 App查看应用的自定义资源配置。
浏览当前 Domain 下的所有 Space 和 App查看应用的自定义资源配置。 **FR-BROWSE-001**: 获取 App 列表
- 选择 Domain 后显示「加载应用」按钮
- 点击按钮后全量获取该 Domain 下的所有 App
- 获取过程中显示加载状态
- App 列表按 App ID 降序排列
- 显示 App 名称、App ID、所属 Space ID若有、创建时间
- App 数据持久化存储,下次打开应用时自动加载
- 支持重新加载(覆盖已有数据)
HW|
BB|**FR-BROWSE-002**: 列表显示
- App 列表使用可点击列表List 组件)展示,无需分页
- 全量显示所有 App显示 App 名称和 App ID
- 点击列表项即可选择 App
- 显示总数量和加载时间
- 所属 Space 暂时不显示(后续版本支持)
NK|
**FR-BROWSE-003**: 搜索过滤
- 支持按 App 名称搜索
- 搜索结果实时过滤
#### 3.2.2 功能需求 **FR-BROWSE-004**: 获取 App 详情
**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 - 所属 Space ID如果 App 隶属于某个 Space
- 创建时间 - 创建时间
- 最后更新时间 - 最后更新时间
**FR-BROWSE-004**: 查看自定义资源配置 **FR-BROWSE-005**: 查看自定义资源配置
- 查看 PC 端的 JavaScript 文件配置 - 查看 PC 端的 JavaScript 文件配置
- 查看 PC 端的 CSS 文件配置 - 查看 PC 端的 CSS 文件配置
- 查看移动端的 JavaScript 文件配置 - 查看移动端的 JavaScript 文件配置
- 查看移动端的 CSS 文件配置 - 查看移动端的 CSS 文件配置
- 查看已安装的 Plugin 列表(只读,后续版本支持管理) - 查看已安装的 Plugin 列表(只读,后续版本支持管理)
**FR-BROWSE-005**: 查看文件详情 **FR-BROWSE-006**: 查看文件详情
- 文件名 - 文件名
- 文件类型JS/CSS - 文件类型JS/CSS
- 部署位置PC/移动端) - 部署位置PC/移动端)
@@ -331,11 +334,12 @@ interface Domain {
#### 3.2.3 Kintone API 端点 #### 3.2.3 Kintone API 端点
``` ```
# 获取 Space 列表 # 获取 App 列表(支持分页)
GET /k/v1/space.json GET /k/v1/apps.json?limit={limit}&offset={offset}
# 限制limit 最大 100offset 从 0 开始
# 获取 App 列表(按 Space # 注意API 不支持排序,需客户端排序
GET /k/v1/apps.json?space={spaceId} # 注意API 不返回 totalCount需通过返回数量判断是否还有更多
# 注意spaceId 已包含在响应中null 表示不属于任何 Space
# 获取 App 配置 # 获取 App 配置
GET /k/v1/app.json?app={appId} GET /k/v1/app.json?app={appId}
@@ -344,6 +348,37 @@ 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 下载已部署代码
@@ -889,8 +924,7 @@ autoUpdater.on('update-downloaded', (info) => {
| 术语 | 说明 | | 术语 | 说明 |
|------|------| |------|------|
| Domain | Kintone 实例 company.kintone.com | | Space | Kintone 空间应用的容器API 不支持获取所有 Space |
| Space | Kintone 空间应用的容器 |
| App | Kintone 应用 | | App | Kintone 应用 |
| FileKey | Kintone 文件的唯一标识 | | FileKey | Kintone 文件的唯一标识 |
| IPC | Electron 进程间通信 | | IPC | Electron 进程间通信 |
@@ -901,8 +935,8 @@ autoUpdater.on('update-downloaded', (info) => {
| 版本 | 日期 | 变更内容 | 作者 | | 版本 | 日期 | 变更内容 | 作者 |
|------|------|----------|------| |------|------|----------|------|
| 1.0.0 | 2026-03-11 | 初始版本 | - |
| 1.1.0 | 2026-03-11 | 更新技术栈LobeHub UICodeMirror 6safeStorage | - | | 1.1.0 | 2026-03-11 | 更新技术栈LobeHub UICodeMirror 6safeStorage | - |
| 1.2.0 | 2026-03-12 | 移除 Space 功能改为 App 列表分页显示支持持久化存储 App 数据 | - |
--- ---

View File

@@ -4,15 +4,28 @@ 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()]

102
eslint.config.mjs Normal file
View File

@@ -0,0 +1,102 @@
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

File diff suppressed because it is too large Load Diff

View File

@@ -22,18 +22,28 @@
"@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",
"@lobehub/ui": "^5.5.0", "@kintone/rest-api-client": "^6.1.2",
"@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": {
@@ -41,6 +51,8 @@
"@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",
@@ -50,6 +62,7 @@
"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"
} }
} }

6
src/main/env.d.ts vendored
View File

@@ -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;
} }

113
src/main/errors.ts Normal file
View File

@@ -0,0 +1,113 @@
/**
* 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();
}

View File

@@ -2,11 +2,7 @@ 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 { import { initializeStorage, isSecureStorageAvailable, getStorageBackend } from "./storage";
initializeStorage,
isSecureStorageAvailable,
getStorageBackend,
} from "./storage";
function createWindow(): void { function createWindow(): void {
// Create the browser window. // Create the browser window.
@@ -19,7 +15,6 @@ 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,
@@ -32,11 +27,25 @@ 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"]) {
@@ -58,9 +67,7 @@ app.whenReady().then(() => {
// Check secure storage availability // Check secure storage availability
if (!isSecureStorageAvailable()) { if (!isSecureStorageAvailable()) {
console.warn( console.warn(`Warning: Secure storage not available (backend: ${getStorageBackend()})`);
`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.");
} }
@@ -74,9 +81,6 @@ 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

View File

@@ -1,20 +1,8 @@
/** import { KintoneRestAPIClient } from "@kintone/rest-api-client";
* Kintone REST API Client import type { KintoneRestAPIError } from "@kintone/rest-api-client";
* Handles authentication and API calls to Kintone import type { DomainWithPassword } from "@shared/types/domain";
* Based on REQUIREMENTS.md:331-345, 502-522 import { type AppResponse, type AppDetail, type FileContent, type KintoneApiError, AppCustomizeParameter } from "@shared/types/kintone";
*/ 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
@@ -24,11 +12,7 @@ export class KintoneError extends Error {
public readonly id?: string; public readonly id?: string;
public readonly statusCode?: number; public readonly statusCode?: number;
constructor( constructor(message: string, apiError?: KintoneApiError, statusCode?: number) {
message: string,
apiError?: KintoneApiError,
statusCode?: number,
) {
super(message); super(message);
this.name = "KintoneError"; this.name = "KintoneError";
this.code = apiError?.code; this.code = apiError?.code;
@@ -41,468 +25,163 @@ export class KintoneError extends Error {
* Kintone REST API Client * Kintone REST API Client
*/ */
export class KintoneClient { export class KintoneClient {
private baseUrl: string; private client: KintoneRestAPIClient;
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.headers = new Headers({ this.client = new KintoneRestAPIClient({
"Content-Type": "application/json", baseUrl: `https://${domainConfig.domain}`,
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 {
* Make an API request with timeout if (error && typeof error === "object" && "code" in error) {
*/ const apiError = error as KintoneRestAPIError;
private async request<T>( return new KintoneError(apiError.message, { code: apiError.code, message: apiError.message, id: apiError.id }, apiError.status);
endpoint: string,
options: RequestInit = {},
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
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) {
if (error instanceof KintoneError) {
throw error;
}
if (error instanceof Error) { if (error instanceof Error) {
if (error.name === "AbortError") { return new KintoneError(error.message);
throw new KintoneError("Request timeout");
}
throw new KintoneError(`Network error: ${error.message}`);
}
throw new KintoneError("Unknown error occurred");
} finally {
clearTimeout(timeoutId);
}
} }
// ==================== Space APIs ==================== return new KintoneError(getErrorMessage("unknownError"));
/**
* 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"); private async withErrorHandling<T>(operation: () => Promise<T>): Promise<T> {
try {
return response.spaces.map((space) => ({ return await operation();
id: space.id, } catch (error) {
name: space.name, throw this.convertError(error);
code: space.code, }
createdAt: space.createdAt,
creator: space.creator,
}));
} }
// ==================== App APIs ==================== // ==================== App APIs ====================
/** /**
* Get list of apps, optionally filtered by space * Get all apps with pagination support
* GET /k/v1/apps.json?space={spaceId} * Fetches all apps by making multiple requests if needed
*/ */
async getApps(spaceId?: string): Promise<KintoneApp[]> { async getApps(options?: { limit?: number; offset?: number }): Promise<AppResponse[]> {
interface AppsResponse { return this.withErrorHandling(async () => {
apps: Array<{ // If pagination options provided, use them directly
appId: string; if (options?.limit !== undefined || options?.offset !== undefined) {
name: string; const params: { limit?: number; offset?: number } = {};
code?: string; if (options.limit) params.limit = options.limit;
spaceId?: string; if (options.offset) params.offset = options.offset;
createdAt: string; const response = await this.client.app.getApps(params);
creator?: { code: string; name: string }; return response.apps;
modifiedAt?: string;
modifier?: { code: string; name: string };
}>;
} }
let endpoint = "apps.json"; // Otherwise, fetch all apps (pagination handled internally)
if (spaceId) { const allApps: AppResponse[] = [];
endpoint += `?space=${spaceId}`; const limit = 100; // Max allowed by Kintone API
let offset = 0;
let hasMore = true;
while (hasMore) {
const response = await this.client.app.getApps({ limit, offset });
allApps.push(...response.apps);
// If we got fewer than limit, we've reached the end
if (response.apps.length < limit) {
hasMore = false;
} else {
offset += limit;
}
} }
const response = await this.request<AppsResponse>(endpoint); return allApps;
});
return response.apps.map((app) => ({
appId: app.appId,
name: app.name,
code: app.code,
spaceId: app.spaceId,
createdAt: app.createdAt,
creator: app.creator,
modifiedAt: app.modifiedAt,
modifier: app.modifier,
}));
} }
/**
* Get app details including customization config
* GET /k/v1/app.json?app={appId}
*/
async getAppDetail(appId: string): Promise<AppDetail> { async getAppDetail(appId: string): Promise<AppDetail> {
interface AppResponse { return this.withErrorHandling(async () => {
appId: string; const [appInfo, customizeInfo] = await Promise.all([
name: string; this.client.app.getApp({ id: appId }),
code?: string; this.client.app.getAppCustomize({ app: appId, preview: true }),
description?: string; ]);
spaceId?: string;
createdAt: string;
creator: { code: string; name: string };
modifiedAt: string;
modifier: { code: string; name: string };
}
interface AppCustomizeResponse {
appId: string;
scope: string;
desktop: {
js?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
css?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
};
mobile: {
js?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
css?: Array<{
type: string;
file?: { fileKey: string; name: string };
url?: string;
}>;
};
}
// Get basic app info
const appInfo = await this.request<AppResponse>(`app.json?app=${appId}`);
// Get customization config
const customizeInfo = await this.request<AppCustomizeResponse>(
`app/customize.json?app=${appId}`,
);
// Transform customization config
const customization: AppCustomizationConfig = {
javascript: {
pc:
customizeInfo.desktop?.js?.map((js) => ({
type: js.type as "FILE" | "URL",
file: js.file
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
: undefined,
url: js.url,
})) || [],
mobile:
customizeInfo.mobile?.js?.map((js) => ({
type: js.type as "FILE" | "URL",
file: js.file
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
: undefined,
url: js.url,
})) || [],
},
stylesheet: {
pc:
customizeInfo.desktop?.css?.map((css) => ({
type: css.type as "FILE" | "URL",
file: css.file
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
: undefined,
url: css.url,
})) || [],
mobile:
customizeInfo.mobile?.css?.map((css) => ({
type: css.type as "FILE" | "URL",
file: css.file
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
: undefined,
url: css.url,
})) || [],
},
plugins: [],
};
return { return {
appId: appInfo.appId, ...appInfo,
name: appInfo.name, customization: customizeInfo,
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> {
const controller = new AbortController(); return this.withErrorHandling(async () => {
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); const data = await this.client.file.downloadFile({ fileKey });
const buffer = Buffer.from(data);
try { const content = buffer.toString("base64");
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, // Kintone doesn't return filename in file API name: fileKey,
size: arrayBuffer.byteLength, size: buffer.byteLength,
mimeType: contentType, mimeType: "application/octet-stream",
content, content,
}; };
} finally {
clearTimeout(timeoutId);
}
}
/**
* Upload a file to Kintone
* POST /k/v1/file.json (multipart/form-data)
*/
async uploadFile(
content: string | Buffer,
fileName: string,
mimeType: string = "application/javascript",
): Promise<{ fileKey: string }> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
try {
const url = `${this.baseUrl}file.json`;
// Create form data
const formData = new FormData();
const blob = new Blob(
[typeof content === "string" ? content : Buffer.from(content)],
{ type: mimeType },
);
formData.append("file", blob, fileName);
// Remove Content-Type header to let browser set it with boundary
const uploadHeaders = new Headers(this.headers);
uploadHeaders.delete("Content-Type");
const response = await fetch(url, {
method: "POST",
headers: uploadHeaders,
body: formData,
signal: controller.signal,
}); });
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(); async uploadFile(content: string | Buffer, fileName: string, _mimeType?: string): Promise<{ fileKey: string }> {
return { fileKey: data.fileKey }; return this.withErrorHandling(async () => {
} finally { const response = await this.client.file.uploadFile({
clearTimeout(timeoutId); file: { name: fileName, data: content },
} });
return { fileKey: response.fileKey };
});
} }
// ==================== Deploy APIs ==================== // ==================== Deploy APIs ====================
/** async updateAppCustomize(appId: string, config: Omit<AppCustomizeParameter, "app">): Promise<void> {
* Update app customization config return this.withErrorHandling(async () => {
* PUT /k/v1/app/customize.json await this.client.app.updateAppCustomize({ ...config, app: appId });
*/
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> {
await this.request("preview/app/deploy.json", { return this.withErrorHandling(async () => {
method: "POST", await this.client.app.deployApp({ apps: [{ app: appId }] });
body: JSON.stringify({ apps: [{ app: appId }] }),
}); });
} }
/** async getDeployStatus(appId: string): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
* Get deploy status return this.withErrorHandling(async () => {
* GET /k/v1/preview/app/deploy.json?app={appId} const response = await this.client.app.getDeployStatus({ apps: [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 {
await this.getApps(); // Use limit=1 to minimize data transfer for faster connection testing
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: error instanceof KintoneError ? error.message : getErrorMessage("connectionFailed"),
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);
} }

View File

@@ -7,12 +7,10 @@
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 "@renderer/types/domain"; import type { Domain, DomainWithPassword } from "@shared/types/domain";
import type { import type { Version, DownloadMetadata, BackupMetadata } from "@shared/types/version";
Version, import type { LocaleCode } from "@shared/types/locale";
DownloadMetadata, import { DEFAULT_LOCALE } from "@shared/types/locale";
BackupMetadata,
} from "@renderer/types/version";
// ==================== Path Helpers ==================== // ==================== Path Helpers ====================
@@ -43,6 +41,28 @@ 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 {
@@ -83,14 +103,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;
} }
} }
@@ -99,12 +119,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";
} }
} }
@@ -115,9 +135,7 @@ export function encryptPassword(password: string): Buffer {
try { try {
return safeStorage.encryptString(password); return safeStorage.encryptString(password);
} catch (error) { } catch (error) {
throw new Error( throw new Error(`Failed to encrypt password: ${error instanceof Error ? error.message : "Unknown error"}`);
`Failed to encrypt password: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -128,9 +146,7 @@ export function decryptPassword(encrypted: Buffer): string {
try { try {
return safeStorage.decryptString(encrypted); return safeStorage.decryptString(encrypted);
} catch (error) { } catch (error) {
throw new Error( throw new Error(`Failed to decrypt password: ${error instanceof Error ? error.message : "Unknown error"}`);
`Failed to decrypt password: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} }
} }
@@ -139,10 +155,7 @@ export function decryptPassword(encrypted: Buffer): string {
/** /**
* Save a domain with encrypted password * Save a domain with encrypted password
*/ */
export async function saveDomain( export async function saveDomain(domain: Domain, password: string): Promise<void> {
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);
@@ -166,9 +179,7 @@ export async function saveDomain(
/** /**
* Get a domain by ID with decrypted password * Get a domain by ID with decrypted password
*/ */
export async function getDomain( export async function getDomain(id: string): Promise<DomainWithPassword | null> {
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);
@@ -229,13 +240,7 @@ 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( const versionDir = getStoragePath("versions", version.domainId, version.appId, version.fileType, version.id);
"versions",
version.domainId,
version.appId,
version.fileType,
version.id,
);
ensureDir(versionDir); ensureDir(versionDir);
@@ -247,10 +252,7 @@ export async function saveVersion(version: Version): Promise<void> {
/** /**
* List versions for a specific app * List versions for a specific app
*/ */
export async function listVersions( export async function listVersions(domainId: string, appId: string): Promise<Version[]> {
domainId: string,
appId: string,
): Promise<Version[]> {
const versions: Version[] = []; const versions: Version[] = [];
const baseDir = getStoragePath("versions", domainId, appId); const baseDir = getStoragePath("versions", domainId, appId);
@@ -280,9 +282,7 @@ export async function listVersions(
} }
// Sort by createdAt descending // Sort by createdAt descending
return versions.sort( return versions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
} }
/** /**
@@ -325,17 +325,9 @@ export async function deleteVersion(id: string): Promise<void> {
/** /**
* Save downloaded files with metadata * Save downloaded files with metadata
*/ */
export async function saveDownload( export async function saveDownload(metadata: DownloadMetadata, files: Map<string, Buffer>): Promise<string> {
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( const downloadDir = getStoragePath("downloads", metadata.domainId, metadata.appId, timestamp);
"downloads",
metadata.domainId,
metadata.appId,
timestamp,
);
ensureDir(downloadDir); ensureDir(downloadDir);
@@ -368,18 +360,9 @@ export function getDownloadPath(domainId: string, appId?: string): string {
/** /**
* Save backup files with metadata * Save backup files with metadata
*/ */
export async function saveBackup( export async function saveBackup(metadata: BackupMetadata, files: Map<string, Buffer>): Promise<string> {
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( const backupDir = getStoragePath("versions", metadata.domainId, metadata.appId, "backup", timestamp);
"versions",
metadata.domainId,
metadata.appId,
"backup",
timestamp,
);
ensureDir(backupDir); ensureDir(backupDir);
@@ -397,6 +380,39 @@ export async function saveBackup(
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 ====================
/** /**
@@ -425,4 +441,5 @@ export function initializeStorage(): void {
ensureDir(getStorageBase()); ensureDir(getStorageBase());
ensureDir(getStoragePath("downloads")); ensureDir(getStoragePath("downloads"));
ensureDir(getStoragePath("versions")); ensureDir(getStoragePath("versions"));
ensureDir(getStoragePath("files"));
} }

View File

@@ -4,34 +4,39 @@ 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,
GetVersionsParams, DownloadAllZipParams,
DownloadAllZipResult,
RollbackParams, RollbackParams,
} from "@renderer/types/ipc"; SetLocaleParams,
import type { Domain, DomainWithStatus } from "@renderer/types/domain"; ShowSaveDialogParams,
import type { SaveFileContentParams,
KintoneSpace, CheckUpdateResult,
KintoneApp, FileSaveParams,
AppDetail, FileSaveResult,
FileContent, FileDeleteParams,
} from "@renderer/types/kintone"; } from "@shared/types/ipc";
import type { Version } from "@renderer/types/version"; import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type { AppResponse, AppDetail, FileContent, KintoneSpace } from "@shared/types/kintone";
import type { Version } from "@shared/types/version";
import type { LocaleCode } from "@shared/types/locale";
declare global { declare global {
interface Window { interface Window {
electron: ElectronAPI; electron: ElectronAPI;
api: ElectronAPI; api: SelfAPI;
} }
} }
export interface ElectronAPI { export interface SelfAPI {
// Platform detection // Platform detection
platform: NodeJS.Platform; platform: NodeJS.Platform;
@@ -44,21 +49,44 @@ export interface ElectronAPI {
testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>; testDomainConnection: (params: TestDomainConnectionParams) => Promise<Result<boolean>>;
// ==================== Browse ==================== // ==================== Browse ====================
getSpaces: (params: GetSpacesParams) => Promise<Result<KintoneSpace[]>>; getApps: (params: GetAppsParams) => Promise<Result<AppResponse[]>>;
getApps: (params: GetAppsParams) => Promise<Result<KintoneApp[]>>;
getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>; getAppDetail: (params: GetAppDetailParams) => Promise<Result<AppDetail>>;
getFileContent: ( getFileContent: (params: GetFileContentParams) => Promise<Result<FileContent>>;
params: GetFileContentParams, getLocalFileContent: (params: GetLocalFileContentParams) => Promise<Result<LocalFileContent>>;
) => Promise<Result<FileContent>>;
// ==================== Deploy ==================== // ==================== Deploy ====================
deploy: (params: DeployParams) => Promise<DeployResult>; deploy: (params: DeployParams) => Promise<Result<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;
} }

View File

@@ -1,13 +1,12 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer, webUtils } from "electron";
import { electronAPI } from "@electron-toolkit/preload"; import { electronAPI } from "@electron-toolkit/preload";
import type { ElectronAPI } from "./index.d"; import type { SelfAPI } from "./index.d";
// Custom APIs for renderer - bridges to IPC handlers // Plain object API - contextBridge cannot serialize Proxy objects
const api: ElectronAPI = { const api: SelfAPI = {
// 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),
@@ -15,22 +14,42 @@ const api: ElectronAPI = {
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),
// ==================== Download ==================== // File storage
download: (params) => ipcRenderer.invoke("download", params), saveFile: (params) => ipcRenderer.invoke("file:save", params),
deleteFile: (params) => ipcRenderer.invoke("file:delete", params),
// ==================== Version Management ==================== // Download
download: (params) => ipcRenderer.invoke("download", params),
downloadAllZip: (params) => ipcRenderer.invoke("downloadAllZip", params),
// 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
@@ -41,11 +60,11 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI); contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("api", api); contextBridge.exposeInMainWorld("api", api);
} catch (error) { } catch (error) {
console.error(error); console.error("[Preload] Failed to expose API:", error);
} }
} else { } else {
// @ts-ignore (define in dts) // @ts-ignore - window is available in non-isolated context
window.electron = electronAPI; window.electron = electronAPI;
// @ts-ignore (define in dts) // @ts-ignore
window.api = api; window.api = api;
} }

View File

@@ -4,35 +4,29 @@
*/ */
import React from "react"; import React from "react";
import { import { useTranslation } from "react-i18next";
Layout, import { Layout, Typography, Space, Modal } from "antd";
Typography,
theme,
ConfigProvider,
App as AntApp,
Tabs,
Button,
Space,
Dropdown,
} from "antd";
import {
SettingOutlined,
GithubOutlined,
CloudServerOutlined,
AppstoreOutlined,
CloudUploadOutlined,
HistoryOutlined,
} from "@ant-design/icons";
import { createStyles } from "antd-style";
import zhCN from "antd/locale/zh_CN";
import { useDomainStore } from "@renderer/stores";
import { DomainManager } from "@renderer/components/DomainManager";
import { SpaceTree } from "@renderer/components/SpaceTree";
import { AppDetail } from "@renderer/components/AppDetail";
import { DeployDialog } from "@renderer/components/DeployDialog";
import { Button, Tooltip } from "@lobehub/ui";
import { Cloud, History, PanelLeftClose, PanelLeftOpen, Settings as SettingsIcon } from "lucide-react";
import { createStyles, useTheme } from "antd-style";
import { useDomainStore } from "@renderer/stores";
import { useUIStore } from "@renderer/stores";
import { DomainManager } from "@renderer/components/DomainManager";
import { AppList } from "@renderer/components/AppList";
import { AppDetail } from "@renderer/components/AppDetail";
import { Settings } from "@renderer/components/Settings";
const { Header, Content, Sider } = Layout; const { Header, Content, Sider } = Layout;
const { Title, Text } = Typography; const { Title } = Typography;
// Domain section heights
const DOMAIN_SECTION_COLLAPSED = 68; // 增加高度,避免按钮覆盖文字
const DOMAIN_SECTION_EXPANDED = 260;
const DEFAULT_SIDER_WIDTH = 320;
const MIN_SIDER_WIDTH = 280;
const MAX_SIDER_WIDTH = 500;
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
layout: css` layout: css`
@@ -48,14 +42,18 @@ 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: 48px; height: 32px;
margin: 8px 16px; margin: ${token.paddingXS}px ${token.padding}px ${token.paddingXXS}px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between;
gap: 8px; gap: 8px;
border-bottom: 1px solid ${token.colorBorderSecondary};
`, `,
logoText: css` logoText: css`
color: ${token.colorText}; color: ${token.colorText};
@@ -63,12 +61,16 @@ const useStyles = createStyles(({ token, css }) => ({
font-weight: 600; font-weight: 600;
`, `,
siderContent: css` siderContent: css`
height: calc(100vh - 64px); height: calc(100vh - 44px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`, `,
mainLayout: css` mainLayout: css`
margin-left: 280px; margin-left: ${DEFAULT_SIDER_WIDTH}px;
transition: margin-left 0.2s;
`,
mainLayoutCollapsed: css`
margin-left: 0;
transition: margin-left 0.2s; transition: margin-left 0.2s;
`, `,
header: css` header: css`
@@ -90,96 +92,162 @@ 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;
`, `,
spaceSection: css` appSection: 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 { const token = useTheme();
token: { colorBgContainer },
} = theme.useToken(); // Prevent Electron from navigating to file:// URLs when files are dropped
// outside of designated drop zones
React.useEffect(() => {
const preventNavigation = (e: DragEvent) => {
e.preventDefault();
};
document.addEventListener("dragover", preventNavigation);
document.addEventListener("drop", preventNavigation);
return () => {
document.removeEventListener("dragover", preventNavigation);
document.removeEventListener("drop", preventNavigation);
};
}, []);
const { currentDomain } = useDomainStore(); const { currentDomain } = useDomainStore();
const [deployDialogOpen, setDeployDialogOpen] = React.useState(false); const { sidebarWidth, siderCollapsed, domainExpanded, setSidebarWidth, setSiderCollapsed, setDomainExpanded } = useUIStore();
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [isResizing, setIsResizing] = React.useState(false);
const domainSectionHeight = domainExpanded ? DOMAIN_SECTION_EXPANDED : DOMAIN_SECTION_COLLAPSED;
// Handle resize start
const handleResizeStart = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
const startX = e.clientX;
const startWidth = sidebarWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = moveEvent.clientX - startX;
const newWidth = Math.min(MAX_SIDER_WIDTH, Math.max(MIN_SIDER_WIDTH, startWidth + delta));
setSidebarWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[sidebarWidth, setSidebarWidth]
);
const toggleSider = () => {
setSiderCollapsed(!siderCollapsed);
};
return ( return (
<ConfigProvider locale={zhCN}>
<AntApp>
<Layout className={styles.layout}> <Layout className={styles.layout}>
{/* Left Sider - Domain List & Space Tree */} {/* Left Sider - Domain List & App List */}
<Sider width={280} className={styles.sider}> <Sider
width={sidebarWidth}
className={`${styles.sider} ${siderCollapsed ? styles.siderCollapsed : ""}`}
style={{ width: siderCollapsed ? 0 : sidebarWidth }}
>
{!siderCollapsed && (
<>
<div className={styles.logo}> <div className={styles.logo}>
<CloudServerOutlined style={{ fontSize: 24, color: "#1890ff" }} /> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span className={styles.logoText}>Kintone Manager</span> <Cloud size={24} style={{ color: token.colorPrimary }} />
<span className={styles.logoText}>Kintone JS/CSS Manager</span>
</div>
<Tooltip title={t("collapseSidebar")} mouseEnterDelay={0.5}>
<Button type="text" icon={<PanelLeftClose size={16} />} onClick={toggleSider} className={styles.siderCloseButton} size="small" />
</Tooltip>
</div> </div>
<div className={styles.siderContent}> <div className={styles.siderContent}>
<div className={styles.domainSection}> <div className={styles.domainSection} style={{ height: domainSectionHeight }}>
<DomainManager /> <DomainManager collapsed={!domainExpanded} onToggleCollapse={() => setDomainExpanded(!domainExpanded)} />
</div> </div>
<div className={styles.spaceSection}> <div className={styles.appSection} style={{ height: `calc(100% - ${domainSectionHeight}px)` }}>
<SpaceTree /> <AppList />
</div> </div>
</div> </div>
{/* Resize handle */}
<div
className={styles.resizeHandle}
onMouseDown={handleResizeStart}
style={{
background: isResizing ? token.colorPrimary : undefined,
}}
/>
</>
)}
</Sider> </Sider>
{/* Main Content */} {/* Main Content */}
<Layout className={styles.mainLayout}> <Layout className={`${styles.mainLayout} ${siderCollapsed ? styles.mainLayoutCollapsed : ""}`} style={{ marginLeft: siderCollapsed ? 0 : sidebarWidth }}>
<Header className={styles.header}> <Header className={styles.header}>
<div className={styles.headerLeft}>
{siderCollapsed && (
<Tooltip title={t("expandSidebar")} mouseEnterDelay={0.5}>
<Button type="text" icon={<PanelLeftOpen size={16} />} onClick={toggleSider} size="small" />
</Tooltip>
)}
<Title level={5} style={{ margin: 0 }}> <Title level={5} style={{ margin: 0 }}>
{currentDomain {currentDomain ? currentDomain.name : "Kintone Customize Manager"}
? currentDomain.name
: "Kintone Customize Manager"}
</Title> </Title>
</div>
<Space> <Space>
<Button <Button icon={<History size={16} />} disabled={!currentDomain}>
type="primary" {t("versionHistory")}
icon={<CloudUploadOutlined />}
onClick={() => setDeployDialogOpen(true)}
disabled={!currentDomain}
>
</Button> </Button>
<Button icon={<HistoryOutlined />} disabled={!currentDomain}> <Tooltip title={t("settings")}>
<Button icon={<SettingsIcon size={16} />} onClick={() => setSettingsOpen(true)} />
</Button> </Tooltip>
<Dropdown
menu={{
items: [
{
key: "settings",
icon: <SettingOutlined />,
label: "设置",
},
{ type: "divider" },
{
key: "github",
icon: <GithubOutlined />,
label: "GitHub",
},
],
}}
>
<Button icon={<SettingOutlined />} />
</Dropdown>
</Space> </Space>
</Header> </Header>
@@ -192,14 +260,11 @@ const App: React.FC = () => {
</Content> </Content>
</Layout> </Layout>
{/* Deploy Dialog */} {/* Settings Modal */}
<DeployDialog <Modal title={t("settings")} open={settingsOpen} onCancel={() => setSettingsOpen(false)} footer={null} width={480} mask={{ closable: false }}>
open={deployDialogOpen} <Settings />
onClose={() => setDeployDialogOpen(false)} </Modal>
/>
</Layout> </Layout>
</AntApp>
</ConfigProvider>
); );
}; };

View File

@@ -1,30 +1,19 @@
/** /**
* AppDetail Component * AppDetail Component
* Displays app configuration details * Displays app configuration details with file management and deploy functionality.
*/ */
import React from "react"; import React, { useCallback, useEffect, useState } from "react";
import { import { useTranslation } from "react-i18next";
Card, import { Spin, Tag, Space, App as AntApp, Tooltip } from "antd";
Descriptions, import { Button, Empty } from "@lobehub/ui";
Tabs, import { LayoutGrid, Download, History, Rocket, Monitor, Smartphone, ArrowLeft, RefreshCw } from "lucide-react";
Empty, import { createStyles, useTheme } from "antd-style";
Spin, import { useAppStore, useDomainStore, useSessionStore, useFileChangeStore } from "@renderer/stores";
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`
@@ -34,6 +23,9 @@ 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};
`, `,
@@ -41,7 +33,6 @@ 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;
@@ -52,6 +43,7 @@ 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;
@@ -59,56 +51,70 @@ 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 } = const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } = useAppStore();
useAppStore(); const { viewMode, selectedFile, setViewMode, setSelectedFile } = useSessionStore();
const fileChangeStore = useFileChangeStore();
const { message } = AntApp.useApp();
// Load app detail when selected const [downloadingKey, setDownloadingKey] = React.useState<string | null>(null);
React.useEffect(() => { const [downloadingAll, setDownloadingAll] = React.useState(false);
if (currentDomain && selectedAppId) { const [deploying, setDeploying] = React.useState(false);
loadAppDetail(); 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);
} }
}, [currentDomain, selectedAppId]); setIsAnySectionOverLimit(overLimitSectionsRef.current.size > 0);
},
[]
);
const loadAppDetail = async () => { // Reset view mode when app changes
if (!currentDomain || !selectedAppId) return; useEffect(() => {
setViewMode("list");
setSelectedFile(null);
}, [selectedAppId]);
const loadAppDetail = useCallback(
async (onSuccessCallback?: () => Promise<void> | void) => {
if (!currentDomain || !selectedAppId) return undefined;
setLoading(true); setLoading(true);
try { try {
@@ -116,31 +122,220 @@ const AppDetail: React.FC = () => {
domainId: currentDomain.id, domainId: currentDomain.id,
appId: selectedAppId, appId: selectedAppId,
}); });
// Check if we're still on the same app and component is mounted before updating
if (result.success) { if (result.success) {
setCurrentApp(result.data); setCurrentApp(result.data);
// Call the callback if provided
if (onSuccessCallback) {
await onSuccessCallback();
} }
return result.data;
}
return undefined;
} catch (error) { } catch (error) {
console.error("Failed to load app detail:", error); console.error("Failed to load app detail:", error);
return undefined;
} finally { } finally {
setLoading(false); setLoading(false);
} }
},
[currentDomain, selectedAppId, setCurrentApp, setLoading]
);
// Load app detail when selected
useEffect(() => {
if (currentDomain && selectedAppId) {
loadAppDetail();
}
}, [currentDomain, selectedAppId, loadAppDetail]);
// Initialize file change store from Kintone data
useEffect(() => {
if (!currentApp || !currentDomain || !selectedAppId) return;
const customize = currentApp.customization;
if (!customize) return;
const files = transformCustomizeToFiles(customize);
fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files);
}, [currentApp]);
const handleFileClick = useCallback((fileKey: string | undefined, name: string, storagePath?: string) => {
const ext = name.split(".").pop()?.toLowerCase();
const type = ext === "css" ? "css" : "js";
setSelectedFile({ type, fileKey, name, storagePath });
setViewMode("code");
}, []);
const handleRefresh = useCallback(async () => {
if (!currentDomain || !selectedAppId || refreshing) return;
setRefreshing(true);
try {
// Clear changes before reloading from Kintone
fileChangeStore.clearChanges(currentDomain.id, selectedAppId);
await loadAppDetail(() => fileChangeStore.clearChanges(currentDomain.id, selectedAppId));
message.success(t("refreshSuccess", { ns: "common" }));
} catch (error) {
console.error("Failed to refresh:", error);
message.error(t("refreshFailed", { ns: "common" }));
} finally {
setRefreshing(false);
}
}, [currentDomain, selectedAppId, refreshing, fileChangeStore, loadAppDetail, message, t]);
const handleBackToList = useCallback(() => {
setViewMode("list");
setSelectedFile(null);
}, []);
// Handle mouse back button (XButton1) to navigate back from code view
useEffect(() => {
const handleMouseClick = (e: MouseEvent) => {
// XButton1 (back button) is typically button 3 on mice with side buttons
if (e.button === 3 && viewMode === "code") {
// Prevent any default browser back action
e.preventDefault();
handleBackToList();
}
}; };
const [activeTab, setActiveTab] = React.useState("info"); // Add event listener for mouse clicks
const [selectedFile, setSelectedFile] = React.useState<{ document.addEventListener("mousedown", handleMouseClick);
type: "js" | "css";
fileKey: string; // Clean up event listener on unmount
name: string; return () => {
} | null>(null); 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,
appId: selectedAppId,
savePath,
});
if (result.success) {
message.success(t("downloadAllSuccess", { path: result.data?.path, ns: "common" }));
} else {
message.error(result.error || t("downloadFailed", { ns: "common" }));
}
} catch {
message.error(t("downloadFailed", { ns: "common" }));
} finally {
setDownloadingAll(false);
}
}, [currentDomain, selectedAppId, downloadingAll, currentApp, message, t]);
const handleDeploy = useCallback(async () => {
if (!currentDomain || !selectedAppId || deploying) return;
const allFiles = fileChangeStore.getFiles(currentDomain.id, selectedAppId);
const deployEntries: DeployFileEntry[] = allFiles.map((f) => ({
id: f.id,
fileName: f.fileName,
fileType: f.fileType,
platform: f.platform,
status: f.status,
fileKey: f.fileKey,
url: f.url,
storagePath: f.storagePath,
}));
setDeploying(true);
try {
const result = await window.api.deploy({
domainId: currentDomain.id,
appId: selectedAppId,
files: deployEntries,
});
if (result.success) {
message.success(t("deploySuccess"));
// Clear changes after successful deploy
await loadAppDetail(() => fileChangeStore.clearChanges(currentDomain.id, selectedAppId));
} else {
message.error(result.error || t("deployFailed"));
}
} catch (error) {
message.error(error instanceof Error ? error.message : t("deployFailed"));
} finally {
setDeploying(false);
}
}, [currentDomain, selectedAppId, deploying, fileChangeStore, loadAppDetail, message, t]);
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 <Empty description={t("selectApp")} />
description="请选择一个应用"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div> </div>
</div> </div>
); );
@@ -158,175 +353,125 @@ const AppDetail: React.FC = () => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.emptySection}> <div className={styles.emptySection}>
<Empty <Empty description={t("appNotFound")} />
description="未找到应用信息"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div> </div>
</div> </div>
); );
} }
const renderFileList = ( const changeCount = currentDomain && selectedAppId ? fileChangeStore.getChangeCount(currentDomain.id, selectedAppId) : { added: 0, deleted: 0, reordered: 0 };
files: (JSFileConfig | CSSFileConfig)[] | undefined, const hasChanges = changeCount.added > 0 || changeCount.deleted > 0 || changeCount.reordered > 0;
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}>
<AppstoreOutlined style={{ fontSize: 24, color: "#1890ff" }} /> <LayoutGrid size={24} style={{ color: token.colorLink }} />
<h3 className={styles.appName}>{currentApp.name}</h3> <h3 className={styles.appName}>{currentApp.name}</h3>
<Tag color="blue">{currentApp.appId}</Tag> <Tag>ID: {currentApp.appId}</Tag>
</div> </div>
<Space> <Space>
<Button icon={<HistoryOutlined />}></Button> <Button icon={<RefreshCw size={16} />} loading={refreshing} onClick={handleRefresh}>
<Button type="primary" icon={<DownloadOutlined />}> {t("refresh", { ns: "common" })}
</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}>
<Tabs {viewMode === "list" ? (
activeKey={activeTab} <>
onChange={setActiveTab} <FileSection
items={[ title={t("pcJs")}
{ icon={<Monitor size={14} />}
key: "info", platform="desktop"
label: "基本信息", fileType="js"
children: ( domainId={currentDomain.id}
<Descriptions column={2} bordered size="small"> appId={selectedAppId}
<Descriptions.Item label="应用ID"> downloadingKey={downloadingKey}
{currentApp.appId} onView={handleFileClick}
</Descriptions.Item> onDownload={handleDownloadFile}
<Descriptions.Item label="应用代码"> onOverLimitChange={handleOverLimitChange("desktop-js")}
{currentApp.code || "-"}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{currentApp.createdAt}
</Descriptions.Item>
<Descriptions.Item label="创建者">
{currentApp.creator?.name}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{currentApp.modifiedAt}
</Descriptions.Item>
<Descriptions.Item label="更新者">
{currentApp.modifier?.name}
</Descriptions.Item>
<Descriptions.Item label="所属Space" span={2}>
{currentApp.spaceName || currentApp.spaceId || "-"}
</Descriptions.Item>
</Descriptions>
),
},
{
key: "pc-js",
label: "PC端 JS",
children: renderFileList(
currentApp.customization?.javascript?.pc,
"js",
),
},
{
key: "pc-css",
label: "PC端 CSS",
children: renderFileList(
currentApp.customization?.stylesheet?.pc,
"css",
),
},
{
key: "mobile-js",
label: "移动端 JS",
children: renderFileList(
currentApp.customization?.javascript?.mobile,
"js",
),
},
{
key: "mobile-css",
label: "移动端 CSS",
children: renderFileList(
currentApp.customization?.stylesheet?.mobile,
"css",
),
},
{
key: "code",
label: "代码查看",
children: selectedFile ? (
<CodeViewer
fileKey={selectedFile.fileKey}
fileName={selectedFile.name}
fileType={selectedFile.type}
/> />
<FileSection
title={t("pcCss")}
icon={<Monitor size={14} />}
platform="desktop"
fileType="css"
domainId={currentDomain.id}
appId={selectedAppId}
downloadingKey={downloadingKey}
onView={handleFileClick}
onDownload={handleDownloadFile}
onOverLimitChange={handleOverLimitChange("desktop-css")}
/>
<FileSection
title={t("mobileJs")}
icon={<Smartphone size={14} />}
platform="mobile"
fileType="js"
domainId={currentDomain.id}
appId={selectedAppId}
downloadingKey={downloadingKey}
onView={handleFileClick}
onDownload={handleDownloadFile}
onOverLimitChange={handleOverLimitChange("mobile-js")}
/>
<FileSection
title={t("mobileCss")}
icon={<Smartphone size={14} />}
platform="mobile"
fileType="css"
domainId={currentDomain.id}
appId={selectedAppId}
downloadingKey={downloadingKey}
onView={handleFileClick}
onDownload={handleDownloadFile}
onOverLimitChange={handleOverLimitChange("mobile-css")}
/>
</>
) : ( ) : (
<div className={styles.emptySection}> <div className={styles.codeView}>
<Button type="text" icon={<ArrowLeft size={16} />} onClick={handleBackToList} className={styles.backButton}>
{t("backToList")}
</Button>
{selectedFile && (
<CodeViewer fileKey={selectedFile.fileKey} fileName={selectedFile.name} fileType={selectedFile.type} storagePath={selectedFile.storagePath} />
)}
</div> </div>
), )}
},
]}
/>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,79 @@
/**
* 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;

View File

@@ -0,0 +1,143 @@
/**
* 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;

View File

@@ -0,0 +1,381 @@
/**
* 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;

View File

@@ -0,0 +1,83 @@
/**
* 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;

View File

@@ -0,0 +1,286 @@
/**
* 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;

View File

@@ -0,0 +1,142 @@
/**
* 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;

View File

@@ -0,0 +1,6 @@
/**
* AppList Components
* Export all app list components
*/
export { default as AppList } from "./AppList";

View File

@@ -4,13 +4,11 @@
*/ */
import React from "react"; import React from "react";
import { Spin, Empty, Alert, Button, Space, message } from "antd"; import { useTranslation } from "react-i18next";
import { import { Spin, Alert, Space, message } from "antd";
CopyOutlined, import { Button, Empty } from "@lobehub/ui";
DownloadOutlined, import { Copy, Download } from "lucide-react";
FullscreenOutlined, import { createStyles, useTheme } from "antd-style";
} 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";
@@ -53,59 +51,83 @@ 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> = ({ const CodeViewer: React.FC<CodeViewerProps> = ({ fileKey, fileName, fileType, storagePath }) => {
fileKey, const { t } = useTranslation("file");
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) { if (currentDomain && (fileKey || storagePath)) {
loadFileContent(); loadFileContent();
} }
}, [currentDomain, fileKey]); }, [currentDomain, fileKey, storagePath]);
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
if (storagePath) {
const result = await window.api.getLocalFileContent({ storagePath });
if (result.success) {
// Decode base64 content properly for UTF-8
const base64 = result.data.content || "";
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const decoder = new TextDecoder("utf-8");
const decoded = decoder.decode(bytes);
setContent(decoded);
detectLanguage();
} else {
setError(result.error || "Failed to load local file content");
}
} else if (currentDomain && fileKey) {
// Load from Kintone
const result = await window.api.getFileContent({ const result = await window.api.getFileContent({
domainId: currentDomain.id, domainId: currentDomain.id,
fileKey, fileKey,
}); });
if (result.success) { if (result.success) {
// Decode base64 content // Decode base64 content properly for UTF-8 (including Japanese characters)
const decoded = atob(result.data.content || ""); const base64 = result.data.content || "";
setContent(decoded); const binaryString = atob(base64);
// Decode as UTF-8 to properly handle Japanese and other multi-byte characters
// Detect language from file name const bytes = new Uint8Array(binaryString.length);
if (fileName.endsWith(".css")) { for (let i = 0; i < binaryString.length; i++) {
setLanguage("css"); bytes[i] = binaryString.charCodeAt(i);
} else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
setLanguage("js");
} else {
setLanguage(fileType);
} }
const decoder = new TextDecoder("utf-8");
const decoded = decoder.decode(bytes);
setContent(decoded);
detectLanguage();
} else { } else {
setError(result.error || "Failed to load file content"); setError(result.error || "Failed to load file content");
} }
} else {
setError("No file source available");
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Unknown error"); setError(err instanceof Error ? err.message : "Unknown error");
} finally { } finally {
@@ -113,28 +135,79 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
} }
}; };
const handleCopy = () => { const detectLanguage = () => {
navigator.clipboard.writeText(content); // Detect language from file name
message.success("已复制到剪贴板"); if (fileName.endsWith(".css")) {
setLanguage("css");
} else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
setLanguage("js");
} else {
setLanguage(fileType);
}
}; };
const handleDownload = () => { const handleCopy = () => {
const blob = new Blob([content], { type: "text/plain" }); navigator.clipboard.writeText(content);
const url = URL.createObjectURL(blob); message.success(t("copiedToClipboard"));
const a = document.createElement("a"); };
a.href = url;
a.download = fileName; const handleDownload = async () => {
document.body.appendChild(a); if (!currentDomain || downloading) return;
a.click();
document.body.removeChild(a); // Check if fileName already has extension
URL.revokeObjectURL(url); const hasExt = /\.(js|css)$/i.test(fileName);
message.success("下载成功"); const finalFileName = hasExt ? fileName : `${fileName}.${fileType}`;
setDownloading(true);
try {
// 1. Show save dialog
const dialogResult = await window.api.showSaveDialog({
defaultPath: finalFileName,
});
if (!dialogResult.success || !dialogResult.data) {
// User cancelled
setDownloading(false);
return;
}
const savePath = dialogResult.data;
// 2. Get current file content (already decoded in state)
const encoder = new TextEncoder();
const uint8Array = encoder.encode(content);
// Convert to base64 for IPC transfer
let base64 = "";
const chunkSize = 8192;
for (let i = 0; i < uint8Array.length; i += chunkSize) {
base64 += String.fromCharCode.apply(null, Array.from(uint8Array.slice(i, i + chunkSize)));
}
const base64Content = btoa(base64);
// 3. Save to selected path
const saveResult = await window.api.saveFileContent({
filePath: savePath,
content: base64Content,
});
if (saveResult.success) {
message.success(t("downloadSuccess"));
} else {
message.error(saveResult.error || t("downloadFailed", { ns: "common" }));
}
} catch (error) {
console.error("Download failed:", error);
message.error(t("downloadFailed", { ns: "common" }));
} finally {
setDownloading(false);
}
}; };
if (loading) { if (loading) {
return ( return (
<div className={styles.loading}> <div className={styles.loading}>
<Spin size="large" tip="加载中..." /> <Spin size="large" description={t("loading")} />
</div> </div>
); );
} }
@@ -144,12 +217,12 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
<div className={styles.error}> <div className={styles.error}>
<Alert <Alert
type="error" type="error"
message="加载失败" message={t("loadFailed")}
description={error} description={error}
showIcon showIcon
action={ action={
<Button size="small" onClick={loadFileContent}> <Button size="small" onClick={loadFileContent}>
{t("retry", { ns: "deploy" })}
</Button> </Button>
} }
/> />
@@ -160,10 +233,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
if (!content) { if (!content) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Empty <Empty description={t("fileEmpty")} />
description="文件内容为空"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div> </div>
); );
} }
@@ -173,21 +243,11 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
<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 <Button type="text" size="small" icon={<Copy size={16} />} onClick={handleCopy}>
type="text" {t("copy", { ns: "common" })}
size="small"
icon={<CopyOutlined />}
onClick={handleCopy}
>
</Button> </Button>
<Button <Button type="text" size="small" icon={<Download size={16} />} loading={downloading} onClick={handleDownload}>
type="text" {t("download", { ns: "common" })}
size="small"
icon={<DownloadOutlined />}
onClick={handleDownload}
>
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -198,7 +258,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
height="100%" height="100%"
extensions={[language === "js" ? javascript() : css()]} extensions={[language === "js" ? javascript() : css()]}
editable={false} editable={false}
theme="light" theme={themeMode}
basicSetup={{ basicSetup={{
lineNumbers: true, lineNumbers: true,
highlightActiveLineGutter: false, highlightActiveLineGutter: false,

View File

@@ -1,419 +0,0 @@
/**
* 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;

View File

@@ -1,6 +0,0 @@
/**
* DeployDialog Components
* Export all deploy dialog components
*/
export { default as DeployDialog } from "./DeployDialog";

View File

@@ -4,27 +4,13 @@
*/ */
import React from "react"; import React from "react";
import { Modal, Form, Input, Select, Button, Space, message } from "antd"; import { useTranslation } from "react-i18next";
import { createStyles } from "antd-style"; import { Button, Input, InputPassword, Modal } from "@lobehub/ui";
import { Form } from "antd";
import { useDomainStore } from "@renderer/stores"; import { useDomainStore } from "@renderer/stores";
import type { Domain } from "@renderer/types/domain"; import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
import type { import { CheckCircle2, XCircle } from "lucide-react";
CreateDomainParams, import { useTheme } from "antd-style";
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;
@@ -32,182 +18,310 @@ 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 { styles } = useStyles(); const { t } = useTranslation("domain");
const token = useTheme();
const [form] = Form.useForm(); const [form] = Form.useForm();
const { domains, createDomain, updateDomainById, loading } = useDomainStore(); const { domains, createDomain, updateDomainById } = useDomainStore();
const isEdit = !!domainId; const isEdit = !!domainId;
const editingDomain = domainId const editingDomain = domainId ? domains.find((d) => d.id === domainId) : null;
? domains.find((d) => d.id === domainId)
: null; // Test connection state
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<TestResult>(null);
// Submit state (separate from testing)
const [submitting, setSubmitting] = React.useState(false);
// Create error state
const [createError, setCreateError] = React.useState<{
type: CreateErrorType;
message: string;
} | null>(null);
// 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.resetFields(); form.setFieldsValue({
form.setFieldsValue({ authType: "password" }); name: "",
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);
// Process domain: remove protocol prefix and trailing slashes
let processedDomain = values.domain.trim();
if (processedDomain.startsWith("https://")) {
processedDomain = processedDomain.slice(8);
} else if (processedDomain.startsWith("http://")) {
processedDomain = processedDomain.slice(7);
}
processedDomain = processedDomain.replace(/\/+$/, "");
// Use domain as name if name is empty
const name = values.name?.trim() || processedDomain; const name = values.name?.trim() || processedDomain;
// Clear previous error
setCreateError(null);
// For new domains, test connection first
if (!isEdit) {
setSubmitting(true);
const testResult = await testConnection({
domain: values.domain,
username: values.username,
password: values.password,
processedDomain,
});
if (!testResult?.success) {
showError("connection", t("createConnectionFailed"));
setSubmitting(false);
return;
}
} else {
setSubmitting(true);
}
if (isEdit && editingDomain) { if (isEdit && editingDomain) {
const params: UpdateDomainParams = { const params: UpdateDomainParams = {
id: domainId, id: 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 {
message.error("更新失败"); showError("unknown", t("updateFailed"));
} }
} 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 {
message.error("创建失败"); showError("unknown", t("createFailed"));
} }
} }
} catch (error) { } catch (error) {
setSubmitting(false);
console.error("Form validation failed:", error); console.error("Form validation failed:", error);
} }
}; };
const authType = Form.useWatch("authType", form); /**
const [testing, setTesting] = React.useState(false); * Render test button with result icon inside
*/
const renderTestButton = () => {
const getIcon = () => {
if (!testResult) return undefined;
return testResult.success ? <CheckCircle2 size={16} color={token.colorSuccess} /> : <XCircle size={16} color={token.colorError} />;
};
// Test connection with current form values return (
const handleTestConnection = async () => { <Button onClick={handleTestConnection} loading={testing} icon={getIcon()}>
try { {t("testConnection")}
const values = await form.validateFields([ </Button>
"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 ? "编辑 Domain" : "添加 Domain"} title={isEdit ? t("editDomain") : t("addDomain")}
open={open} open={open}
onCancel={onClose} onCancel={onClose}
footer={null} footer={null}
width={520} width={520}
destroyOnClose destroyOnHidden
mask={{ closable: false }}
> >
<Form <Form form={form} layout="vertical" onValuesChange={handleFieldChange}>
form={form} <Form.Item name="name" label={t("name")} style={{ marginTop: 8 }}>
layout="vertical" <Input placeholder={t("nameOptional")} />
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="Kintone 域名" label={t("kintoneDomain")}
rules={[ rules={[
{ required: true, message: "请输入域名" }, { required: true, message: t("enterDomain") },
{ {
validator: (_, value) => { validator: (_, value) => {
if (!value) return Promise.resolve(); if (!value) return Promise.resolve();
// Allow https:// or http:// prefix const processed = processDomain(value);
let domain = value.trim(); if (validateDomainFormat(processed)) {
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("请输入有效的域名")); return Promise.reject(new Error(t("validDomainRequired")));
}, },
}, },
]} ]}
@@ -215,57 +329,37 @@ 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 <Form.Item name="username" label={t("username")} rules={[{ required: true, message: t("enterUsername") }]}>
name="username" <Input placeholder={t("usernameLoginHint")} />
label="用户名"
rules={[{ required: true, message: "请输入用户名" }]}
>
<Input placeholder="登录 Kintone 的用户名" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="password" label={t("password")} rules={isEdit ? [] : [{ required: true, message: t("enterPassword") }]}>
name="authType" <InputPassword placeholder={isEdit ? t("keepPasswordHint") : t("enterPassword")} />
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>
{authType === "password" && ( <Form.Item style={{ marginTop: 32, marginBottom: 0 }}>
<Form.Item <div
name="password" style={{
label="密码" display: "flex",
rules={isEdit ? [] : [{ required: true, message: "请输入密码" }]} justifyContent: "space-between",
gap: 8,
alignItems: "center",
}}
> >
<Input.Password {/* Left side: Cancel button and error message */}
placeholder={isEdit ? "留空则保持原密码" : "请输入密码"} <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
/> <Button onClick={onClose}>{t("cancel", { ns: "common" })}</Button>
</Form.Item> </div>
)}
{authType === "api_token" && ( {/* Right side: Test button and Create/Update button */}
<Form.Item <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
name="apiToken" {createError && <span style={{ color: token.colorError, fontSize: 14 }}>{createError.message}</span>}
label="API Token" {renderTestButton()}
rules={[{ required: true, message: "请输入 API Token" }]} <Button type="primary" onClick={handleSubmit} loading={submitting}>
> {isEdit ? t("update") : t("create")}
<Input placeholder="从 Kintone 设置中获取 API Token" />
</Form.Item>
)}
<Form.Item style={{ marginTop: 24, marginBottom: 0 }}>
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={onClose}></Button>
<Button onClick={handleTestConnection} loading={testing}>
</Button> </Button>
<Button type="primary" onClick={handleSubmit} loading={loading}> </div>
{isEdit ? "更新" : "创建"} </div>
</Button>
</Space>
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>

View File

@@ -1,59 +1,92 @@
/** /**
* DomainList Component * DomainList Component
* Displays list of domains with connection status * Displays list of domains with drag-to-reorder functionality
*/ */
import React from "react"; import React from "react";
import { List, Avatar, Tag, Button, Popconfirm, Space, Tooltip } from "antd"; import { useTranslation } from "react-i18next";
import { import { Popconfirm, Space } from "antd";
CloudServerOutlined, import { Button, SortableList, Tooltip } from "@lobehub/ui";
EditOutlined, import { Pencil, Trash2 } from "lucide-react";
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 type { Domain } from "@renderer/types/domain"; import { usePendingChangesCheck } from "@renderer/hooks/usePendingChangesCheck";
import type { Domain } from "@shared/types/domain";
const useStyles = createStyles(({ token, css }) => ({ const useStyles = createStyles(({ token, css }) => ({
item: css` itemWrapper: css`
padding: ${token.paddingMD}px; width: 100%;
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`
margin-left: ${token.paddingSM}px; itemContent: css`
display: flex;
align-items: center;
gap: ${token.paddingXS}px;
position: relative;
`,
domainText: css`
flex: 1;
min-width: 0;
overflow: hidden;
`, `,
})); }));
@@ -62,17 +95,20 @@ 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 { const { domains, currentDomain, switchDomain, deleteDomain, reorderDomains } = useDomainStore();
domains, const { checkAndConfirmDomainSwitch } = usePendingChangesCheck();
currentDomain,
connectionStatuses,
switchDomain,
deleteDomain,
testConnection,
} = useDomainStore();
const handleSelect = (domain: Domain) => { const handleSelect = async (domain: Domain) => {
if (currentDomain?.id === domain.id) {
return;
}
// Check for pending changes before switching domain
const confirmed = await checkAndConfirmDomainSwitch(domain.id);
if (!confirmed) {
return;
}
switchDomain(domain); switchDomain(domain);
}; };
@@ -80,83 +116,51 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
await deleteDomain(id); await deleteDomain(id);
}; };
const getStatusIcon = (id: string) => { // Handle reorder - convert SortableListItem[] back to reorder action
const status = connectionStatuses[id]; const handleSortChange = (newItems: { id: string }[]) => {
switch (status) { const newOrder = newItems.map((item) => item.id);
case "connected": const oldOrder = domains.map((d) => d.id);
return <CheckCircleOutlined style={{ color: "#52c41a" }} />;
case "error": // Find the element that was moved: its position changed from old to new
return <CloseCircleOutlined style={{ color: "#ff4d4f" }} />; // When item at oldIndex moves to newIndex, oldOrder[newIndex] !== newOrder[newIndex]
default: for (let i = 0; i < newOrder.length; i++) {
return <QuestionCircleOutlined style={{ color: "#faad14" }} />; if (oldOrder[i] !== newOrder[i]) {
// The item at position i in newOrder came from somewhere in oldOrder
const movedItemId = newOrder[i];
const fromIndex = oldOrder.indexOf(movedItemId);
const toIndex = i;
reorderDomains(fromIndex, toIndex);
break;
}
} }
}; };
const getStatusTag = (id: string) => { const renderItem = (item: { id: string }) => {
const status = connectionStatuses[id]; const domain = domains.find((d) => d.id === item.id);
switch (status) { if (!domain) return null;
case "connected":
return <Tag color="success"></Tag>;
case "error":
return <Tag color="error"></Tag>;
default:
return <Tag color="warning"></Tag>;
}
};
return (
<List
dataSource={domains}
renderItem={(domain) => {
const isSelected = currentDomain?.id === domain.id; const isSelected = currentDomain?.id === domain.id;
return ( return (
<div <SortableList.Item id={domain.id}>
className={`${styles.item} ${isSelected ? styles.selectedItem : ""}`} <div className={`${styles.itemWrapper} ${isSelected ? styles.selectedItem : ""}`} onClick={() => handleSelect(domain)}>
onClick={() => handleSelect(domain)} <div className={styles.itemContent}>
> <SortableList.DragHandle />
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div className={styles.domainInfo}> <div className={styles.domainInfo}>
<Avatar <div className={styles.domainText}>
icon={<CloudServerOutlined />} <div className={styles.domainName}>{domain.name}</div>
style={{
backgroundColor: isSelected ? "#1890ff" : "#87d068",
}}
/>
<div>
<div className={styles.domainName}>
{domain.name}
{getStatusTag(domain.id)}
</div>
<div className={styles.domainUrl}> <div className={styles.domainUrl}>
{domain.domain} · {domain.username} {domain.username} · {domain.domain}
</div> </div>
</div> </div>
</div> </div>
<Space> <Space className={"domain-item-actions " + styles.actions}>
<Tooltip title="测试连接"> <Tooltip title={t("edit")}>
<Button <Button
type="text" type="text"
size="small" size="small"
icon={getStatusIcon(domain.id)} icon={<Pencil size={16} />}
onClick={(e) => {
e.stopPropagation();
testConnection(domain.id);
}}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEdit(domain.id); onEdit(domain.id);
@@ -164,31 +168,28 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
/> />
</Tooltip> </Tooltip>
<Popconfirm <Popconfirm
title="确认删除" title={t("confirmDelete")}
description="确定要删除此 Domain 配置吗?" description={t("confirmDeleteDesc")}
onConfirm={(e) => { onConfirm={(e) => {
e?.stopPropagation(); e?.stopPropagation();
handleDelete(domain.id); handleDelete(domain.id);
}} }}
onCancel={(e) => e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}
okText="删除" okText={t("delete", { ns: "common" })}
cancelText="取消" cancelText={t("cancel", { ns: "common" })}
> >
<Button <Tooltip title={t("delete", { ns: "common" })}>
type="text" <Button type="text" size="small" danger icon={<Trash2 size={16} />} onClick={(e) => e.stopPropagation()} />
size="small" </Tooltip>
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm> </Popconfirm>
</Space> </Space>
</div> </div>
</div> </div>
); </SortableList.Item>
}}
/>
); );
}; };
return <SortableList items={domains} renderItem={renderItem} onChange={handleSortChange} gap={4} />;
};
export default DomainList; export default DomainList;

View File

@@ -1,45 +1,61 @@
/** /**
* 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 { Button, Empty, Spin } from "antd"; import { Spin } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons"; import { Button, Tooltip, Avatar, Empty, Block } from "@lobehub/ui";
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;
padding: ${token.paddingLG}px; background: ${token.colorFillSecondary};
background: ${token.colorBgContainer}; border-radius: ${token.borderRadiusLG}px;
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;
margin-bottom: ${token.marginLG}px; flex: 1;
padding-bottom: ${token.paddingMD}px; cursor: pointer;
border-bottom: 1px solid ${token.colorBorderSecondary}; padding-right: ${token.paddingSM}px;
`,
headerRight: css`
display: flex;
align-items: center;
gap: ${token.paddingXS}px;
`, `,
title: css` title: css`
font-size: ${token.fontSizeHeading4}px; font-size: ${token.fontSize}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;
@@ -47,11 +63,57 @@ 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;
`,
})); }));
const DomainManager: React.FC = () => { interface DomainManagerProps {
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 } = useDomainStore(); const { domains, loading, loadDomains, currentDomain } = 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);
@@ -74,25 +136,65 @@ const DomainManager: React.FC = () => {
setEditingDomain(null); setEditingDomain(null);
}; };
const handleRefresh = () => { // Collapsed view - show current domain only
loadDomains(); if (collapsed) {
};
return ( 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 (
<>
<div className={styles.wrapper}>
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
<h2 className={styles.title}>Domain </h2> <Tooltip title={t("collapse")} placement="topRight">
<div className={styles.actions}> <div className={styles.headerLeft} onClick={onToggleCollapse}>
<Button <h3 className={styles.title}>{t("domainManagement")}</h3>
icon={<ReloadOutlined />} <ChevronUp size={16} style={{ opacity: 0.5 }} />
onClick={handleRefresh} </div>
loading={loading} </Tooltip>
> <div className={styles.headerRight}>
<Tooltip title={t("addDomain")}>
</Button> <Button type="primary" size="small" icon={<Plus size={16} />} onClick={handleAdd} />
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}> </Tooltip>
Domain
</Button>
</div> </div>
</div> </div>
@@ -102,25 +204,15 @@ const DomainManager: React.FC = () => {
<Spin size="large" /> <Spin size="large" />
</div> </div>
) : domains.length === 0 ? ( ) : domains.length === 0 ? (
<Empty <Empty description={t("noDomainConfig")}></Empty>
description="暂无 Domain 配置"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" onClick={handleAdd}>
Domain
</Button>
</Empty>
) : ( ) : (
<DomainList onEdit={handleEdit} /> <DomainList onEdit={handleEdit} />
)} )}
</div> </div>
<DomainForm
open={formOpen}
onClose={handleCloseForm}
domainId={editingDomain}
/>
</div> </div>
</div>
<DomainForm open={formOpen} onClose={handleCloseForm} domainId={editingDomain} />
</>
); );
}; };

View File

@@ -4,17 +4,12 @@
*/ */
import React from "react"; import React from "react";
import { Upload, Button, List, Space, Tag, message, Popconfirm } from "antd"; import { useTranslation } from "react-i18next";
import { import { Upload, List, Space, Tag, message, Popconfirm } from "antd";
InboxOutlined, import { Button } from "@lobehub/ui";
DeleteOutlined, import { Inbox, Trash2, File, Code } from "lucide-react";
FileTextOutlined,
FileOutlined,
CodeOutlined,
} from "@ant-design/icons";
import { createStyles } from "antd-style"; import { createStyles } from "antd-style";
import type { UploadFile, UploadProps } from "antd"; import type { DeployFile } from "@shared/types/ipc";
import type { DeployFile } from "@renderer/types/ipc";
const { Dragger } = Upload; const { Dragger } = Upload;
@@ -63,19 +58,20 @@ 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: UploadProps["beforeUpload"] = (file) => { const handleBeforeUpload = (file: 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("只支持 .js 和 .css 文件"); message.error(t("onlyJsCss"));
return Upload.LIST_IGNORE; return Upload.LIST_IGNORE;
} }
// Check file size // Check file size
if (file.size > maxFileSize) { if (file.size > maxFileSize) {
message.error(`文件大小不能超过 ${maxFileSize / 1024 / 1024}MB`); message.error(t("fileSizeLimit", { size: maxFileSize / 1024 / 1024 }));
return Upload.LIST_IGNORE; return Upload.LIST_IGNORE;
} }
@@ -120,20 +116,12 @@ const FileUploader: React.FC<FileUploaderProps> = ({
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Dragger <Dragger className={styles.dragger} beforeUpload={handleBeforeUpload} showUploadList={false} multiple accept=".js,.css">
className={styles.dragger}
beforeUpload={handleBeforeUpload}
showUploadList={false}
multiple
accept=".js,.css"
>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<InboxOutlined /> <Inbox size={24} />
</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 && (
@@ -146,16 +134,16 @@ const FileUploader: React.FC<FileUploaderProps> = ({
marginBottom: 8, marginBottom: 8,
}} }}
> >
<span> {files.length} </span> <span>{t("selectedFiles", { count: files.length })}</span>
<Popconfirm <Popconfirm
title="确认清空" title={t("confirmClear")}
description="确定要清空所有文件吗?" description={t("confirmClearDesc")}
onConfirm={handleClear} onConfirm={handleClear}
okText="清空" okText={t("clearAll")}
cancelText="取消" cancelText={t("cancel", { ns: "common" })}
> >
<Button size="small" danger> <Button size="small" danger>
{t("clearAll")}
</Button> </Button>
</Popconfirm> </Popconfirm>
</div> </div>
@@ -165,31 +153,18 @@ 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}>
<FileOutlined <File size={20} style={{ color: getFileTypeColor(file.fileType) }} />
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 <Tag color={file.fileType === "js" ? "gold" : "blue"} style={{ marginLeft: 8 }}>
color={file.fileType === "js" ? "gold" : "blue"}
style={{ marginLeft: 8 }}
>
{file.fileType.toUpperCase()} {file.fileType.toUpperCase()}
</Tag> </Tag>
</div> </div>
</div> </div>
</div> </div>
<Button <Button type="text" danger icon={<Trash2 size={16} />} onClick={() => handleRemove(index)} />
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleRemove(index)}
/>
</div> </div>
)} )}
/> />

View File

@@ -0,0 +1,242 @@
/**
* 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;

View File

@@ -0,0 +1,6 @@
/**
* Settings Components
* Export all settings components
*/
export { default as Settings } from "./Settings";

View File

@@ -1,228 +0,0 @@
/**
* 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;

View File

@@ -1,6 +0,0 @@
/**
* SpaceTree Components
* Export all space tree components
*/
export { default as SpaceTree } from "./SpaceTree";

View File

@@ -4,32 +4,16 @@
*/ */
import React from "react"; import React from "react";
import { import { useTranslation } from "react-i18next";
List, import { List, Tag, Space, Spin, Popconfirm, Typography } from "antd";
Avatar, import { Button, Tooltip, Empty, Avatar } from "@lobehub/ui";
Tag,
Button, import { History, Download, Trash2, Undo2, Code, FileText } from "lucide-react";
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 "@renderer/types/version"; import type { Version } from "@shared/types/version";
const { Text } = Typography; const { Text } = Typography;
@@ -102,11 +86,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 } = const { versions, loading, setVersions, setLoading, removeVersion } = useVersionStore();
useVersionStore();
// Load versions when app changes // Load versions when app changes
React.useEffect(() => { React.useEffect(() => {
@@ -181,9 +165,9 @@ const VersionHistory: React.FC = () => {
const getSourceTag = (source: Version["source"]) => { const getSourceTag = (source: Version["source"]) => {
const config = { const config = {
upload: { color: "blue", text: "上传" }, upload: { color: "blue", text: t("sourceUpload") },
download: { color: "green", text: "下载" }, download: { color: "green", text: t("sourceDownload") },
rollback: { color: "orange", text: "回滚" }, rollback: { color: "orange", text: t("sourceRollback") },
}; };
return config[source] || { color: "default", text: source }; return config[source] || { color: "default", text: source };
}; };
@@ -192,10 +176,7 @@ 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 <Empty description={t("selectApp", { ns: "app" })} />
description="请选择一个应用"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div> </div>
</div> </div>
); );
@@ -213,25 +194,18 @@ 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}>
<HistoryOutlined style={{ fontSize: 20 }} /> <History size={20} />
<Text strong></Text> <Text strong>{t("title")}</Text>
<Tag>{versions.length} </Tag> <Tag>{t("totalVersions", { count: versions.length })}</Tag>
</div> </div>
<Button <Button icon={<Download size={16} />} onClick={loadVersions} loading={loading}>
icon={<DownloadOutlined />} {t("refresh", { ns: "common" })}
onClick={loadVersions}
loading={loading}
>
</Button> </Button>
</div> </div>
<div className={styles.content}> <div className={styles.content}>
{versions.length === 0 ? ( {versions.length === 0 ? (
<Empty <Empty description={t("noVersions")} />
description="暂无版本历史"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : ( ) : (
<List <List
dataSource={versions} dataSource={versions}
@@ -241,16 +215,9 @@ const VersionHistory: React.FC = () => {
<div className={styles.versionItem}> <div className={styles.versionItem}>
<div className={styles.versionInfo}> <div className={styles.versionInfo}>
<Avatar <Avatar
icon={ icon={version.fileType === "js" ? <Code size={16} /> : <FileText size={16} />}
version.fileType === "js" ? (
<CodeOutlined />
) : (
<FileTextOutlined />
)
}
style={{ style={{
backgroundColor: backgroundColor: version.fileType === "js" ? "#f7df1e" : "#264de4",
version.fileType === "js" ? "#f7df1e" : "#264de4",
}} }}
/> />
<div className={styles.versionDetails}> <div className={styles.versionDetails}>
@@ -258,75 +225,48 @@ 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"> <Text type="secondary">{formatFileSize(version.size)}</Text>
{formatFileSize(version.size)} <Text type="secondary">{formatDate(version.createdAt)}</Text>
</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 <Tag key={i} color="processing">
key={i}
icon={<TagOutlined />}
color="processing"
>
{tag} {tag}
</Tag> </Tag>
))} ))}
</div> </div>
)} )}
{version.notes && ( {version.notes && <Text type="secondary">{version.notes}</Text>}
<Text type="secondary">{version.notes}</Text>
)}
</div> </div>
</div> </div>
<Space> <Space>
<Tooltip title="查看代码"> <Tooltip title={t("viewCode")}>
<Button <Button type="text" size="small" icon={<Code size={16} />} />
type="text"
size="small"
icon={<CodeOutlined />}
/>
</Tooltip> </Tooltip>
<Tooltip title="下载"> <Tooltip title={t("download", { ns: "common" })}>
<Button <Button type="text" size="small" icon={<Download size={16} />} />
type="text"
size="small"
icon={<DownloadOutlined />}
/>
</Tooltip> </Tooltip>
<Tooltip title="回滚到此版本"> <Tooltip title={t("confirmRollback")}>
<Popconfirm <Popconfirm
title="确认回滚" title={t("confirmRollback")}
description="确定要回滚到此版本吗?" description={t("confirmRollbackDesc")}
onConfirm={() => handleRollback(version)} onConfirm={() => handleRollback(version)}
okText="回滚" okText={t("sourceRollback")}
cancelText="取消" cancelText={t("cancel", { ns: "common" })}
> >
<Button <Button type="text" size="small" icon={<Undo2 size={16} />} />
type="text"
size="small"
icon={<RollbackOutlined />}
/>
</Popconfirm> </Popconfirm>
</Tooltip> </Tooltip>
<Popconfirm <Popconfirm
title="确认删除" title={t("confirmDelete")}
description="确定要删除此版本吗?" description={t("confirmDeleteDesc")}
onConfirm={() => handleDelete(version.id)} onConfirm={() => handleDelete(version.id)}
okText="删除" okText={t("delete", { ns: "common" })}
cancelText="取消" cancelText={t("cancel", { ns: "common" })}
> >
<Button <Button type="text" size="small" danger icon={<Trash2 size={16} />} />
type="text"
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm> </Popconfirm>
</Space> </Space>
</div> </div>

View File

@@ -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;
} }

View File

@@ -0,0 +1,115 @@
/**
* 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,
};
};

76
src/renderer/src/i18n.ts Normal file
View File

@@ -0,0 +1,76 @@
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;

View File

@@ -4,9 +4,9 @@ body,
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, font-family:
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
'Noto Color Emoji'; "Segoe UI Symbol", "Noto Color Emoji";
} }
/* macOS style window controls area */ /* macOS style window controls area */

View File

@@ -0,0 +1,54 @@
{
"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)"
}

View File

@@ -0,0 +1,48 @@
{
"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)"
}

View File

@@ -0,0 +1,63 @@
{
"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"
}

View File

@@ -0,0 +1,23 @@
{
"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"
}

View File

@@ -0,0 +1,15 @@
{
"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"
}

View File

@@ -0,0 +1,28 @@
{
"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"
}

View File

@@ -0,0 +1,13 @@
{
"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"
}

View File

@@ -0,0 +1,54 @@
{
"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を超えています"
}

View File

@@ -0,0 +1,48 @@
{
"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}} 件の移動ファイル"
}

View File

@@ -0,0 +1,63 @@
{
"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": "折りたたむ"
}

View File

@@ -0,0 +1,23 @@
{
"networkError": "ネットワークエラー",
"invalidDomain": "無効なドメイン",
"connectionFailed": "接続に失敗しました",
"saveFailed": "保存に失敗しました",
"loadFailed": "読み込みに失敗しました",
"unknownError": "不明なエラー",
"timeout": "リクエストがタイムアウトしました",
"unauthorized": "認証されていません",
"forbidden": "アクセスが禁止されています",
"notFound": "リソースが見つかりません",
"serverError": "サーバーエラー",
"invalidInput": "入力が無効です",
"requiredField": "この項目は必須です",
"invalidFormat": "形式が無効です",
"duplicateEntry": "同じレコードが既に存在します",
"operationCancelled": "操作がキャンセルされました",
"permissionDenied": "権限がありません",
"fileTooLarge": "ファイルが大きすぎます",
"unsupportedFormat": "サポートされていないファイル形式です",
"storageFull": "ストレージ容量不足",
"domainAlreadyExists": "このドメインは既に存在します"
}

View File

@@ -0,0 +1,15 @@
{
"loading": "読み込み中...",
"loadFailed": "読み込み失敗",
"fileEmpty": "ファイルの内容が空です",
"copiedToClipboard": "クリップボードにコピーしました",
"downloadSuccess": "ダウンロード成功",
"clickOrDragToUpload": "クリックまたはドラッグしてファイルをアップロード",
"supportFiles": ".jsと.cssファイルに対応、最大{{size}}MB",
"selectedFiles": "{{count}}個のファイルを選択",
"confirmClear": "クリア確認",
"confirmClearDesc": "すべてのファイルをクリアしますか?",
"clearAll": "すべてクリア",
"onlyJsCss": ".jsと.cssファイルのみ対応しています",
"fileSizeLimit": "ファイルサイズは{{size}}MBを超えられません"
}

View File

@@ -0,0 +1,28 @@
{
"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": "更新をダウンロード"
}

View File

@@ -0,0 +1,13 @@
{
"title": "バージョン履歴",
"totalVersions": "{{count}}バージョン",
"noVersions": "バージョン履歴なし",
"viewCode": "コードを表示",
"confirmRollback": "ロールバック確認",
"confirmRollbackDesc": "このバージョンにロールバックしますか?",
"confirmDelete": "削除確認",
"confirmDeleteDesc": "このバージョンを削除しますか?",
"sourceUpload": "アップロード",
"sourceDownload": "ダウンロード",
"sourceRollback": "ロールバック"
}

View File

@@ -0,0 +1,54 @@
{
"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"
}

View File

@@ -0,0 +1,48 @@
{
"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}} 个移动文件"
}

View File

@@ -0,0 +1,63 @@
{
"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": "折叠"
}

View File

@@ -0,0 +1,23 @@
{
"networkError": "网络错误",
"invalidDomain": "无效的域名",
"connectionFailed": "连接失败",
"saveFailed": "保存失败",
"loadFailed": "加载失败",
"unknownError": "未知错误",
"timeout": "请求超时",
"unauthorized": "未授权",
"forbidden": "禁止访问",
"notFound": "资源不存在",
"serverError": "服务器错误",
"invalidInput": "输入无效",
"requiredField": "此字段为必填",
"invalidFormat": "格式无效",
"duplicateEntry": "已存在相同记录",
"operationCancelled": "操作已取消",
"permissionDenied": "权限不足",
"fileTooLarge": "文件过大",
"unsupportedFormat": "不支持的文件格式",
"storageFull": "存储空间不足",
"domainAlreadyExists": "该 Domain 已存在"
}

View File

@@ -0,0 +1,15 @@
{
"loading": "加载中...",
"loadFailed": "加载失败",
"fileEmpty": "文件内容为空",
"copiedToClipboard": "已复制到剪贴板",
"downloadSuccess": "下载成功",
"clickOrDragToUpload": "点击或拖拽文件到此区域上传",
"supportFiles": "支持 .js 和 .css 文件,单个文件最大 {{size}}MB",
"selectedFiles": "已选择 {{count}} 个文件",
"confirmClear": "确认清空",
"confirmClearDesc": "确定要清空所有文件吗?",
"clearAll": "清空全部",
"onlyJsCss": "只支持 .js 和 .css 文件",
"fileSizeLimit": "文件大小不能超过 {{size}}MB"
}

View File

@@ -0,0 +1,28 @@
{
"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": "下载更新"
}

View File

@@ -0,0 +1,13 @@
{
"title": "版本历史",
"totalVersions": "{{count}} 个版本",
"noVersions": "暂无版本历史",
"viewCode": "查看代码",
"confirmRollback": "确认回滚",
"confirmRollbackDesc": "确定要回滚到此版本吗?",
"confirmDelete": "确认删除",
"confirmDeleteDesc": "确定要删除此版本吗?",
"sourceUpload": "上传",
"sourceDownload": "下载",
"sourceRollback": "回滚"
}

View File

@@ -1,27 +1,39 @@
import React from 'react' import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import { ConfigProvider, App as AntdApp } from 'antd' import { App as AntdApp } from "antd";
import zhCN from 'antd/locale/zh_CN' import { ConfigProvider, ThemeProvider } from "@lobehub/ui";
import { ThemeProvider } from '@lobehub/ui' import { I18nextProvider } from "react-i18next";
import App from './App' import i18n from "./i18n";
import './index.css' import App from "./App";
import { useThemeStore } from "./stores/themeStore";
ReactDOM.createRoot(document.getElementById('root')!).render( import { motion } from "motion/react";
<React.StrictMode>
<ConfigProvider const ThemeApp: React.FC = () => {
locale={zhCN} const { themeMode, setThemeMode } = useThemeStore();
theme={{
token: { return (
colorPrimary: '#1677ff', <ThemeProvider
borderRadius: 6, themeMode={themeMode}
}, onThemeModeChange={setThemeMode}
}} // customTheme={{primaryColor: 'blue'}}
customToken={() => ({
colorLink: "#1890ff",
})}
> >
<ThemeProvider>
<AntdApp> <AntdApp>
<App /> <App />
</AntdApp> </AntdApp>
</ThemeProvider> </ThemeProvider>
);
};
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ConfigProvider motion={motion}>
<I18nextProvider i18n={i18n}>
<ThemeApp />
</I18nextProvider>
</ConfigProvider> </ConfigProvider>
</React.StrictMode>, </React.StrictMode>
) );

View File

@@ -1,57 +1,72 @@
/** /**
* App Store * App Store
* Manages app browsing state (spaces, apps, current selection) * Manages app browsing state (apps, current selection, pagination)
* Persisted to localStorage for offline access
*/ */
import { create } from "zustand"; import { create } from "zustand";
import type { import { persist } from "zustand/middleware";
KintoneSpace, import type { AppResponse, AppDetail } from "@shared/types/kintone";
KintoneApp,
AppDetail,
} from "@renderer/types/kintone";
interface AppState { interface AppState {
// State // State
spaces: KintoneSpace[]; apps: AppResponse[];
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
setSpaces: (spaces: KintoneSpace[]) => void; setApps: (apps: AppResponse[]) => 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;
} }
export const useAppStore = create<AppState>()((set) => ({ const initialState = {
// 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>()(
persist(
(set) => ({
...initialState,
// Actions // Actions
setSpaces: (spaces) => set({ spaces }), setApps: (apps) =>
set({
apps,
loadedAt: new Date().toISOString(),
}),
setApps: (apps) => set({ apps }), setCurrentApp: (currentApp) => set({ currentApp }),
setCurrentSpace: (space) => set({ currentSpace: space }), setSelectedAppId: (selectedAppId) => set({ selectedAppId }),
setCurrentApp: (app) => set({ currentApp: app }),
setSelectedAppId: (id) => set({ selectedAppId: id }),
setLoading: (loading) => set({ loading }), setLoading: (loading) => set({ loading }),
@@ -59,14 +74,32 @@ export const useAppStore = create<AppState>()((set) => ({
clearError: () => set({ error: null }), clearError: () => set({ error: null }),
clear: () => setCurrentPage: (currentPage) => set({ currentPage }),
setPageSize: (pageSize) =>
set({ set({
spaces: [], pageSize,
apps: [], currentPage: 1, // Reset to first page when page size changes
currentSpace: null,
currentApp: null,
selectedAppId: null,
loading: false,
error: null,
}), }),
}));
setSearchText: (searchText) =>
set({
searchText,
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,
}),
}
)
);

View File

@@ -1,85 +0,0 @@
/**
* 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),
}));

View File

@@ -4,13 +4,12 @@
*/ */
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 "@renderer/types/domain"; import type { Domain, DomainWithStatus } from "@shared/types/domain";
import type { ConnectionStatus } from "@renderer/types/domain"; import type { ConnectionStatus } from "@shared/types/domain";
import type { import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
CreateDomainParams,
UpdateDomainParams,
} from "@renderer/types/ipc";
interface DomainState { interface DomainState {
// State // State
@@ -26,12 +25,9 @@ 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: ( setConnectionStatus: (id: string, status: ConnectionStatus, error?: string) => void;
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;
@@ -67,27 +63,29 @@ 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: currentDomain: state.currentDomain?.id === domain.id ? domain : state.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: currentDomain: state.currentDomain?.id === id ? null : state.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 connectionErrors: error ? { ...state.connectionErrors, [id]: error } : state.connectionErrors,
? { ...state.connectionErrors, [id]: error }
: state.connectionErrors,
})), })),
setLoading: (loading) => set({ loading }), setLoading: (loading) => set({ loading }),
@@ -108,14 +106,20 @@ export const useDomainStore = create<DomainState>()(
} }
} catch (error) { } catch (error) {
set({ set({
error: error: error instanceof Error ? error.message : "Failed to load domains",
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);
@@ -131,10 +135,7 @@ export const useDomainStore = create<DomainState>()(
} }
} catch (error) { } catch (error) {
set({ set({
error: error: error instanceof Error ? error.message : "Failed to create domain",
error instanceof Error
? error.message
: "Failed to create domain",
loading: false, loading: false,
}); });
return false; return false;
@@ -147,13 +148,8 @@ 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) => domains: state.domains.map((d) => (d.id === result.data.id ? result.data : d)),
d.id === result.data.id ? result.data : d, currentDomain: state.currentDomain?.id === result.data.id ? result.data : state.currentDomain,
),
currentDomain:
state.currentDomain?.id === result.data.id
? result.data
: state.currentDomain,
loading: false, loading: false,
})); }));
return true; return true;
@@ -163,10 +159,7 @@ export const useDomainStore = create<DomainState>()(
} }
} catch (error) { } catch (error) {
set({ set({
error: error: error instanceof Error ? error.message : "Failed to update domain",
error instanceof Error
? error.message
: "Failed to update domain",
loading: false, loading: false,
}); });
return false; return false;
@@ -180,8 +173,7 @@ 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: currentDomain: state.currentDomain?.id === id ? null : state.currentDomain,
state.currentDomain?.id === id ? null : state.currentDomain,
loading: false, loading: false,
})); }));
return true; return true;
@@ -191,10 +183,7 @@ export const useDomainStore = create<DomainState>()(
} }
} catch (error) { } catch (error) {
set({ set({
error: error: error instanceof Error ? error.message : "Failed to delete domain",
error instanceof Error
? error.message
: "Failed to delete domain",
loading: false, loading: false,
}); });
return false; return false;
@@ -202,10 +191,32 @@ 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);
if (status) { // Check if we're still on the same domain before updating connection status
if (status && get().currentDomain?.id === requestDomainId) {
set({ set({
connectionStatuses: { connectionStatuses: {
...get().connectionStatuses, ...get().connectionStatuses,
@@ -213,6 +224,23 @@ 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) => {
@@ -225,9 +253,7 @@ export const useDomainStore = create<DomainState>()(
...state.connectionStatuses, ...state.connectionStatuses,
[id]: status.connectionStatus, [id]: status.connectionStatus,
}, },
connectionErrors: status.connectionError connectionErrors: status.connectionError ? { ...state.connectionErrors, [id]: status.connectionError } : state.connectionErrors,
? { ...state.connectionErrors, [id]: status.connectionError }
: state.connectionErrors,
})); }));
return status; return status;
} else { } else {
@@ -248,10 +274,7 @@ export const useDomainStore = create<DomainState>()(
connectionStatuses: { ...state.connectionStatuses, [id]: "error" }, connectionStatuses: { ...state.connectionStatuses, [id]: "error" },
connectionErrors: { connectionErrors: {
...state.connectionErrors, ...state.connectionErrors,
[id]: [id]: error instanceof Error ? error.message : "Connection test failed",
error instanceof Error
? error.message
: "Connection test failed",
}, },
})); }));
return null; return null;
@@ -264,6 +287,6 @@ export const useDomainStore = create<DomainState>()(
domains: state.domains, domains: state.domains,
currentDomain: state.currentDomain, currentDomain: state.currentDomain,
}), }),
}, }
), )
); );

View File

@@ -0,0 +1,303 @@
/**
* 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",
}
)
);

View File

@@ -5,5 +5,11 @@
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";

View File

@@ -0,0 +1,34 @@
/**
* 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,
}),
}
)
);

View File

@@ -0,0 +1,56 @@
/**
* 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,
}),
}
)
);

View File

@@ -0,0 +1,32 @@
/**
* 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",
}
)
);

View File

@@ -0,0 +1,97 @@
/**
* 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,
}),
}
)
);

View File

@@ -4,7 +4,7 @@
*/ */
import { create } from "zustand"; import { create } from "zustand";
import type { Version } from "@renderer/types/version"; import type { Version } from "@shared/types/version";
interface VersionState { interface VersionState {
// State // State
@@ -45,8 +45,7 @@ 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: selectedVersion: state.selectedVersion?.id === id ? null : state.selectedVersion,
state.selectedVersion?.id === id ? null : state.selectedVersion,
})), })),
setSelectedVersion: (version) => set({ selectedVersion: version }), setSelectedVersion: (version) => set({ selectedVersion: version }),

View File

@@ -1,157 +0,0 @@
/**
* 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>;
}

View File

@@ -1,112 +0,0 @@
/**
* 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[] }>;
}

View File

@@ -6,10 +6,8 @@
export interface Domain { export interface Domain {
id: string; // UUID id: string; // UUID
name: string; // 自定义名称 name: string; // 自定义名称
domain: string; // Kintone 域名
username: string; // 用户名(邮箱) username: string; // 用户名(邮箱)
authType: "password" | "api_token"; domain: string; // Kintone 域名
apiToken?: string; // 可选,当 authType 为 api_token 时
createdAt: string; // ISO 8601 createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601 updatedAt: string; // ISO 8601
} }

232
src/shared/types/ipc.ts Normal file
View File

@@ -0,0 +1,232 @@
/**
* 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>>;
}

View File

@@ -0,0 +1,89 @@
/**
* 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;
}

View File

@@ -0,0 +1,46 @@
/**
* 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",
},
];

View File

@@ -0,0 +1,37 @@
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;
}

View File

@@ -0,0 +1,51 @@
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,
};
}

View File

@@ -1,14 +1,24 @@
{ {
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"], "include": [
"electron.vite.config.ts",
"src/main/**/*",
"src/preload/**/*",
"src/renderer/**/*",
"src/shared/**/*"
],
"exclude": [
"**/*.d.ts"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "skipLibCheck": 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

View File

@@ -1,13 +1,12 @@
{ {
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [ "include": ["src/renderer/src/**/*", "src/shared/**/*"],
"src/renderer/src/**/*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@renderer/*": ["src/renderer/src/*"] "@renderer/*": ["src/renderer/src/*"],
"@shared/*": ["src/shared/*"]
} }
} }
} }