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:
@@ -4,7 +4,7 @@
|
|||||||
* Based on REQUIREMENTS.md:228-268
|
* Based on REQUIREMENTS.md:228-268
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ipcMain } from "electron";
|
import { ipcMain, dialog } from "electron";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import {
|
import {
|
||||||
saveDomain,
|
saveDomain,
|
||||||
@@ -573,6 +573,39 @@ export function registerIpcHandlers(): void {
|
|||||||
registerGetLocale();
|
registerGetLocale();
|
||||||
registerSetLocale();
|
registerSetLocale();
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
registerShowSaveDialog();
|
||||||
|
registerSaveFileContent();
|
||||||
|
|
||||||
console.log("IPC handlers registered");
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
@@ -14,6 +14,8 @@ import type {
|
|||||||
GetVersionsParams,
|
GetVersionsParams,
|
||||||
RollbackParams,
|
RollbackParams,
|
||||||
SetLocaleParams,
|
SetLocaleParams,
|
||||||
|
ShowSaveDialogParams,
|
||||||
|
SaveFileContentParams,
|
||||||
} from "@shared/types/ipc";
|
} from "@shared/types/ipc";
|
||||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
||||||
import type {
|
import type {
|
||||||
@@ -68,4 +70,7 @@ export interface SelfAPI {
|
|||||||
getLocale: () => Promise<Result<LocaleCode>>;
|
getLocale: () => Promise<Result<LocaleCode>>;
|
||||||
setLocale: (params: SetLocaleParams) => Promise<Result<void>>;
|
setLocale: (params: SetLocaleParams) => Promise<Result<void>>;
|
||||||
|
|
||||||
|
// ==================== Dialog ====================
|
||||||
|
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
||||||
|
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const api: SelfAPI = {
|
|||||||
getLocale: () => ipcRenderer.invoke("getLocale"),
|
getLocale: () => ipcRenderer.invoke("getLocale"),
|
||||||
setLocale: (params) => ipcRenderer.invoke("setLocale", params),
|
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
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
ConfigProvider,
|
ConfigProvider,
|
||||||
App as AntApp,
|
App as AntApp,
|
||||||
Space,
|
Space,
|
||||||
|
Modal
|
||||||
} from "antd";
|
} from "antd";
|
||||||
|
|
||||||
import { Button, Tooltip, Modal } from "@lobehub/ui";
|
import { Button, Tooltip } from "@lobehub/ui";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Cloud,
|
Cloud,
|
||||||
@@ -327,6 +328,7 @@ const App: React.FC = () => {
|
|||||||
onCancel={() => setSettingsOpen(false)}
|
onCancel={() => setSettingsOpen(false)}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={480}
|
width={480}
|
||||||
|
mask={{ closable: false }}
|
||||||
>
|
>
|
||||||
<Settings />
|
<Settings />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -5,20 +5,18 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import { Spin, Tag, Space, message } from "antd";
|
||||||
Descriptions,
|
|
||||||
Tabs,
|
|
||||||
Spin,
|
|
||||||
Tag,
|
|
||||||
Space,
|
|
||||||
} from "antd";
|
|
||||||
import { Button, Empty } from "@lobehub/ui";
|
import { Button, Empty } from "@lobehub/ui";
|
||||||
import {
|
import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Download,
|
Download,
|
||||||
History,
|
History,
|
||||||
Code,
|
Code,
|
||||||
|
FileCode,
|
||||||
FileText,
|
FileText,
|
||||||
|
Monitor,
|
||||||
|
Smartphone,
|
||||||
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import { useAppStore } from "@renderer/stores";
|
import { useAppStore } from "@renderer/stores";
|
||||||
@@ -26,6 +24,7 @@ import { useDomainStore } from "@renderer/stores";
|
|||||||
import { CodeViewer } from "../CodeViewer";
|
import { CodeViewer } from "../CodeViewer";
|
||||||
import { FileConfigResponse, isFileResource } from "@shared/types/kintone";
|
import { FileConfigResponse, isFileResource } from "@shared/types/kintone";
|
||||||
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
import { getDisplayName, getFileKey } from "@shared/utils/fileDisplay";
|
||||||
|
|
||||||
const useStyles = createStyles(({ token, css }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
container: css`
|
container: css`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -34,6 +33,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 +43,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;
|
||||||
@@ -59,39 +60,84 @@ const useStyles = createStyles(({ token, css }) => ({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
`,
|
`,
|
||||||
|
// Table-like file list styles
|
||||||
|
fileTable: css`
|
||||||
|
border: 1px solid ${token.colorBorderSecondary};
|
||||||
|
border-radius: ${token.borderRadiusLG}px;
|
||||||
|
overflow: hidden;
|
||||||
|
`,
|
||||||
fileItem: css`
|
fileItem: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||||
border: 1px solid ${token.colorBorderSecondary};
|
|
||||||
border-radius: ${token.borderRadiusLG}px;
|
|
||||||
margin-bottom: ${token.marginSM}px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: background 0.2s;
|
||||||
|
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: ${token.colorPrimary};
|
background: ${token.colorBgTextHover};
|
||||||
background: ${token.colorPrimaryBg};
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
fileInfo: css`
|
fileInfo: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: ${token.paddingSM}px;
|
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`
|
fileName: css`
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
`,
|
overflow: hidden;
|
||||||
fileType: css`
|
text-overflow: ellipsis;
|
||||||
font-size: ${token.fontSizeSM}px;
|
white-space: nowrap;
|
||||||
color: ${token.colorTextSecondary};
|
|
||||||
`,
|
`,
|
||||||
emptySection: css`
|
emptySection: css`
|
||||||
padding: ${token.paddingLG}px;
|
padding: ${token.paddingLG}px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: ${token.colorTextSecondary};
|
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 = () => {
|
const AppDetail: React.FC = () => {
|
||||||
@@ -101,6 +147,26 @@ const AppDetail: React.FC = () => {
|
|||||||
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
|
const { currentApp, selectedAppId, loading, setCurrentApp, setLoading } =
|
||||||
useAppStore();
|
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
|
// Load app detail when selected
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentDomain && selectedAppId) {
|
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) {
|
if (!currentDomain || !selectedAppId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<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 = (
|
const renderFileList = (
|
||||||
files: (FileConfigResponse)[] | undefined,
|
files: FileConfigResponse[] | undefined,
|
||||||
type: "js" | "css",
|
type: "js" | "css",
|
||||||
) => {
|
) => {
|
||||||
if (!files || files.length === 0) {
|
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 (
|
return (
|
||||||
<div>
|
<div className={styles.fileTable}>
|
||||||
{files.map((file, index) => {
|
{files.map((file, index) => {
|
||||||
const fileName = getDisplayName(file, type, index);
|
const fileName = getDisplayName(file, type, index);
|
||||||
const fileKey = getFileKey(file);
|
const fileKey = getFileKey(file);
|
||||||
const canView = isFileResource(file);
|
const canView = isFileResource(file);
|
||||||
|
const isDownloading = fileKey === downloadingKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -189,20 +367,13 @@ const AppDetail: React.FC = () => {
|
|||||||
className={styles.fileItem}
|
className={styles.fileItem}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (fileKey) {
|
if (fileKey) {
|
||||||
setSelectedFile({ type, fileKey, name: fileName });
|
handleFileClick(type, fileKey, fileName);
|
||||||
setActiveTab("code");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={styles.fileInfo}>
|
<div className={styles.fileInfo}>
|
||||||
<FileText size={16} />
|
<span className={styles.fileIcon}>{getFileTypeIcon(type)}</span>
|
||||||
<div>
|
<span className={styles.fileName}>{fileName}</span>
|
||||||
<div className={styles.fileName}>{fileName}</div>
|
|
||||||
<div className={styles.fileType}>
|
|
||||||
{type.toUpperCase()} ·{" "}
|
|
||||||
{t("fileUpload")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{canView && (
|
{canView && (
|
||||||
<Space>
|
<Space>
|
||||||
@@ -212,13 +383,25 @@ const AppDetail: React.FC = () => {
|
|||||||
icon={<Code size={16} />}
|
icon={<Code size={16} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedFile({ type, fileKey, name: fileName });
|
if (fileKey) {
|
||||||
setActiveTab("code");
|
handleFileClick(type, fileKey, fileName);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("view")}
|
{t("view")}
|
||||||
</Button>
|
</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" })}
|
{t("download", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@@ -239,96 +439,67 @@ const AppDetail: React.FC = () => {
|
|||||||
<Tag color="blue">{currentApp.appId}</Tag>
|
<Tag color="blue">{currentApp.appId}</Tag>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Button icon={<History size={16} />}>{t("versionHistory", { ns: "common" })}</Button>
|
<Button icon={<History size={16} />}>
|
||||||
<Button type="primary" icon={<Download size={16} />}>
|
{t("versionHistory", { ns: "common" })}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Download size={16} />}
|
||||||
|
loading={downloadingAll}
|
||||||
|
onClick={handleDownloadAll}
|
||||||
|
>
|
||||||
{t("downloadAll")}
|
{t("downloadAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Tabs
|
{viewMode === "list" ? (
|
||||||
activeKey={activeTab}
|
<>
|
||||||
onChange={setActiveTab}
|
{renderFileSection(
|
||||||
items={[
|
t("pcJs"),
|
||||||
{
|
<Monitor size={14} />,
|
||||||
key: "info",
|
currentApp.customization?.desktop?.js,
|
||||||
label: t("basicInfo"),
|
"js",
|
||||||
children: (
|
)}
|
||||||
<Descriptions column={2} bordered size="small">
|
{renderFileSection(
|
||||||
<Descriptions.Item label={t("appId")}>
|
t("pcCss"),
|
||||||
{currentApp.appId}
|
<Monitor size={14} />,
|
||||||
</Descriptions.Item>
|
currentApp.customization?.desktop?.css,
|
||||||
<Descriptions.Item label={t("appCode")}>
|
"css",
|
||||||
{currentApp.code || "-"}
|
)}
|
||||||
</Descriptions.Item>
|
{renderFileSection(
|
||||||
<Descriptions.Item label={t("createdAt")}>
|
t("mobileJs"),
|
||||||
{currentApp.createdAt}
|
<Smartphone size={14} />,
|
||||||
</Descriptions.Item>
|
currentApp.customization?.mobile?.js,
|
||||||
<Descriptions.Item label={t("creator")}>
|
"js",
|
||||||
{currentApp.creator?.name}
|
)}
|
||||||
</Descriptions.Item>
|
{renderFileSection(
|
||||||
<Descriptions.Item label={t("modifiedAt")}>
|
t("mobileCss"),
|
||||||
{currentApp.modifiedAt}
|
<Smartphone size={14} />,
|
||||||
</Descriptions.Item>
|
currentApp.customization?.mobile?.css,
|
||||||
<Descriptions.Item label={t("modifier")}>
|
"css",
|
||||||
{currentApp.modifier?.name}
|
)}
|
||||||
</Descriptions.Item>
|
</>
|
||||||
<Descriptions.Item label={t("spaceId")} span={2}>
|
) : (
|
||||||
{currentApp.spaceId || "-"}
|
<div className={styles.codeView}>
|
||||||
</Descriptions.Item>
|
<Button
|
||||||
</Descriptions>
|
type="text"
|
||||||
),
|
icon={<ArrowLeft size={16} />}
|
||||||
},
|
onClick={handleBackToList}
|
||||||
{
|
className={styles.backButton}
|
||||||
key: "pc-js",
|
>
|
||||||
label: t("pcJs"),
|
{t("backToList")}
|
||||||
children: renderFileList(
|
</Button>
|
||||||
currentApp.customization?.desktop?.js,
|
{selectedFile && (
|
||||||
"js",
|
<CodeViewer
|
||||||
),
|
fileKey={selectedFile.fileKey}
|
||||||
},
|
fileName={selectedFile.name}
|
||||||
{
|
fileType={selectedFile.type}
|
||||||
key: "pc-css",
|
/>
|
||||||
label: t("pcCss"),
|
)}
|
||||||
children: renderFileList(
|
</div>
|
||||||
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 ? (
|
|
||||||
<CodeViewer
|
|
||||||
fileKey={selectedFile.fileKey}
|
|
||||||
fileName={selectedFile.name}
|
|
||||||
fileType={selectedFile.type}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={styles.emptySection}>
|
|
||||||
{t("selectFileToView")}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button, Form, Modal } from "@lobehub/ui";
|
import { Button, Form } from "@lobehub/ui";
|
||||||
import { Input, message } from "antd";
|
import { Input, message, Modal } from "antd";
|
||||||
import { createStyles } from "antd-style";
|
import { createStyles } from "antd-style";
|
||||||
import { useDomainStore } from "@renderer/stores";
|
import { useDomainStore } from "@renderer/stores";
|
||||||
import type { CreateDomainParams, UpdateDomainParams } from "@shared/types/ipc";
|
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 }) => ({
|
const useStyles = createStyles(({ token, css }) => ({
|
||||||
form: css`
|
form: css`
|
||||||
.ant-form-item {
|
.ant-form-item {
|
||||||
margin-bottom: ${token.marginMD}px;
|
margin-bottom: ${token.marginSM}px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
passwordHint: css`
|
passwordHint: css`
|
||||||
|
|||||||
@@ -34,5 +34,6 @@
|
|||||||
"unpin": "Unpin",
|
"unpin": "Unpin",
|
||||||
"pinApp": "Pin App",
|
"pinApp": "Pin App",
|
||||||
"selectDomainFirst": "Please select a Domain first",
|
"selectDomainFirst": "Please select a Domain first",
|
||||||
"loadAppsFailed": "Failed to load apps"
|
"loadAppsFailed": "Failed to load apps",
|
||||||
|
"backToList": "Back to List"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,8 @@
|
|||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
"deployFiles": "Deploy Files",
|
"deployFiles": "Deploy Files",
|
||||||
"versionHistory": "Version History",
|
"versionHistory": "Version History",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"downloadSuccess": "Download successful",
|
||||||
|
"downloadFailed": "Download failed",
|
||||||
|
"downloadAllSuccess": "Downloaded to: {{path}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,6 @@
|
|||||||
"unpin": "ピン留め解除",
|
"unpin": "ピン留め解除",
|
||||||
"pinApp": "アプリをピン留め",
|
"pinApp": "アプリをピン留め",
|
||||||
"selectDomainFirst": "最初にドメインを選択してください",
|
"selectDomainFirst": "最初にドメインを選択してください",
|
||||||
"loadAppsFailed": "アプリの読み込みに失敗しました"
|
"loadAppsFailed": "アプリの読み込みに失敗しました",
|
||||||
|
"backToList": "リストに戻る"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,8 @@
|
|||||||
"selectAll": "すべて選択",
|
"selectAll": "すべて選択",
|
||||||
"deployFiles": "ファイルをデプロイ",
|
"deployFiles": "ファイルをデプロイ",
|
||||||
"versionHistory": "バージョン履歴",
|
"versionHistory": "バージョン履歴",
|
||||||
"settings": "設定"
|
"settings": "設定",
|
||||||
|
"downloadSuccess": "ダウンロード成功",
|
||||||
|
"downloadFailed": "ダウンロード失敗",
|
||||||
|
"downloadAllSuccess": "ダウンロード先: {{path}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,6 @@
|
|||||||
"unpin": "取消置顶",
|
"unpin": "取消置顶",
|
||||||
"pinApp": "置顶应用",
|
"pinApp": "置顶应用",
|
||||||
"selectDomainFirst": "请先选择一个 Domain",
|
"selectDomainFirst": "请先选择一个 Domain",
|
||||||
"loadAppsFailed": "加载应用失败"
|
"loadAppsFailed": "加载应用失败",
|
||||||
|
"backToList": "返回列表"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,8 @@
|
|||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
"deployFiles": "部署文件",
|
"deployFiles": "部署文件",
|
||||||
"versionHistory": "版本历史",
|
"versionHistory": "版本历史",
|
||||||
"settings": "设置"
|
"settings": "设置",
|
||||||
|
"downloadSuccess": "下载成功",
|
||||||
|
"downloadFailed": "下载失败",
|
||||||
|
"downloadAllSuccess": "已下载到: {{path}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,16 @@ export interface SetLocaleParams {
|
|||||||
locale: import("./locale").LocaleCode;
|
locale: import("./locale").LocaleCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Dialog IPC Types ====================
|
||||||
|
|
||||||
|
export interface ShowSaveDialogParams {
|
||||||
|
defaultPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveFileContentParams {
|
||||||
|
filePath: string;
|
||||||
|
content: string; // Base64 encoded
|
||||||
|
}
|
||||||
// ==================== IPC API Interface ====================
|
// ==================== IPC API Interface ====================
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
@@ -146,4 +156,8 @@ export interface ElectronAPI {
|
|||||||
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>;
|
||||||
|
|
||||||
|
// Dialog
|
||||||
|
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
||||||
|
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user