update reload funtion

This commit is contained in:
2026-03-17 17:51:34 +08:00
parent 9a6e6b8ecb
commit 23a4e1e8cb
11 changed files with 419 additions and 111 deletions

View File

@@ -1,6 +1,71 @@
# MEMORY.md
## 2026-03-17 - GAIA_BL01 部署错误修复
## 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 部署错误修复(初始记录 - 已过时)
### 遇到什么问题
@@ -27,7 +92,6 @@
---
# MEMORY.md
## 2026-03-15 - CSS 模板字符串语法错误

View File

@@ -16,7 +16,10 @@ export type MainErrorKey =
| "unknownError"
| "rollbackNotImplemented"
| "encryptPasswordFailed"
| "decryptPasswordFailed";
| "decryptPasswordFailed"
| "deployFailed"
| "deployCancelled"
| "deployTimeout";
/**
* Error messages by locale
@@ -31,6 +34,9 @@ const errorMessages: Record<LocaleCode, Record<MainErrorKey, string>> = {
rollbackNotImplemented: "回滚功能尚未实现",
encryptPasswordFailed: "密码加密失败",
decryptPasswordFailed: "密码解密失败",
deployFailed: "部署失败",
deployCancelled: "部署已取消",
deployTimeout: "部署超时,请稍后刷新查看结果",
},
"en-US": {
domainNotFound: "Domain not found",
@@ -41,6 +47,9 @@ const errorMessages: Record<LocaleCode, Record<MainErrorKey, string>> = {
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: "ドメインが見つかりません",
@@ -51,6 +60,9 @@ const errorMessages: Record<LocaleCode, Record<MainErrorKey, string>> = {
rollbackNotImplemented: "ロールバック機能はまだ実装されていません",
encryptPasswordFailed: "パスワードの暗号化に失敗しました",
decryptPasswordFailed: "パスワードの復号化に失敗しました",
deployFailed: "デプロイに失敗しました",
deployCancelled: "デプロイがキャンセルされました",
deployTimeout: "デプロイがタイムアウトしました。後で更新してください",
},
};

View File

@@ -57,7 +57,13 @@ import type {
BackupMetadata,
DownloadFile,
} from "@shared/types/version";
import { isFileResource, isUrlResource, type AppCustomizeParameter, type AppDetail } from "@shared/types/kintone";
import {
FileConfigResponse,
isFileResource,
isUrlResource,
type AppCustomizeParameter,
type AppDetail,
} from "@shared/types/kintone";
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
import { getErrorMessage } from "./errors";
@@ -96,12 +102,49 @@ function handle<P = void, T = unknown>(
const data = await handler(params as P);
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : getErrorMessage("unknownError");
const message =
error instanceof Error
? error.message
: getErrorMessage("unknownError");
return { success: false, error: message };
}
});
}
/**
* Wait for Kintone deployment to complete
* Polls getDeployStatus until SUCCESS, FAIL, CANCEL, or timeout
*/
async function waitForDeploySuccess(
client: KintoneClient,
appId: string,
options: { timeoutMs?: number; pollIntervalMs?: number } = {},
): Promise<void> {
const { timeoutMs = 60000, pollIntervalMs = 1000 } = options;
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const status = await client.getDeployStatus(appId);
if (status === "SUCCESS") {
return;
}
if (status === "FAIL") {
throw new Error(getErrorMessage("deployFailed"));
}
if (status === "CANCEL") {
throw new Error(getErrorMessage("deployCancelled"));
}
// status === "PROCESSING" - wait and retry
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(getErrorMessage("deployTimeout"));
}
// ==================== Domain Management IPC Handlers ====================
/**
@@ -304,7 +347,7 @@ async function addFilesToBackup(
client: KintoneClient,
appDetail: AppDetail,
backupFiles: Map<string, Buffer>,
backupFileList: DownloadFile[]
backupFileList: DownloadFile[],
): Promise<void> {
const files = appDetail.customization?.[platform]?.[fileType] || [];
@@ -353,10 +396,38 @@ function registerDeploy(): void {
const backupFiles = new Map<string, Buffer>();
const backupFileList: BackupMetadata["files"] = [];
await addFilesToBackup("desktop", "js", client, appDetail, backupFiles, backupFileList);
await addFilesToBackup("desktop", "css", client, appDetail, backupFiles, backupFileList);
await addFilesToBackup("mobile", "js", client, appDetail, backupFiles, backupFileList);
await addFilesToBackup("mobile", "css", client, appDetail, backupFiles, backupFileList);
await addFilesToBackup(
"desktop",
"js",
client,
appDetail,
backupFiles,
backupFileList,
);
await addFilesToBackup(
"desktop",
"css",
client,
appDetail,
backupFiles,
backupFileList,
);
await addFilesToBackup(
"mobile",
"js",
client,
appDetail,
backupFiles,
backupFileList,
);
await addFilesToBackup(
"mobile",
"css",
client,
appDetail,
backupFiles,
backupFileList,
);
const backupMetadata: BackupMetadata = {
backedUpAt: new Date().toISOString(),
@@ -381,16 +452,114 @@ function registerDeploy(): void {
for (const file of params.files) {
if (file.status === "deleted") continue;
type FileEntry = { type: "FILE"; file: { fileKey: string } } | { type: "URL"; url: string };
type FileEntry =
| { type: "FILE"; file: { fileKey: string } }
| { type: "URL"; url: string };
let entry: FileEntry;
if (file.status === "unchanged") {
// IMPORTANT: Get fresh fileKey from current Kintone config, not from frontend
// The fileKey from frontend may have been consumed by a previous deployment
// and would cause GAIA_BL01 error
const currentFiles =
file.platform === "desktop"
? file.fileType === "js"
? appDetail.customization?.desktop?.js
: appDetail.customization?.desktop?.css
: file.fileType === "js"
? appDetail.customization?.mobile?.js
: appDetail.customization?.mobile?.css;
// Strategy for matching unchanged files:
// 1. First, try to match by fileKey (most reliable for FILE type)
// 2. If not found, fall back to name matching (for URL type or if fileKey changed)
//
// Note: We use isFileType() instead of isFileResource() for matching
// because isFileResource() requires fileKey to exist, which could cause
// valid files to be skipped during matching.
let matchingFile: FileConfigResponse | undefined;
let matchMethod: string = "unknown";
// Step 1: Try exact fileKey match (most reliable)
if (file.fileKey) {
entry = { type: "FILE", file: { fileKey: file.fileKey } };
} else if (file.url) {
entry = { type: "URL", url: file.url };
matchingFile = currentFiles?.find(
(f) => isFileResource(f) && f.file.fileKey === file.fileKey,
);
if (matchingFile) {
matchMethod = "fileKey exact match";
console.log(`[DEPLOY DEBUG] Matched by fileKey: ${file.fileKey}`);
}
}
// Step 2: Try URL match for URL-type files
if (!matchingFile && file.url) {
matchingFile = currentFiles?.find(
(f) => isUrlResource(f) && f.url === file.url,
);
if (matchingFile) {
matchMethod = "URL exact match";
console.log(`[DEPLOY DEBUG] Matched by URL: ${file.url}`);
}
}
// Step 3: Fall back to name matching (less reliable, could match wrong file if duplicates exist)
if (!matchingFile) {
matchingFile = currentFiles?.find((f) => {
if (isFileResource(f)) {
return f.file.name === file.fileName;
}
return false;
});
if (matchingFile) {
matchMethod = "filename match (fallback)";
console.log(
`[DEPLOY DEBUG] Matched by filename (fallback): ${file.fileName}`,
);
}
}
console.log(
`[DEPLOY DEBUG] Final matching result for "${file.fileName}":`,
matchingFile
? `${matchMethod}${isFileResource(matchingFile) ? `FILE key="${matchingFile.file.fileKey}"` : `URL "${matchingFile.url}"`}`
: "NOT FOUND",
);
if (matchingFile) {
if (isFileResource(matchingFile)) {
// Validate that the matched file has a valid fileKey
if (!matchingFile.file.fileKey) {
throw new Error(
`Matched file "${file.fileName}" has no fileKey in Kintone config. ` +
`This indicates corrupted data. Please refresh and try again.`,
);
}
// Verify filename matches (sanity check)
if (matchingFile.file.name !== file.fileName) {
console.warn(
`[DEPLOY WARNING] Filename mismatch: expected "${file.fileName}", found "${matchingFile.file.name}". ` +
`Proceeding with matched fileKey.`,
);
}
entry = {
type: "FILE",
file: { fileKey: matchingFile.file.fileKey },
};
} else if (isUrlResource(matchingFile)) {
entry = { type: "URL", url: matchingFile.url };
} else {
throw new Error(
`Invalid file type in Kintone config for "${file.fileName}"`,
);
}
} else {
throw new Error(`Invalid unchanged file entry: no fileKey or url for "${file.fileName}"`);
// File not found in current Kintone config - this is an error
// The file may have been deleted from Kintone externally
throw new Error(
`File "${file.fileName}" not found in current Kintone configuration. ` +
`It may have been deleted externally. Please refresh and try again.`,
);
}
} else {
// added: read from disk and upload
@@ -414,6 +583,9 @@ function registerDeploy(): void {
await client.updateAppCustomize(params.appId, newConfig);
await client.deployApp(params.appId);
// Wait for deployment to complete before returning
await waitForDeploySuccess(client, params.appId);
return {
success: true,
backupPath,
@@ -690,14 +862,14 @@ function registerCheckForUpdates(): void {
}
const result = await autoUpdater.checkForUpdates();
if (result && result.updateInfo) {
const currentVersion = app.getVersion();
const latestVersion = result.updateInfo.version;
// Compare versions
const hasUpdate = latestVersion !== currentVersion;
return {
hasUpdate,
updateInfo: hasUpdate
@@ -709,7 +881,7 @@ function registerCheckForUpdates(): void {
: undefined,
};
}
return {
hasUpdate: false,
updateInfo: undefined,
@@ -781,26 +953,32 @@ export function registerIpcHandlers(): void {
* Show save dialog
*/
function registerShowSaveDialog(): void {
handle<{ defaultPath?: string }, string | null>("showSaveDialog", async (params) => {
const result = await dialog.showSaveDialog({
defaultPath: params.defaultPath,
filters: [
{ name: "JavaScript", extensions: ["js"] },
{ name: "CSS", extensions: ["css"] },
{ name: "All Files", extensions: ["*"] },
],
});
return result.filePath || null;
});
handle<{ defaultPath?: string }, string | null>(
"showSaveDialog",
async (params) => {
const result = await dialog.showSaveDialog({
defaultPath: params.defaultPath,
filters: [
{ name: "JavaScript", extensions: ["js"] },
{ name: "CSS", extensions: ["css"] },
{ name: "All Files", extensions: ["*"] },
],
});
return result.filePath || null;
},
);
}
/**
* Save file content to disk
*/
function registerSaveFileContent(): void {
handle<{ filePath: string; content: string }, void>("saveFileContent", async (params) => {
const fs = await import("fs");
const buffer = Buffer.from(params.content, "base64");
await fs.promises.writeFile(params.filePath, buffer);
});
handle<{ filePath: string; content: string }, void>(
"saveFileContent",
async (params) => {
const fs = await import("fs");
const buffer = Buffer.from(params.content, "base64");
await fs.promises.writeFile(params.filePath, buffer);
},
);
}

