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
This commit is contained in:
2026-03-16 00:07:49 +08:00
parent 345f0b344c
commit 43820127f4
13 changed files with 377 additions and 136 deletions

View File

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

View File

@@ -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<Result<LocaleCode>>;
setLocale: (params: SetLocaleParams) => Promise<Result<void>>;
// ==================== Dialog ====================
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
}

View File

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

View File

@@ -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 }}
>
<Settings />
</Modal>

View File

@@ -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<string | null>(
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 (
<div className={styles.container}>
@@ -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" ? (
<FileCode size={16} style={{ color: "#f7df1e" }} />
) : (
<FileText size={16} style={{ color: "#264de4" }} />
);
};
const renderFileList = (
files: (FileConfigResponse)[] | undefined,
files: FileConfigResponse[] | undefined,
type: "js" | "css",
) => {
if (!files || files.length === 0) {
return <div className={styles.emptySection}>{t("noConfig")}</div>;
return (
<div className={styles.fileTable}>
<div className={styles.emptySection} style={{ padding: "16px" }}>
{t("noConfig")}
</div>
</div>
);
}
return (
<div>
<div className={styles.fileTable}>
{files.map((file, index) => {
const fileName = getDisplayName(file, type, index);
const fileKey = getFileKey(file);
const canView = isFileResource(file);
const isDownloading = fileKey === downloadingKey;
return (
<div
@@ -189,20 +367,13 @@ const AppDetail: React.FC = () => {
className={styles.fileItem}
onClick={() => {
if (fileKey) {
setSelectedFile({ type, fileKey, name: fileName });
setActiveTab("code");
handleFileClick(type, fileKey, fileName);
}
}}
>
<div className={styles.fileInfo}>
<FileText size={16} />
<div>
<div className={styles.fileName}>{fileName}</div>
<div className={styles.fileType}>
{type.toUpperCase()} ·{" "}
{t("fileUpload")}
</div>
</div>
<span className={styles.fileIcon}>{getFileTypeIcon(type)}</span>
<span className={styles.fileName}>{fileName}</span>
</div>
{canView && (
<Space>
@@ -212,13 +383,25 @@ const AppDetail: React.FC = () => {
icon={<Code size={16} />}
onClick={(e) => {
e.stopPropagation();
setSelectedFile({ type, fileKey, name: fileName });
setActiveTab("code");
if (fileKey) {
handleFileClick(type, fileKey, fileName);
}
}}
>
{t("view")}
</Button>
<Button type="text" size="small" icon={<Download size={16} />}>
<Button
type="text"
size="small"
icon={<Download size={16} />}
loading={isDownloading}
onClick={(e) => {
e.stopPropagation();
if (fileKey) {
handleDownloadFile(fileKey, fileName, type);
}
}}
>
{t("download", { ns: "common" })}
</Button>
</Space>
@@ -230,6 +413,23 @@ const AppDetail: React.FC = () => {
);
};
const renderFileSection = (
title: string,
icon: React.ReactNode,
files: FileConfigResponse[] | undefined,
type: "js" | "css",
) => {
return (
<div>
<div className={styles.sectionHeader}>
{icon}
<span className={styles.sectionTitle}>{title}</span>
</div>
{renderFileList(files, type)}
</div>
);
};
return (
<div className={styles.container}>
<div className={styles.header}>
@@ -239,96 +439,67 @@ const AppDetail: React.FC = () => {
<Tag color="blue">{currentApp.appId}</Tag>
</div>
<Space>
<Button icon={<History size={16} />}>{t("versionHistory", { ns: "common" })}</Button>
<Button type="primary" icon={<Download size={16} />}>
<Button icon={<History size={16} />}>
{t("versionHistory", { ns: "common" })}
</Button>
<Button
type="primary"
icon={<Download size={16} />}
loading={downloadingAll}
onClick={handleDownloadAll}
>
{t("downloadAll")}
</Button>
</Space>
</div>
<div className={styles.content}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: "info",
label: t("basicInfo"),
children: (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label={t("appId")}>
{currentApp.appId}
</Descriptions.Item>
<Descriptions.Item label={t("appCode")}>
{currentApp.code || "-"}
</Descriptions.Item>
<Descriptions.Item label={t("createdAt")}>
{currentApp.createdAt}
</Descriptions.Item>
<Descriptions.Item label={t("creator")}>
{currentApp.creator?.name}
</Descriptions.Item>
<Descriptions.Item label={t("modifiedAt")}>
{currentApp.modifiedAt}
</Descriptions.Item>
<Descriptions.Item label={t("modifier")}>
{currentApp.modifier?.name}
</Descriptions.Item>
<Descriptions.Item label={t("spaceId")} span={2}>
{currentApp.spaceId || "-"}
</Descriptions.Item>
</Descriptions>
),
},
{
key: "pc-js",
label: t("pcJs"),
children: renderFileList(
{viewMode === "list" ? (
<>
{renderFileSection(
t("pcJs"),
<Monitor size={14} />,
currentApp.customization?.desktop?.js,
"js",
),
},
{
key: "pc-css",
label: t("pcCss"),
children: renderFileList(
)}
{renderFileSection(
t("pcCss"),
<Monitor size={14} />,
currentApp.customization?.desktop?.css,
"css",
),
},
{
key: "mobile-js",
label: t("mobileJs"),
children: renderFileList(
)}
{renderFileSection(
t("mobileJs"),
<Smartphone size={14} />,
currentApp.customization?.mobile?.js,
"js",
),
},
{
key: "mobile-css",
label: t("mobileCss"),
children: renderFileList(
)}
{renderFileSection(
t("mobileCss"),
<Smartphone size={14} />,
currentApp.customization?.mobile?.css,
"css",
),
},
{
key: "code",
label: t("codeView"),
children: selectedFile && selectedFile.fileKey ? (
)}
</>
) : (
<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}
/>
) : (
<div className={styles.emptySection}>
{t("selectFileToView")}
)}
</div>
),
},
]}
/>
)}
</div>
</div>
);

View File

@@ -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`

View File

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

View File

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

View File

@@ -34,5 +34,6 @@
"unpin": "ピン留め解除",
"pinApp": "アプリをピン留め",
"selectDomainFirst": "最初にドメインを選択してください",
"loadAppsFailed": "アプリの読み込みに失敗しました"
"loadAppsFailed": "アプリの読み込みに失敗しました",
"backToList": "リストに戻る"
}

View File

@@ -32,5 +32,8 @@
"selectAll": "すべて選択",
"deployFiles": "ファイルをデプロイ",
"versionHistory": "バージョン履歴",
"settings": "設定"
"settings": "設定",
"downloadSuccess": "ダウンロード成功",
"downloadFailed": "ダウンロード失敗",
"downloadAllSuccess": "ダウンロード先: {{path}}"
}

View File

@@ -34,5 +34,6 @@
"unpin": "取消置顶",
"pinApp": "置顶应用",
"selectDomainFirst": "请先选择一个 Domain",
"loadAppsFailed": "加载应用失败"
"loadAppsFailed": "加载应用失败",
"backToList": "返回列表"
}

View File

@@ -32,5 +32,8 @@
"selectAll": "全选",
"deployFiles": "部署文件",
"versionHistory": "版本历史",
"settings": "设置"
"settings": "设置",
"downloadSuccess": "下载成功",
"downloadFailed": "下载失败",
"downloadAllSuccess": "已下载到: {{path}}"
}

View File

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