From c3a333e2ed1300d338bd172a6c27d0425002d17e Mon Sep 17 00:00:00 2001 From: xue jiahao Date: Wed, 18 Mar 2026 09:21:23 +0800 Subject: [PATCH] UI improve --- src/main/ipc-handlers.ts | 47 ++++++- src/preload/index.d.ts | 3 + src/preload/index.ts | 1 + .../src/components/AppDetail/AppDetail.tsx | 82 +++++++---- .../src/components/AppDetail/FileItem.tsx | 128 +++++++++++------- .../src/components/AppDetail/FileSection.tsx | 73 +++++++--- .../components/AppDetail/SortableFileList.tsx | 6 +- .../src/components/CodeViewer/CodeViewer.tsx | 87 ++++++++---- src/renderer/src/locales/en-US/app.json | 4 +- src/renderer/src/locales/en-US/common.json | 4 +- src/renderer/src/locales/ja-JP/app.json | 4 +- src/renderer/src/locales/ja-JP/common.json | 4 +- src/renderer/src/locales/zh-CN/app.json | 4 +- src/renderer/src/locales/zh-CN/common.json | 4 +- src/renderer/src/stores/sessionStore.ts | 4 +- src/shared/types/ipc.ts | 12 ++ 16 files changed, 330 insertions(+), 137 deletions(-) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index a15ede3..8756931 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -31,6 +31,8 @@ import type { GetAppsParams, GetAppDetailParams, GetFileContentParams, + GetLocalFileContentParams, + LocalFileContent, DeployParams, DeployResult, DownloadParams, @@ -288,7 +290,7 @@ function registerGetAppDetail(): void { } /** - * Get file content + * Get file content from Kintone */ function registerGetFileContent(): void { handle>>("getFileContent", async (params) => { @@ -297,6 +299,48 @@ function registerGetFileContent(): void { }); } +/** + * Get local file content from storage + */ +function registerGetLocalFileContent(): void { + handle("getLocalFileContent", async (params) => { + const fs = await import("fs"); + const path = await import("path"); + + const { storagePath } = params; + + // Read file content + const buffer = fs.readFileSync(storagePath); + + // Get file name + const fileName = path.basename(storagePath); + + // Get file size + const stats = fs.statSync(storagePath); + + // Determine MIME type from extension + const ext = path.extname(storagePath).toLowerCase(); + let mimeType: string; + switch (ext) { + case ".js": + mimeType = "application/javascript"; + break; + case ".css": + mimeType = "text/css"; + break; + default: + mimeType = "application/octet-stream"; + } + + return { + name: fileName, + size: stats.size, + mimeType, + content: buffer.toString("base64"), + }; + }); +} + // ==================== Deploy IPC Handlers ==================== /** @@ -824,6 +868,7 @@ export function registerIpcHandlers(): void { registerGetApps(); registerGetAppDetail(); registerGetFileContent(); + registerGetLocalFileContent(); // Deploy registerDeploy(); diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 6cce900..d25fb0f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -7,6 +7,8 @@ import type { GetAppsParams, GetAppDetailParams, GetFileContentParams, + GetLocalFileContentParams, + LocalFileContent, DeployParams, DeployResult, DownloadParams, @@ -50,6 +52,7 @@ export interface SelfAPI { getApps: (params: GetAppsParams) => Promise>; getAppDetail: (params: GetAppDetailParams) => Promise>; getFileContent: (params: GetFileContentParams) => Promise>; + getLocalFileContent: (params: GetLocalFileContentParams) => Promise>; // ==================== Deploy ==================== deploy: (params: DeployParams) => Promise>; diff --git a/src/preload/index.ts b/src/preload/index.ts index 62b6be4..1db5ff4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -18,6 +18,7 @@ const api: SelfAPI = { getApps: (params) => ipcRenderer.invoke("getApps", params), getAppDetail: (params) => ipcRenderer.invoke("getAppDetail", params), getFileContent: (params) => ipcRenderer.invoke("getFileContent", params), + getLocalFileContent: (params) => ipcRenderer.invoke("getLocalFileContent", params), // Deploy deploy: (params) => ipcRenderer.invoke("deploy", params), diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index 242d5cf..b9286d5 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -3,9 +3,9 @@ * Displays app configuration details with file management and deploy functionality. */ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Spin, Tag, Space, App as AntApp } from "antd"; +import { Spin, Tag, Space, App as AntApp, Tooltip } from "antd"; import { Button, Empty } from "@lobehub/ui"; import { LayoutGrid, Download, History, Rocket, Monitor, Smartphone, ArrowLeft, RefreshCw } from "lucide-react"; import { createStyles, useTheme } from "antd-style"; @@ -89,6 +89,22 @@ const AppDetail: React.FC = () => { const [downloadingAll, setDownloadingAll] = React.useState(false); const [deploying, setDeploying] = React.useState(false); const [refreshing, setRefreshing] = React.useState(false); + const [isAnySectionOverLimit, setIsAnySectionOverLimit] = useState(false); + + // Track over-limit status from each FileSection using refs + const overLimitSectionsRef = React.useRef>(new Set()); + + const handleOverLimitChange = useCallback( + (sectionId: string) => (isOverLimit: boolean) => { + if (isOverLimit) { + overLimitSectionsRef.current.add(sectionId); + } else { + overLimitSectionsRef.current.delete(sectionId); + } + setIsAnySectionOverLimit(overLimitSectionsRef.current.size > 0); + }, + [] + ); // Reset view mode when app changes useEffect(() => { @@ -140,10 +156,10 @@ const AppDetail: React.FC = () => { fileChangeStore.initializeApp(currentDomain.id, selectedAppId, files); }, [currentApp]); - const handleFileClick = useCallback((fileKey: string, name: string) => { + 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 }); + setSelectedFile({ type, fileKey, name, storagePath }); setViewMode("code"); }, []); @@ -334,27 +350,35 @@ const AppDetail: React.FC = () => { - + + + @@ -371,6 +395,7 @@ const AppDetail: React.FC = () => { downloadingKey={downloadingKey} onView={handleFileClick} onDownload={handleDownloadFile} + onOverLimitChange={handleOverLimitChange("desktop-js")} /> { downloadingKey={downloadingKey} onView={handleFileClick} onDownload={handleDownloadFile} + onOverLimitChange={handleOverLimitChange("desktop-css")} /> { downloadingKey={downloadingKey} onView={handleFileClick} onDownload={handleDownloadFile} + onOverLimitChange={handleOverLimitChange("mobile-js")} /> { downloadingKey={downloadingKey} onView={handleFileClick} onDownload={handleDownloadFile} + onOverLimitChange={handleOverLimitChange("mobile-css")} /> ) : ( @@ -411,7 +439,9 @@ const AppDetail: React.FC = () => { - {selectedFile && } + {selectedFile && ( + + )} )} diff --git a/src/renderer/src/components/AppDetail/FileItem.tsx b/src/renderer/src/components/AppDetail/FileItem.tsx index 74fb941..dc7d2e4 100644 --- a/src/renderer/src/components/AppDetail/FileItem.tsx +++ b/src/renderer/src/components/AppDetail/FileItem.tsx @@ -6,9 +6,9 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Space, Badge } from "antd"; +import { Space, Badge, Tooltip } from "antd"; import { Button, MaterialFileTypeIcon, SortableList } from "@lobehub/ui"; -import { Code, Download, Trash2, Undo2 } from "lucide-react"; +import { Download, Trash2, Undo2 } from "lucide-react"; import { createStyles, useTheme } from "antd-style"; import type { FileEntry } from "@renderer/stores"; @@ -19,21 +19,31 @@ interface FileItemProps { onView?: () => void; onDownload?: () => void; isDownloading?: boolean; + showDivider?: boolean; } const useStyles = createStyles(({ token, css }) => ({ + wrapper: css` + width: 100%; + `, item: css` display: flex; align-items: center; justify-content: space-between; padding: ${token.paddingSM}px ${token.paddingMD}px; - border-bottom: 1px solid ${token.colorBorderSecondary}; width: 100%; + cursor: pointer; + transition: background-color 0.15s ease; - &:last-child { - border-bottom: none; + &:hover { + background-color: ${token.colorBgTextHover}; } `, + divider: css` + height: 1px; + background-color: ${token.colorBorder}; + margin: 0 ${token.paddingMD}px; + `, fileInfo: css` display: flex; align-items: center; @@ -49,7 +59,6 @@ const useStyles = createStyles(({ token, css }) => ({ `, fileNameDeleted: css` text-decoration: line-through; - color: ${token.colorTextDisabled}; `, fileSize: css` min-width: 40px; @@ -57,12 +66,6 @@ const useStyles = createStyles(({ token, css }) => ({ color: ${token.colorTextSecondary}; font-size: ${token.fontSizeSM}px; `, - statusDot: css` - width: 6px; - height: 6px; - border-radius: 50%; - flex-shrink: 0; - `, })); const formatFileSize = (size: number | undefined): string => { @@ -72,57 +75,80 @@ const formatFileSize = (size: number | undefined): string => { return `${(size / (1024 * 1024)).toFixed(1)} MB`; }; -const FileItem: React.FC = ({ entry, onDelete, onRestore, onView, onDownload, isDownloading }) => { +const FileItem: React.FC = ({ entry, onDelete, onRestore, onView, onDownload, isDownloading, showDivider = true }) => { const { t } = useTranslation(["app", "common"]); const { styles, cx } = useStyles(); const token = useTheme(); const statusColor: Record = { - unchanged: "transparent", 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 ( -
-
- - {entry.status !== "unchanged" &&
} - - {entry.fileName} - {entry.status === "added" && } - {entry.status === "deleted" && ( - - )} - {entry.status === "reordered" && ( - - )} +
+
+
+
+ +
+ + + {entry.fileName} + + {entry.status === "added" && ( + + )} + {entry.status === "deleted" && ( + + )} + {entry.status === "reordered" && ( + + )} +
+ + + {entry.size && {formatFileSize(entry.size)}} + + {entry.status === "deleted" ? ( + + ) : ( + <> + {(entry.status === "unchanged" || entry.status === "reordered") && onDownload && entry.fileKey && ( + +
- - - {entry.size && {formatFileSize(entry.size)}} - - {entry.status === "deleted" ? ( - - ) : ( - <> - {(entry.status === "unchanged" || entry.status === "reordered") && onView && entry.fileKey && ( - - )} - {(entry.status === "unchanged" || entry.status === "reordered") && onDownload && entry.fileKey && ( - - )} -
+ {/* File limit warning */} + {isOverLimit && } + {/* The entire card is the drop target */}
= ({ title, icon, platform, fileTy
+ + {/* File limit warning - bottom */} + {isOverLimit && }
); }; diff --git a/src/renderer/src/components/AppDetail/SortableFileList.tsx b/src/renderer/src/components/AppDetail/SortableFileList.tsx index 4354bf8..7e912af 100644 --- a/src/renderer/src/components/AppDetail/SortableFileList.tsx +++ b/src/renderer/src/components/AppDetail/SortableFileList.tsx @@ -16,7 +16,7 @@ import type { FileEntry } from "@renderer/stores"; interface SortableFileListProps { items: FileEntry[]; onReorder: (newOrder: string[], draggedItemId: string) => void; - renderItem: (entry: FileEntry) => React.ReactNode; + renderItem: (entry: FileEntry, index: number, totalCount: number) => React.ReactNode; } const SortableFileList: React.FC = ({ items, onReorder, renderItem }) => { @@ -45,9 +45,9 @@ const SortableFileList: React.FC = ({ items, onReorder, r return ( - {items.map((entry) => ( + {items.map((entry, index) => ( - {renderItem(entry)} + {renderItem(entry, index, items.length)} ))} diff --git a/src/renderer/src/components/CodeViewer/CodeViewer.tsx b/src/renderer/src/components/CodeViewer/CodeViewer.tsx index d946bc2..1b39afd 100644 --- a/src/renderer/src/components/CodeViewer/CodeViewer.tsx +++ b/src/renderer/src/components/CodeViewer/CodeViewer.tsx @@ -51,12 +51,14 @@ const useStyles = createStyles(({ token, css }) => ({ })); interface CodeViewerProps { - fileKey: string; + fileKey?: string; fileName: string; fileType: "js" | "css"; + /** For locally added files: absolute path on disk */ + storagePath?: string; } -const CodeViewer: React.FC = ({ fileKey, fileName, fileType }) => { +const CodeViewer: React.FC = ({ fileKey, fileName, fileType, storagePath }) => { const { t } = useTranslation("file"); const { styles } = useStyles(); const { appearance } = useTheme(); @@ -71,46 +73,60 @@ const CodeViewer: React.FC = ({ fileKey, fileName, fileType }) // Load file content React.useEffect(() => { - if (currentDomain && fileKey) { + if (currentDomain && (fileKey || storagePath)) { loadFileContent(); } - }, [currentDomain, fileKey]); + }, [currentDomain, fileKey, storagePath]); const loadFileContent = async () => { - if (!currentDomain) return; - setLoading(true); setError(null); try { - const result = await window.api.getFileContent({ - domainId: currentDomain.id, - fileKey, - }); + // 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 (including Japanese characters) - const base64 = result.data.content || ""; - const binaryString = atob(base64); - // Decode as UTF-8 to properly handle Japanese and other multi-byte characters - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - const decoder = new TextDecoder("utf-8"); - const decoded = decoder.decode(bytes); - setContent(decoded); - - // Detect language from file name - if (fileName.endsWith(".css")) { - setLanguage("css"); - } else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { - setLanguage("js"); + 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 { - setLanguage(fileType); + setError(result.error || "Failed to load local file content"); + } + } else if (currentDomain && fileKey) { + // Load from Kintone + const result = await window.api.getFileContent({ + domainId: currentDomain.id, + fileKey, + }); + + if (result.success) { + // Decode base64 content properly for UTF-8 (including Japanese characters) + const base64 = result.data.content || ""; + const binaryString = atob(base64); + // Decode as UTF-8 to properly handle Japanese and other multi-byte characters + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const decoder = new TextDecoder("utf-8"); + const decoded = decoder.decode(bytes); + setContent(decoded); + detectLanguage(); + } else { + setError(result.error || "Failed to load file content"); } } else { - setError(result.error || "Failed to load file content"); + setError("No file source available"); } } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); @@ -119,6 +135,17 @@ const CodeViewer: React.FC = ({ fileKey, fileName, fileType }) } }; + const detectLanguage = () => { + // Detect language from file name + if (fileName.endsWith(".css")) { + setLanguage("css"); + } else if (fileName.endsWith(".js") || fileName.endsWith(".ts")) { + setLanguage("js"); + } else { + setLanguage(fileType); + } + }; + const handleCopy = () => { navigator.clipboard.writeText(content); message.success(t("copiedToClipboard")); diff --git a/src/renderer/src/locales/en-US/app.json b/src/renderer/src/locales/en-US/app.json index d1819a4..2c71158 100644 --- a/src/renderer/src/locales/en-US/app.json +++ b/src/renderer/src/locales/en-US/app.json @@ -48,5 +48,7 @@ "statusAdded": "New", "statusDeleted": "Deleted", "statusReordered": "Moved", - "restore": "Restore" + "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)" } diff --git a/src/renderer/src/locales/en-US/common.json b/src/renderer/src/locales/en-US/common.json index 4dc4759..dbdda53 100644 --- a/src/renderer/src/locales/en-US/common.json +++ b/src/renderer/src/locales/en-US/common.json @@ -36,5 +36,7 @@ "downloadFailed": "Download failed", "downloadAllSuccess": "Downloaded to: {{path}}", "refreshSuccess": "Refreshed successfully", - "refreshFailed": "Refresh failed" + "refreshFailed": "Refresh failed", + "downloadFile": "Download file", + "deleteFile": "Delete file" } diff --git a/src/renderer/src/locales/ja-JP/app.json b/src/renderer/src/locales/ja-JP/app.json index d0a9a67..10ae73b 100644 --- a/src/renderer/src/locales/ja-JP/app.json +++ b/src/renderer/src/locales/ja-JP/app.json @@ -48,5 +48,7 @@ "statusAdded": "新規", "statusDeleted": "削除", "statusReordered": "順序変更", - "restore": "復元" + "restore": "復元", + "fileLimitWarning": "このセクションには30以上のファイルがあります。デプロイ前にファイル数を減らしてください。", + "deployDisabledReason": "1つ以上のセクションがファイル制限(30)を超えています" } diff --git a/src/renderer/src/locales/ja-JP/common.json b/src/renderer/src/locales/ja-JP/common.json index 16f9088..f8c5eca 100644 --- a/src/renderer/src/locales/ja-JP/common.json +++ b/src/renderer/src/locales/ja-JP/common.json @@ -36,5 +36,7 @@ "downloadFailed": "ダウンロード失敗", "downloadAllSuccess": "ダウンロード先: {{path}}", "refreshSuccess": "更新しました", - "refreshFailed": "更新に失敗しました" + "refreshFailed": "更新に失敗しました", + "downloadFile": "ファイルをダウンロード", + "deleteFile": "ファイルを削除" } diff --git a/src/renderer/src/locales/zh-CN/app.json b/src/renderer/src/locales/zh-CN/app.json index 97fc8fd..bb5a75a 100644 --- a/src/renderer/src/locales/zh-CN/app.json +++ b/src/renderer/src/locales/zh-CN/app.json @@ -48,5 +48,7 @@ "statusAdded": "新增", "statusDeleted": "删除", "statusReordered": "已移动", - "restore": "恢复" + "restore": "恢复", + "fileLimitWarning": "此区块文件数超过30个,请减少文件后再部署。", + "deployDisabledReason": "一个或多个区块超过文件数量限制(30)" } diff --git a/src/renderer/src/locales/zh-CN/common.json b/src/renderer/src/locales/zh-CN/common.json index f93b24a..4376365 100644 --- a/src/renderer/src/locales/zh-CN/common.json +++ b/src/renderer/src/locales/zh-CN/common.json @@ -36,5 +36,7 @@ "downloadFailed": "下载失败", "downloadAllSuccess": "已下载到: {{path}}", "refreshSuccess": "刷新成功", - "refreshFailed": "刷新失败" + "refreshFailed": "刷新失败", + "downloadFile": "下载文件", + "deleteFile": "删除文件" } diff --git a/src/renderer/src/stores/sessionStore.ts b/src/renderer/src/stores/sessionStore.ts index 9b872b6..9b5ca3c 100644 --- a/src/renderer/src/stores/sessionStore.ts +++ b/src/renderer/src/stores/sessionStore.ts @@ -11,8 +11,10 @@ export type ViewMode = "list" | "code"; export interface SelectedFile { type: "js" | "css"; - fileKey: string; + fileKey?: string; name: string; + /** For locally added files: absolute path on disk */ + storagePath?: string; } interface SessionState { diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index 3024f23..42fc0c1 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -51,6 +51,17 @@ export interface GetFileContentParams { fileKey: string; } +export interface GetLocalFileContentParams { + storagePath: string; +} + +export interface LocalFileContent { + name: string; + size: number; + mimeType: string; + content: string; // Base64 encoded +} + // ==================== Deploy IPC Types ==================== /** @@ -197,6 +208,7 @@ export interface ElectronAPI { getApps: (params: GetAppsParams) => Promise>; getAppDetail: (params: GetAppDetailParams) => Promise>; getFileContent: (params: GetFileContentParams) => Promise>; + getLocalFileContent: (params: GetLocalFileContentParams) => Promise>; // Deploy deploy: (params: DeployParams) => Promise>;