fix load app, download, upgrade
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -17,7 +17,7 @@
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@kintone/rest-api-client": "^6.1.2",
|
||||
"@lobehub/ui": "^5.5.0",
|
||||
"@lobehub/ui": "^5.5.1",
|
||||
"@uiw/react-codemirror": "^4.23.0",
|
||||
"antd": "^6.1.0",
|
||||
"antd-style": "^4.1.0",
|
||||
@@ -2581,9 +2581,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lobehub/ui": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@lobehub/ui/-/ui-5.5.0.tgz",
|
||||
"integrity": "sha512-wVAAl13cYxtFSiJcKPxmQuiKA6ZqHenXDPV7h1vadMjlhTp71PV4ZhtAJffAv7Mni4EjyJL4B6+pEX+nqlxeoQ==",
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/@lobehub/ui/-/ui-5.5.1.tgz",
|
||||
"integrity": "sha512-rAZ46Fc633EDgY0eBDEIifmLpSNUr5hHJIL6ra6q7nL0KiCSuIbiQmszA5QyzETKMvF1WW6EbQk4JOyq2E4kyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ant-design/cssinjs": "^2.0.3",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@kintone/rest-api-client": "^6.1.2",
|
||||
"@lobehub/ui": "^5.5.0",
|
||||
"@lobehub/ui": "^5.5.1",
|
||||
"@uiw/react-codemirror": "^4.23.0",
|
||||
"antd": "^6.1.0",
|
||||
"antd-style": "^4.1.0",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* Based on REQUIREMENTS.md:228-268
|
||||
*/
|
||||
|
||||
import { ipcMain, dialog } from "electron";
|
||||
import { ipcMain, dialog, app } from "electron";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
saveDomain,
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
GetVersionsParams,
|
||||
RollbackParams,
|
||||
SetLocaleParams,
|
||||
CheckUpdateResult,
|
||||
} from "@shared/types/ipc";
|
||||
import type { LocaleCode } from "@shared/types/locale";
|
||||
import type {
|
||||
@@ -539,6 +541,69 @@ function registerSetLocale(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== App Version & Update IPC Handlers ====================
|
||||
|
||||
/**
|
||||
* Get the current app version
|
||||
*/
|
||||
function registerGetAppVersion(): void {
|
||||
handle<void, string>("getAppVersion", async () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for app updates
|
||||
*/
|
||||
function registerCheckForUpdates(): void {
|
||||
handle<void, CheckUpdateResult>("checkForUpdates", async () => {
|
||||
try {
|
||||
// In development, autoUpdater.checkForUpdates will throw an error
|
||||
// because there's no update server configured
|
||||
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
|
||||
// Return mock result for development
|
||||
return {
|
||||
hasUpdate: false,
|
||||
updateInfo: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
? {
|
||||
version: result.updateInfo.version,
|
||||
releaseDate: result.updateInfo.releaseDate,
|
||||
releaseNotes: result.releaseNotes as string | undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate: false,
|
||||
updateInfo: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
// If update check fails, return no update available
|
||||
console.error("Update check failed:", error);
|
||||
return {
|
||||
hasUpdate: false,
|
||||
updateInfo: undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Register All Handlers ====================
|
||||
|
||||
/**
|
||||
@@ -573,6 +638,10 @@ export function registerIpcHandlers(): void {
|
||||
registerGetLocale();
|
||||
registerSetLocale();
|
||||
|
||||
// App Version & Update
|
||||
registerGetAppVersion();
|
||||
registerCheckForUpdates();
|
||||
|
||||
// Dialog
|
||||
registerShowSaveDialog();
|
||||
registerSaveFileContent();
|
||||
|
||||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
@@ -16,6 +16,7 @@ import type {
|
||||
SetLocaleParams,
|
||||
ShowSaveDialogParams,
|
||||
SaveFileContentParams,
|
||||
CheckUpdateResult,
|
||||
} from "@shared/types/ipc";
|
||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
||||
import type {
|
||||
@@ -70,6 +71,10 @@ export interface SelfAPI {
|
||||
getLocale: () => Promise<Result<LocaleCode>>;
|
||||
setLocale: (params: SetLocaleParams) => Promise<Result<void>>;
|
||||
|
||||
// ==================== App Version & Update ====================
|
||||
getAppVersion: () => Promise<Result<string>>;
|
||||
checkForUpdates: () => Promise<Result<CheckUpdateResult>>;
|
||||
|
||||
// ==================== Dialog ====================
|
||||
showSaveDialog: (params: ShowSaveDialogParams) => Promise<Result<string | null>>;
|
||||
saveFileContent: (params: SaveFileContentParams) => Promise<Result<void>>;
|
||||
|
||||
@@ -34,10 +34,13 @@ const api: SelfAPI = {
|
||||
getLocale: () => ipcRenderer.invoke("getLocale"),
|
||||
setLocale: (params) => ipcRenderer.invoke("setLocale", params),
|
||||
|
||||
// App Version & Update
|
||||
getAppVersion: () => ipcRenderer.invoke("getAppVersion"),
|
||||
checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"),
|
||||
|
||||
// Dialog
|
||||
showSaveDialog: (params) => ipcRenderer.invoke("showSaveDialog", params),
|
||||
saveFileContent: (params) => ipcRenderer.invoke("saveFileContent", params),
|
||||
|
||||
};
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -4,15 +4,13 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input, Spin, Typography, Space } from "antd";
|
||||
import { Button, Tooltip, Empty, Select } from "@lobehub/ui";
|
||||
import { Spin, Typography, Space } from "antd";
|
||||
import { Button, Tooltip, Empty, Select, Input } from "@lobehub/ui";
|
||||
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
LayoutGrid,
|
||||
Pin,
|
||||
ArrowUpDown,
|
||||
ArrowDownUp,
|
||||
} from "lucide-react";
|
||||
@@ -21,9 +19,9 @@ import { useAppStore } from "@renderer/stores";
|
||||
import { useDomainStore } from "@renderer/stores";
|
||||
import { useUIStore } from "@renderer/stores";
|
||||
import type { AppDetail } from "@shared/types/kintone";
|
||||
import AppListItem from "./AppListItem";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
height: 100%;
|
||||
@@ -157,74 +155,6 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
`,
|
||||
}));
|
||||
|
||||
interface AppListItemProps {
|
||||
app: AppDetail;
|
||||
isActive: boolean;
|
||||
isPinned: boolean;
|
||||
onItemClick: (app: AppDetail) => void;
|
||||
onPinToggle: (e: React.MouseEvent, appId: string) => void;
|
||||
styles: ReturnType<typeof useStyles>["styles"];
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const AppListItem: React.FC<AppListItemProps> = ({
|
||||
app,
|
||||
isActive,
|
||||
isPinned,
|
||||
onItemClick,
|
||||
onPinToggle,
|
||||
styles,
|
||||
t,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
// Pin overlay is visible when:
|
||||
// 1. Item is pinned (always show)
|
||||
// 2. Item is hovered (show for unpinned items)
|
||||
const showPinOverlay = isPinned || isHovered;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className={`${styles.listItemMotion} ${isActive ? styles.listItemActive : ""} ${isPinned ? styles.listItemPinned : ""}`}
|
||||
onClick={() => onItemClick(app)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
>
|
||||
<div className={styles.appInfoWrapper}>
|
||||
<div className={styles.iconWrapper}>
|
||||
{/* Pin overlay - visible on hover for unpinned, always visible for pinned */}
|
||||
<div
|
||||
className={`${styles.pinOverlay} ${showPinOverlay ? styles.pinOverlayVisible : ""}`}
|
||||
onClick={(e) => onPinToggle(e, app.appId)}
|
||||
>
|
||||
<Tooltip title={isPinned ? t("unpin") : t("pinApp")}>
|
||||
<span
|
||||
className={`${styles.pinButton} ${isPinned ? styles.pinButtonPinned : ""}`}
|
||||
>
|
||||
{isPinned ? (
|
||||
<Pin size={16} className="fill-current" />
|
||||
) : (
|
||||
<Pin size={16} />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* App icon - hidden when pin overlay is visible */}
|
||||
{!showPinOverlay && <LayoutGrid size={16} style={{ color: "#1890ff" }} />}
|
||||
</div>
|
||||
<Tooltip title={app.name}>
|
||||
<span className={styles.appName}>{app.name}</span>
|
||||
</Tooltip>
|
||||
<Text code className={styles.appId}>
|
||||
ID: {app.appId}
|
||||
</Text>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppList: React.FC = () => {
|
||||
const { t } = useTranslation("app");
|
||||
const { styles } = useStyles();
|
||||
|
||||
166
src/renderer/src/components/AppList/AppListItem.tsx
Normal file
166
src/renderer/src/components/AppList/AppListItem.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* AppListItem Component
|
||||
* Individual app item in the app list with pinning support
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Typography } from "antd";
|
||||
import { Tooltip } from "@lobehub/ui";
|
||||
import { Pin, LayoutGrid } from "lucide-react";
|
||||
import type { AppDetail } from "@shared/types/kintone";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Styles for AppListItem - defined inline to be self-contained
|
||||
const listItemStyles = {
|
||||
listItemMotion: {
|
||||
cursor: "pointer",
|
||||
padding: "8px 16px !important",
|
||||
borderBottom:
|
||||
"1px solid var(--ant-color-border-secondary, #f0f0f0) !important",
|
||||
position: "relative" as const,
|
||||
},
|
||||
listItemActive: {
|
||||
background: "var(--ant-color-primary-bg-hover, #e6f7ff) !important",
|
||||
borderLeft: "3px solid var(--ant-color-primary, #1890ff) !important",
|
||||
},
|
||||
listItemPinned: {
|
||||
background: "var(--ant-color-warning-bg, #fffbe6) !important",
|
||||
},
|
||||
appInfoWrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
position: "relative" as const,
|
||||
},
|
||||
iconWrapper: {
|
||||
position: "relative" as const,
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
pinOverlay: {
|
||||
position: "absolute" as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: 0,
|
||||
transition: "opacity 0.15s ease",
|
||||
zIndex: 1,
|
||||
},
|
||||
appName: {
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
},
|
||||
appId: {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
color: "var(--ant-color-text-secondary, #8c8c8c)",
|
||||
flexShrink: 0,
|
||||
},
|
||||
pinButton: {
|
||||
color: "var(--ant-color-text-tertiary, #bfbfbf)",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
};
|
||||
|
||||
export interface AppListItemProps {
|
||||
app: AppDetail;
|
||||
isActive: boolean;
|
||||
isPinned: boolean;
|
||||
onItemClick: (app: AppDetail) => void;
|
||||
onPinToggle: (e: React.MouseEvent, appId: string) => void;
|
||||
t: (key: string) => string;
|
||||
styles: {
|
||||
listItemMotion: string;
|
||||
listItemActive: string;
|
||||
listItemPinned: string;
|
||||
appInfoWrapper: string;
|
||||
iconWrapper: string;
|
||||
pinOverlay: string;
|
||||
pinOverlayVisible: string;
|
||||
pinButton: string;
|
||||
pinButtonPinned: string;
|
||||
appName: string;
|
||||
appId: string;
|
||||
};
|
||||
}
|
||||
|
||||
const AppListItem: React.FC<AppListItemProps> = ({
|
||||
app,
|
||||
isActive,
|
||||
isPinned,
|
||||
onItemClick,
|
||||
onPinToggle,
|
||||
styles,
|
||||
t,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
// Pin overlay is visible when:
|
||||
// 1. Item is pinned (always show)
|
||||
// 2. Item is hovered (show for unpinned items)
|
||||
const showPinOverlay = isPinned || isHovered;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
className={`${styles.listItemMotion} ${isActive ? styles.listItemActive : ""} ${isPinned ? styles.listItemPinned : ""}`}
|
||||
onClick={() => onItemClick(app)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
>
|
||||
<div className={styles.appInfoWrapper}>
|
||||
<div className={styles.iconWrapper}>
|
||||
{/* Pin overlay - visible on hover for unpinned, always visible for pinned */}
|
||||
<div
|
||||
className={`${styles.pinOverlay} ${showPinOverlay ? styles.pinOverlayVisible : ""}`}
|
||||
onClick={(e) => onPinToggle(e, app.appId)}
|
||||
>
|
||||
<Tooltip title={isPinned ? t("unpin") : t("pinApp")}>
|
||||
<span
|
||||
className={`${styles.pinButton} ${isPinned ? styles.pinButtonPinned : ""}`}
|
||||
>
|
||||
{isPinned ? (
|
||||
<Pin size={16} className="fill-current" />
|
||||
) : (
|
||||
<Pin size={16} />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* App icon - hidden when pin overlay is visible */}
|
||||
{!showPinOverlay && (
|
||||
<LayoutGrid size={16} style={{ color: "#1890ff" }} />
|
||||
)}
|
||||
</div>
|
||||
<Tooltip title={app.name}>
|
||||
<span className={styles.appName}>{app.name}</span>
|
||||
</Tooltip>
|
||||
<Text code className={styles.appId}>
|
||||
ID: {app.appId}
|
||||
</Text>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppListItem;
|
||||
@@ -7,10 +7,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spin, Alert, Space, message } from "antd";
|
||||
import { Button, Empty } from "@lobehub/ui";
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { Copy, Download } from "lucide-react";
|
||||
import { createStyles } from "antd-style";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
@@ -72,6 +69,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [content, setContent] = React.useState<string>("");
|
||||
const [language, setLanguage] = React.useState<"js" | "css">(fileType);
|
||||
const [downloading, setDownloading] = React.useState(false);
|
||||
|
||||
// Load file content
|
||||
React.useEffect(() => {
|
||||
@@ -128,17 +126,62 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
|
||||
message.success(t("copiedToClipboard"));
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
message.success(t("downloadSuccess"));
|
||||
const handleDownload = async () => {
|
||||
if (!currentDomain || downloading) return;
|
||||
|
||||
// Check if fileName already has extension
|
||||
const hasExt = /\.(js|css)$/i.test(fileName);
|
||||
const finalFileName = hasExt ? fileName : `${fileName}.${fileType}`;
|
||||
|
||||
setDownloading(true);
|
||||
|
||||
try {
|
||||
// 1. Show save dialog
|
||||
const dialogResult = await window.api.showSaveDialog({
|
||||
defaultPath: finalFileName,
|
||||
});
|
||||
|
||||
if (!dialogResult.success || !dialogResult.data) {
|
||||
// User cancelled
|
||||
setDownloading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const savePath = dialogResult.data;
|
||||
|
||||
// 2. Get current file content (already decoded in state)
|
||||
const encoder = new TextEncoder();
|
||||
const uint8Array = encoder.encode(content);
|
||||
// Convert to base64 for IPC transfer
|
||||
let base64 = "";
|
||||
const chunkSize = 8192;
|
||||
for (let i = 0; i < uint8Array.length; i += chunkSize) {
|
||||
base64 += String.fromCharCode.apply(
|
||||
null,
|
||||
Array.from(uint8Array.slice(i, i + chunkSize)),
|
||||
);
|
||||
}
|
||||
const base64Content = btoa(base64);
|
||||
|
||||
// 3. Save to selected path
|
||||
const saveResult = await window.api.saveFileContent({
|
||||
filePath: savePath,
|
||||
content: base64Content,
|
||||
});
|
||||
|
||||
if (saveResult.success) {
|
||||
message.success(t("downloadSuccess"));
|
||||
} else {
|
||||
message.error(
|
||||
saveResult.error || t("downloadFailed", { ns: "common" }),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
message.error(t("downloadFailed", { ns: "common" }));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -170,9 +213,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
|
||||
if (!content) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Empty
|
||||
description={t("fileEmpty")}
|
||||
/>
|
||||
<Empty description={t("fileEmpty")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -194,6 +235,7 @@ const CodeViewer: React.FC<CodeViewerProps> = ({
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Download size={16} />}
|
||||
loading={downloading}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{t("download", { ns: "common" })}
|
||||
|
||||
@@ -100,6 +100,9 @@ const DomainList: React.FC<DomainListProps> = ({ onEdit }) => {
|
||||
useDomainStore();
|
||||
|
||||
const handleSelect = (domain: Domain) => {
|
||||
if (currentDomain?.id === domain.id) {
|
||||
return;
|
||||
}
|
||||
switchDomain(domain);
|
||||
};
|
||||
|
||||
|
||||
@@ -242,7 +242,6 @@ const DomainManager: React.FC<DomainManagerProps> = ({
|
||||
<h2 className={styles.title}>{t("domainManagement")}</h2>
|
||||
<div className={styles.actions}>
|
||||
<Button type="primary" icon={<Plus size={16} />} onClick={handleAdd}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography, Radio, Divider } from "antd";
|
||||
import { Globe } from "lucide-react";
|
||||
import { Typography, Radio, Divider, Space, message } from "antd";
|
||||
import { Globe, Info, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@lobehub/ui";
|
||||
|
||||
import { createStyles } from "antd-style";
|
||||
import { useLocaleStore } from "@renderer/stores/localeStore";
|
||||
@@ -42,6 +43,28 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
versionInfo: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${token.marginSM}px;
|
||||
color: ${token.colorTextSecondary};
|
||||
font-size: 14px;
|
||||
`,
|
||||
versionNumber: css`
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
`,
|
||||
updateInfo: css`
|
||||
margin-top: ${token.marginSM}px;
|
||||
padding: ${token.paddingSM}px ${token.paddingMD}px;
|
||||
background: ${token.colorInfoBg};
|
||||
border-radius: ${token.borderRadius}px;
|
||||
font-size: 13px;
|
||||
`,
|
||||
updateAvailable: css`
|
||||
background: ${token.colorSuccessBg};
|
||||
color: ${token.colorSuccessText};
|
||||
`,
|
||||
}));
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
@@ -50,6 +73,25 @@ const Settings: React.FC = () => {
|
||||
const { locale, setLocale } = useLocaleStore();
|
||||
const i18n = useTranslation().i18n;
|
||||
|
||||
// Version and update state
|
||||
const [appVersion, setAppVersion] = React.useState<string>("");
|
||||
const [checkingUpdate, setCheckingUpdate] = React.useState(false);
|
||||
const [updateInfo, setUpdateInfo] = React.useState<{
|
||||
hasUpdate: boolean;
|
||||
version?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Load app version on mount
|
||||
React.useEffect(() => {
|
||||
const loadVersion = async () => {
|
||||
const result = await window.api.getAppVersion();
|
||||
if (result.success) {
|
||||
setAppVersion(result.data);
|
||||
}
|
||||
};
|
||||
loadVersion();
|
||||
}, []);
|
||||
|
||||
const handleLocaleChange = async (newLocale: LocaleCode) => {
|
||||
setLocale(newLocale);
|
||||
i18n.changeLanguage(newLocale);
|
||||
@@ -57,6 +99,33 @@ const Settings: React.FC = () => {
|
||||
await window.api.setLocale({ locale: newLocale });
|
||||
};
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setCheckingUpdate(true);
|
||||
setUpdateInfo(null);
|
||||
|
||||
try {
|
||||
const result = await window.api.checkForUpdates();
|
||||
if (result.success) {
|
||||
setUpdateInfo({
|
||||
hasUpdate: result.data.hasUpdate,
|
||||
version: result.data.updateInfo?.version,
|
||||
});
|
||||
} else {
|
||||
message.error(t("checkUpdateFailed"));
|
||||
}
|
||||
} catch {
|
||||
message.error(t("checkUpdateFailed"));
|
||||
} finally {
|
||||
setCheckingUpdate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenReleasePage = () => {
|
||||
// Open GitHub releases page (or configured update URL)
|
||||
const releaseUrl = "https://github.com/example/kintone-customize-manager/releases";
|
||||
window.electron?.shell?.openExternal?.(releaseUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Language Section */}
|
||||
@@ -84,7 +153,58 @@ const Settings: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Future settings sections can be added here */}
|
||||
{/* About Section */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<Info size={16} />
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{t("about")}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<div className={styles.versionInfo}>
|
||||
<span>{t("version")}:</span>
|
||||
<span className={styles.versionNumber}>{appVersion || "-"}</span>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%", marginTop: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ExternalLink size={14} />}
|
||||
onClick={handleCheckUpdate}
|
||||
loading={checkingUpdate}
|
||||
>
|
||||
{t("checkUpdate")}
|
||||
</Button>
|
||||
|
||||
{updateInfo && (
|
||||
<div
|
||||
className={`${styles.updateInfo} ${
|
||||
updateInfo.hasUpdate ? styles.updateAvailable : ""
|
||||
}`}
|
||||
>
|
||||
{updateInfo.hasUpdate ? (
|
||||
<Space direction="vertical" size={4}>
|
||||
<span>
|
||||
{t("updateAvailable")}: v{updateInfo.version}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ExternalLink size={12} />}
|
||||
onClick={handleOpenReleasePage}
|
||||
style={{ padding: 0, height: "auto" }}
|
||||
>
|
||||
{t("downloadUpdate")}
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<span>{t("noUpdates")}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
"checkUpdate": "Check for Updates",
|
||||
"checkUpdateFailed": "Failed to check for updates",
|
||||
"noUpdates": "You're up to date",
|
||||
"updateAvailable": "Update available"
|
||||
"updateAvailable": "Update available",
|
||||
"downloadUpdate": "Download Update"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"about": "アプリについて",
|
||||
"version": "バージョン",
|
||||
"checkUpdate": "更新を確認",
|
||||
"checkUpdateFailed": "更新の確認に失敗しました",
|
||||
"noUpdates": "最新バージョンです",
|
||||
"updateAvailable": "新しいバージョンが利用可能です"
|
||||
"updateAvailable": "新しいバージョンが利用可能です",
|
||||
"downloadUpdate": "更新をダウンロード"
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"about": "关于",
|
||||
"version": "版本",
|
||||
"checkUpdate": "检查更新",
|
||||
"checkUpdateFailed": "检查更新失败",
|
||||
"noUpdates": "已是最新版本",
|
||||
"updateAvailable": "有新版本可用"
|
||||
"updateAvailable": "有新版本可用",
|
||||
"downloadUpdate": "下载更新"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { useAppStore } from "./appStore";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { Domain, DomainWithStatus } from "@shared/types/domain";
|
||||
import type { ConnectionStatus } from "@shared/types/domain";
|
||||
@@ -217,8 +218,17 @@ export const useDomainStore = create<DomainState>()(
|
||||
},
|
||||
|
||||
switchDomain: async (domain: Domain) => {
|
||||
const appStore = useAppStore.getState();
|
||||
|
||||
// 1. reset
|
||||
appStore.setLoading(true);
|
||||
appStore.setApps([]);
|
||||
appStore.setSelectedAppId(null);
|
||||
|
||||
// 2. Set current domain
|
||||
set({ currentDomain: domain });
|
||||
// Test connection after switching
|
||||
|
||||
// 3. Test connection after switching
|
||||
const status = await get().testConnection(domain.id);
|
||||
if (status) {
|
||||
set({
|
||||
@@ -228,6 +238,19 @@ export const useDomainStore = create<DomainState>()(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Auto-load apps for the new domain
|
||||
try {
|
||||
const result = await window.api.getApps({ domainId: domain.id });
|
||||
if (result.success) {
|
||||
appStore.setApps(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail - user can manually reload
|
||||
console.error("Failed to auto-load apps:", error);
|
||||
} finally {
|
||||
appStore.setLoading(false);
|
||||
}
|
||||
},
|
||||
|
||||
testConnection: async (id: string) => {
|
||||
|
||||
@@ -125,6 +125,20 @@ export interface ShowSaveDialogParams {
|
||||
export interface SaveFileContentParams {
|
||||
filePath: string;
|
||||
content: string; // Base64 encoded
|
||||
// ==================== App Version & Update IPC Types ====================
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
releaseDate?: string;
|
||||
releaseNotes?: string;
|
||||
}
|
||||
|
||||
export interface CheckUpdateResult {
|
||||
hasUpdate: boolean;
|
||||
updateInfo?: UpdateInfo;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
// ==================== IPC API Interface ====================
|
||||
|
||||
|
||||
Reference in New Issue
Block a user