From 43820127f4aea2d6ed4b54941926cef9d8bfb6cc Mon Sep 17 00:00:00 2001 From: xue jiahao Date: Mon, 16 Mar 2026 00:07:49 +0800 Subject: [PATCH] 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 --- src/main/ipc-handlers.ts | 35 +- src/preload/index.d.ts | 5 + src/preload/index.ts | 4 + src/renderer/src/App.tsx | 4 +- .../src/components/AppDetail/AppDetail.tsx | 421 ++++++++++++------ .../components/DomainManager/DomainForm.tsx | 6 +- src/renderer/src/locales/en-US/app.json | 3 +- src/renderer/src/locales/en-US/common.json | 5 +- src/renderer/src/locales/ja-JP/app.json | 3 +- src/renderer/src/locales/ja-JP/common.json | 5 +- src/renderer/src/locales/zh-CN/app.json | 3 +- src/renderer/src/locales/zh-CN/common.json | 5 +- src/shared/types/ipc.ts | 14 + 13 files changed, 377 insertions(+), 136 deletions(-) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 144f46c..cf88ada 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -4,7 +4,7 @@ * Based on REQUIREMENTS.md:228-268 */ -import { ipcMain } from "electron"; +import { ipcMain, dialog } from "electron"; import { v4 as uuidv4 } from "uuid"; import { saveDomain, @@ -573,6 +573,39 @@ export function registerIpcHandlers(): void { registerGetLocale(); registerSetLocale(); + // Dialog + registerShowSaveDialog(); + registerSaveFileContent(); + console.log("IPC handlers registered"); } +// ==================== Dialog IPC Handlers ==================== + +/** + * 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; + }); +} + +/** + * 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); + }); +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index ba46f82..4cbbb19 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -14,6 +14,8 @@ import type { GetVersionsParams, RollbackParams, SetLocaleParams, + ShowSaveDialogParams, + SaveFileContentParams, } from "@shared/types/ipc"; import type { Domain, DomainWithStatus } from "@shared/types/domain"; import type { @@ -68,4 +70,7 @@ export interface SelfAPI { getLocale: () => Promise>; setLocale: (params: SetLocaleParams) => Promise>; + // ==================== Dialog ==================== + showSaveDialog: (params: ShowSaveDialogParams) => Promise>; + saveFileContent: (params: SaveFileContentParams) => Promise>; } diff --git a/src/preload/index.ts b/src/preload/index.ts index cfd7753..8208c98 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -34,6 +34,10 @@ const api: SelfAPI = { getLocale: () => ipcRenderer.invoke("getLocale"), setLocale: (params) => ipcRenderer.invoke("setLocale", params), + // Dialog + showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params), + saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params), + }; // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3cffa59..7cdc3ae 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,9 +11,10 @@ import { ConfigProvider, App as AntApp, Space, + Modal } from "antd"; -import { Button, Tooltip, Modal } from "@lobehub/ui"; +import { Button, Tooltip } from "@lobehub/ui"; import { Cloud, @@ -327,6 +328,7 @@ const App: React.FC = () => { onCancel={() => setSettingsOpen(false)} footer={null} width={480} + mask={{ closable: false }} > diff --git a/src/renderer/src/components/AppDetail/AppDetail.tsx b/src/renderer/src/components/AppDetail/AppDetail.tsx index 4dbae02..a19c5c7 100644 --- a/src/renderer/src/components/AppDetail/AppDetail.tsx +++ b/src/renderer/src/components/AppDetail/AppDetail.tsx @@ -5,20 +5,18 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { - Descriptions, - Tabs, - Spin, - Tag, - Space, -} from "antd"; +import { Spin, Tag, Space, message } from "antd"; import { Button, Empty } from "@lobehub/ui"; import { LayoutGrid, Download, History, Code, + FileCode, FileText, + Monitor, + Smartphone, + ArrowLeft, } from "lucide-react"; import { createStyles } from "antd-style"; import { useAppStore } from "@renderer/stores"; @@ -26,6 +24,7 @@ import { useDomainStore } from "@renderer/stores"; import { CodeViewer } from "../CodeViewer"; import { FileConfigResponse, isFileResource } from "@shared/types/kintone"; import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay"; + const useStyles = createStyles(({ token, css }) => ({ container: css` height: 100%; @@ -34,6 +33,9 @@ const useStyles = createStyles(({ token, css }) => ({ background: ${token.colorBgContainer}; `, header: css` + display: flex; + justify-content: space-between; + align-items: center; padding: ${token.paddingMD}px ${token.paddingLG}px; border-bottom: 1px solid ${token.colorBorderSecondary}; `, @@ -41,7 +43,6 @@ const useStyles = createStyles(({ token, css }) => ({ display: flex; align-items: center; gap: ${token.paddingSM}px; - margin-bottom: ${token.marginSM}px; `, appName: css` font-size: ${token.fontSizeHeading5}px; @@ -59,39 +60,84 @@ const useStyles = createStyles(({ token, css }) => ({ align-items: center; height: 300px; `, + // Table-like file list styles + fileTable: css` + border: 1px solid ${token.colorBorderSecondary}; + border-radius: ${token.borderRadiusLG}px; + overflow: hidden; + `, 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; + transition: background 0.2s; + border-bottom: 1px solid ${token.colorBorderSecondary}; + + &:last-child { + border-bottom: none; + } &:hover { - border-color: ${token.colorPrimary}; - background: ${token.colorPrimaryBg}; + background: ${token.colorBgTextHover}; } `, fileInfo: css` display: flex; align-items: center; gap: ${token.paddingSM}px; + flex: 1; + min-width: 0; + `, + fileIcon: css` + width: 16px; + height: 16px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; `, fileName: css` font-weight: 500; - `, - fileType: css` - font-size: ${token.fontSizeSM}px; - color: ${token.colorTextSecondary}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `, emptySection: css` padding: ${token.paddingLG}px; text-align: center; color: ${token.colorTextSecondary}; `, + sectionHeader: css` + display: flex; + align-items: center; + gap: ${token.paddingXS}px; + padding: ${token.paddingSM}px 0; + margin-top: ${token.marginMD}px; + font-weight: ${token.fontWeightStrong}; + color: ${token.colorTextSecondary}; + + &:first-of-type { + margin-top: 0; + } + `, + sectionTitle: css` + font-size: ${token.fontSizeSM}px; + `, + // Back button - no border, left aligned with text + backButton: css` + padding: 0; + margin-bottom: ${token.marginMD}px; + display: flex; + align-items: center; + justify-content: flex-start; + `, + codeView: css` + height: 100%; + display: flex; + flex-direction: column; + `, })); const AppDetail: React.FC = () => { @@ -101,6 +147,26 @@ const AppDetail: React.FC = () => { const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } = useAppStore(); + // View mode: 'list' shows file sections, 'code' shows code viewer + const [viewMode, setViewMode] = React.useState<"list" | "code">("list"); + const [selectedFile, setSelectedFile] = React.useState<{ + type: "js" | "css"; + fileKey: string; + name: string; + } | null>(null); + + // Download state: track which file is being downloaded + const [downloadingKey, setDownloadingKey] = React.useState( + null, + ); + const [downloadingAll, setDownloadingAll] = React.useState(false); + + // Reset view mode when app changes + React.useEffect(() => { + setViewMode("list"); + setSelectedFile(null); + }, [selectedAppId]); + // Load app detail when selected React.useEffect(() => { if (currentDomain && selectedAppId) { @@ -127,13 +193,6 @@ const AppDetail: React.FC = () => { } }; - const [activeTab, setActiveTab] = React.useState("info"); - const [selectedFile, setSelectedFile] = React.useState<{ - type: "js" | "css"; - fileKey?: string; - name: string; - } | null>(null); - if (!currentDomain || !selectedAppId) { return (
@@ -168,20 +227,139 @@ const AppDetail: React.FC = () => { ); } + const handleFileClick = ( + type: "js" | "css", + fileKey: string, + name: string, + ) => { + setSelectedFile({ type, fileKey, name }); + setViewMode("code"); + }; + + const handleBackToList = () => { + setViewMode("list"); + setSelectedFile(null); + }; + + // Download single file with save dialog + const handleDownloadFile = async ( + fileKey: string, + fileName: string, + type: "js" | "css", + ) => { + if (!currentDomain || downloadingKey) return; + + // Check if fileName already has extension + const hasExt = /\.(js|css)$/i.test(fileName); + const finalFileName = hasExt ? fileName : `${fileName}.${type}`; + + setDownloadingKey(fileKey); + + try { + // 1. Show save dialog + const dialogResult = await window.api.showSaveDialog({ + defaultPath: finalFileName, + }); + + if (!dialogResult.success || !dialogResult.data) { + // User cancelled + setDownloadingKey(null); + return; + } + + const savePath = dialogResult.data; + + // 2. Get file content + 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; + } + + // 3. Save to selected path + const saveResult = await window.api.saveFileContent({ + filePath: savePath, + content: contentResult.data.content, + }); + + if (saveResult.success) { + message.success(t("downloadSuccess", { ns: "common" })); + } else { + message.error( + saveResult.error || t("downloadFailed", { ns: "common" }), + ); + } + } catch (error) { + console.error("Download failed:", error); + message.error(t("downloadFailed", { ns: "common" })); + } finally { + setDownloadingKey(null); + } + }; + + // Download all files + const handleDownloadAll = async () => { + if (!currentDomain || !selectedAppId || downloadingAll) return; + + setDownloadingAll(true); + + try { + const result = await window.api.download({ + domainId: currentDomain.id, + appId: selectedAppId, + }); + + if (result.success) { + message.success(t("downloadAllSuccess", { path: result.path })); + } else { + message.error(result.error || t("downloadFailed", { ns: "common" })); + } + } catch (error) { + console.error("Download all failed:", error); + message.error(t("downloadFailed", { ns: "common" })); + } finally { + setDownloadingAll(false); + } + }; + + // Icon for file type: FileCode for JS, FileText for CSS + const getFileTypeIcon = (type: "js" | "css") => { + return type === "js" ? ( + + ) : ( + + ); + }; + const renderFileList = ( - files: (FileConfigResponse)[] | undefined, + files: FileConfigResponse[] | undefined, type: "js" | "css", ) => { if (!files || files.length === 0) { - return
{t("noConfig")}
; + return ( +
+
+ {t("noConfig")} +
+
+ ); } return ( -
+
{files.map((file, index) => { const fileName = getDisplayName(file, type, index); const fileKey = getFileKey(file); const canView = isFileResource(file); + const isDownloading = fileKey === downloadingKey; return (
{ className={styles.fileItem} onClick={() => { if (fileKey) { - setSelectedFile({ type, fileKey, name: fileName }); - setActiveTab("code"); + handleFileClick(type, fileKey, fileName); } }} >
- -
-
{fileName}
-
- {type.toUpperCase()} ·{" "} - {t("fileUpload")} -
-
+ {getFileTypeIcon(type)} + {fileName}
{canView && ( @@ -212,13 +383,25 @@ const AppDetail: React.FC = () => { icon={} onClick={(e) => { e.stopPropagation(); - setSelectedFile({ type, fileKey, name: fileName }); - setActiveTab("code"); + if (fileKey) { + handleFileClick(type, fileKey, fileName); + } }} > {t("view")} - @@ -230,6 +413,23 @@ const AppDetail: React.FC = () => { ); }; + const renderFileSection = ( + title: string, + icon: React.ReactNode, + files: FileConfigResponse[] | undefined, + type: "js" | "css", + ) => { + return ( +
+
+ {icon} + {title} +
+ {renderFileList(files, type)} +
+ ); + }; + return (
@@ -239,96 +439,67 @@ const AppDetail: React.FC = () => { {currentApp.appId}
- - +
- - - {currentApp.appId} - - - {currentApp.code || "-"} - - - {currentApp.createdAt} - - - {currentApp.creator?.name} - - - {currentApp.modifiedAt} - - - {currentApp.modifier?.name} - - - {currentApp.spaceId || "-"} - - - ), - }, - { - key: "pc-js", - label: t("pcJs"), - children: renderFileList( - currentApp.customization?.desktop?.js, - "js", - ), - }, - { - key: "pc-css", - label: t("pcCss"), - children: renderFileList( - currentApp.customization?.desktop?.css, - "css", - ), - }, - { - key: "mobile-js", - label: t("mobileJs"), - children: renderFileList( - currentApp.customization?.mobile?.js, - "js", - ), - }, - { - key: "mobile-css", - label: t("mobileCss"), - children: renderFileList( - currentApp.customization?.mobile?.css, - "css", - ), - }, - { - key: "code", - label: t("codeView"), - children: selectedFile && selectedFile.fileKey ? ( - - ) : ( -
- {t("selectFileToView")} -
- ), - }, - ]} - /> + {viewMode === "list" ? ( + <> + {renderFileSection( + t("pcJs"), + , + currentApp.customization?.desktop?.js, + "js", + )} + {renderFileSection( + t("pcCss"), + , + currentApp.customization?.desktop?.css, + "css", + )} + {renderFileSection( + t("mobileJs"), + , + currentApp.customization?.mobile?.js, + "js", + )} + {renderFileSection( + t("mobileCss"), + , + currentApp.customization?.mobile?.css, + "css", + )} + + ) : ( +
+ + {selectedFile && ( + + )} +
+ )}
); diff --git a/src/renderer/src/components/DomainManager/DomainForm.tsx b/src/renderer/src/components/DomainManager/DomainForm.tsx index fccbda0..cc336a8 100644 --- a/src/renderer/src/components/DomainManager/DomainForm.tsx +++ b/src/renderer/src/components/DomainManager/DomainForm.tsx @@ -5,8 +5,8 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Button, Form, Modal } from "@lobehub/ui"; -import { Input, message } from "antd"; +import { Button, Form } from "@lobehub/ui"; +import { Input, message, Modal } from "antd"; import { createStyles } from "antd-style"; import { useDomainStore } from "@renderer/stores"; import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc"; @@ -14,7 +14,7 @@ import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc"; const useStyles = createStyles(({ token, css }) => ({ form: css` .ant-form-item { - margin-bottom: ${token.marginMD}px; + margin-bottom: ${token.marginSM}px; } `, passwordHint: css` diff --git a/src/renderer/src/locales/en-US/app.json b/src/renderer/src/locales/en-US/app.json index 3b48f43..741674d 100644 --- a/src/renderer/src/locales/en-US/app.json +++ b/src/renderer/src/locales/en-US/app.json @@ -34,5 +34,6 @@ "unpin": "Unpin", "pinApp": "Pin App", "selectDomainFirst": "Please select a Domain first", - "loadAppsFailed": "Failed to load apps" + "loadAppsFailed": "Failed to load apps", + "backToList": "Back to List" } diff --git a/src/renderer/src/locales/en-US/common.json b/src/renderer/src/locales/en-US/common.json index e955fd9..2d4f6d8 100644 --- a/src/renderer/src/locales/en-US/common.json +++ b/src/renderer/src/locales/en-US/common.json @@ -32,5 +32,8 @@ "selectAll": "Select all", "deployFiles": "Deploy Files", "versionHistory": "Version History", - "settings": "Settings" + "settings": "Settings", + "downloadSuccess": "Download successful", + "downloadFailed": "Download failed", + "downloadAllSuccess": "Downloaded to: {{path}}" } diff --git a/src/renderer/src/locales/ja-JP/app.json b/src/renderer/src/locales/ja-JP/app.json index 2e5670c..cce5d33 100644 --- a/src/renderer/src/locales/ja-JP/app.json +++ b/src/renderer/src/locales/ja-JP/app.json @@ -34,5 +34,6 @@ "unpin": "ピン留め解除", "pinApp": "アプリをピン留め", "selectDomainFirst": "最初にドメインを選択してください", - "loadAppsFailed": "アプリの読み込みに失敗しました" + "loadAppsFailed": "アプリの読み込みに失敗しました", + "backToList": "リストに戻る" } diff --git a/src/renderer/src/locales/ja-JP/common.json b/src/renderer/src/locales/ja-JP/common.json index da190fb..951c42d 100644 --- a/src/renderer/src/locales/ja-JP/common.json +++ b/src/renderer/src/locales/ja-JP/common.json @@ -32,5 +32,8 @@ "selectAll": "すべて選択", "deployFiles": "ファイルをデプロイ", "versionHistory": "バージョン履歴", - "settings": "設定" + "settings": "設定", + "downloadSuccess": "ダウンロード成功", + "downloadFailed": "ダウンロード失敗", + "downloadAllSuccess": "ダウンロード先: {{path}}" } diff --git a/src/renderer/src/locales/zh-CN/app.json b/src/renderer/src/locales/zh-CN/app.json index 39b246b..27f20ba 100644 --- a/src/renderer/src/locales/zh-CN/app.json +++ b/src/renderer/src/locales/zh-CN/app.json @@ -34,5 +34,6 @@ "unpin": "取消置顶", "pinApp": "置顶应用", "selectDomainFirst": "请先选择一个 Domain", - "loadAppsFailed": "加载应用失败" + "loadAppsFailed": "加载应用失败", + "backToList": "返回列表" } diff --git a/src/renderer/src/locales/zh-CN/common.json b/src/renderer/src/locales/zh-CN/common.json index a587edf..051c848 100644 --- a/src/renderer/src/locales/zh-CN/common.json +++ b/src/renderer/src/locales/zh-CN/common.json @@ -32,5 +32,8 @@ "selectAll": "全选", "deployFiles": "部署文件", "versionHistory": "版本历史", - "settings": "设置" + "settings": "设置", + "downloadSuccess": "下载成功", + "downloadFailed": "下载失败", + "downloadAllSuccess": "已下载到: {{path}}" } diff --git a/src/shared/types/ipc.ts b/src/shared/types/ipc.ts index ee43117..d39a56f 100644 --- a/src/shared/types/ipc.ts +++ b/src/shared/types/ipc.ts @@ -116,6 +116,16 @@ export interface SetLocaleParams { locale: import("./locale").LocaleCode; } +// ==================== Dialog IPC Types ==================== + +export interface ShowSaveDialogParams { + defaultPath?: string; +} + +export interface SaveFileContentParams { + filePath: string; + content: string; // Base64 encoded +} // ==================== IPC API Interface ==================== export interface ElectronAPI { @@ -146,4 +156,8 @@ export interface ElectronAPI { getVersions: (params: GetVersionsParams) => Promise>; deleteVersion: (id: string) => Promise>; rollback: (params: RollbackParams) => Promise; + + // Dialog + showSaveDialog: (params: ShowSaveDialogParams) => Promise>; + saveFileContent: (params: SaveFileContentParams) => Promise>; }