View File

@@ -121,7 +121,7 @@ export class KintoneClient {
return this.withErrorHandling(async () => {
const [appInfo, customizeInfo] = await Promise.all([
this.client.app.getApp({ id: appId }),
this.client.app.getAppCustomize({ app: appId }),
this.client.app.getAppCustomize({ app: appId, preview: true }),
]);
return {

View File

@@ -25,8 +25,7 @@ import {
useFileChangeStore,
} from "@renderer/stores";
import { CodeViewer } from "../CodeViewer";
import { isFileResource, isUrlResource } from "@shared/types/kintone";
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
import { transformCustomizeToFiles } from "@shared/utils/fileTransform";
import type { DeployFileEntry } from "@shared/types/ipc";
import FileSection from "./FileSection";
@@ -115,71 +114,7 @@ const AppDetail: React.FC = () => {
setSelectedFile(null);
}, [selectedAppId]);
// Load app detail when selected
useEffect(() => {
if (currentDomain && selectedAppId) {
loadAppDetail();
}
}, [currentDomain, selectedAppId]);
// Initialize file change store from Kintone data
useEffect(() => {
if (!currentApp || !currentDomain || !selectedAppId) return;
const customize = currentApp.customization;
if (!customize) return;
const files = [
...(customize.desktop?.js ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "js", index),
fileType: "js" as const,
platform: "desktop" as const,
size: isFileResource(file)
? parseInt(file.file.size ?? "0", 10) || undefined
: undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
...(customize.desktop?.css ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "css", index),
fileType: "css" as const,
platform: "desktop" as const,
size: isFileResource(file)
? parseInt(file.file.size ?? "0", 10) || undefined
: undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
...(customize.mobile?.js ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "js", index),
fileType: "js" as const,
platform: "mobile" as const,
size: isFileResource(file)
? parseInt(file.file.size ?? "0", 10) || undefined
: undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
...(customize.mobile?.css ?? []).map((file, index) => ({
id: crypto.randomUUID(),
fileName: getDisplayName(file, "css", index),
fileType: "css" as const,
platform: "mobile" as const,
size: isFileResource(file)
? parseInt(file.file.size ?? "0", 10) || undefined
: undefined,
fileKey: getFileKey(file),
url: isUrlResource(file) ? file.url : undefined,
})),
];
fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files);
}, [currentApp?.appId]);
const loadAppDetail = async () => {
const loadAppDetail = useCallback(async () => {
if (!currentDomain || !selectedAppId) return undefined;
setLoading(true);
@@ -191,6 +126,8 @@ const AppDetail: React.FC = () => {
// Check if we're still on the same app and component is mounted before updating
if (result.success) {
// Clear changes and reload from Kintone
fileChangeStore.clearChanges(currentDomain.id, selectedAppId);
setCurrentApp(result.data);
return result.data;
}
@@ -201,7 +138,25 @@ const AppDetail: React.FC = () => {
} finally {
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, name: string) => {
const ext = name.split(".").pop()?.toLowerCase();
@@ -210,6 +165,21 @@ const AppDetail: React.FC = () => {
setViewMode("code");
}, []);
const handleRefresh = useCallback(async () => {
if (!currentDomain || !selectedAppId || refreshing) return;
setRefreshing(true);
try {
await loadAppDetail();
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);
@@ -333,8 +303,6 @@ const AppDetail: React.FC = () => {
if (result.success) {
message.success(t("deploySuccess"));
// Clear changes and reload from Kintone
fileChangeStore.clearChanges(currentDomain.id, selectedAppId);
await loadAppDetail();
} else {
message.error(result.error || t("deployFailed"));
@@ -344,7 +312,7 @@ const AppDetail: React.FC = () => {
} finally {
setDeploying(false);
}
}, [currentDomain, selectedAppId, deploying, fileChangeStore, message, t]);
}, [currentDomain, selectedAppId, deploying, fileChangeStore, loadAppDetail, message, t]);
if (!currentDomain || !selectedAppId) {
return (

View File

@@ -176,7 +176,6 @@ const FileSection: React.FC<FileSectionProps> = ({
};
addFile(domainId, appId, entry);
message.success(t("fileAdded", { name: result.data.fileName }));
} catch {
message.error(t("fileAddFailed"));
} finally {

View File

@@ -43,7 +43,6 @@
"dropFileHere": "Drop file here",
"fileTypeNotSupported": "Only {{expected}} files are supported",
"fileSizeExceeded": "File size exceeds the 20 MB limit",
"fileAdded": "{{name}} added",
"fileAddFailed": "Failed to add file",
"fileDeleteFailed": "Failed to delete file",
"statusAdded": "New",

View File

@@ -43,7 +43,6 @@
"dropFileHere": "ここにドロップ",
"fileTypeNotSupported": "{{expected}} ファイルのみ対応しています",
"fileSizeExceeded": "ファイルサイズが 20 MB 制限を超えています",
"fileAdded": "{{name}} を追加しました",
"fileAddFailed": "ファイルの追加に失敗しました",
"fileDeleteFailed": "ファイルの削除に失敗しました",
"statusAdded": "新規",

View File

@@ -43,7 +43,6 @@
"dropFileHere": "松开以添加文件",
"fileTypeNotSupported": "仅支持 {{expected}} 文件",
"fileSizeExceeded": "文件大小超过 20 MB 限制",
"fileAdded": "{{name}} 已添加",
"fileAddFailed": "添加文件失败",
"fileDeleteFailed": "删除文件失败",
"statusAdded": "新增",

View File

@@ -55,6 +55,17 @@ export interface GetFileContentParams {
// ==================== 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";
export interface DeployFileEntry {

View File

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