feat: update localization files and improve system version management

- Changed output path for i18next configuration in admin and user apps to "public/assets/locales/{{language}}/{{namespace}}.json".
- Added new translation keys in English and Chinese for admin and user updates in tool.json files.
- Refactored SystemVersionCard component to utilize new service version checking and updating logic.
- Introduced basic service version checking and updating functions in the gateway service.
- Added typings for new API endpoints related to service version management.
- Updated Vite configuration to rewrite API paths.
- Added TypeScript error handling in various service files.
This commit is contained in:
web 2025-12-01 03:00:47 -08:00
parent 5f3354e948
commit 3cf6a5cfb4
44 changed files with 427 additions and 143 deletions

View File

@ -18,6 +18,6 @@ export default defineConfig({
"src/**/*.{js,jsx,ts,tsx}",
"../../packages/ui/src/**/*.{js,jsx,ts,tsx}",
], // Source files to scan
output: "public/locales/{{language}}/{{namespace}}.json", // Output path template
output: "public/assets/locales/{{language}}/{{namespace}}.json", // Output path template
},
});

View File

@ -1,8 +1,9 @@
{
"adminUpdateSuccess": "Admin updated successfully",
"cancel": "Cancel",
"confirmReboot": "Confirm Reboot",
"confirmSystemReboot": "Confirm System Reboot",
"newVersionAvailable": "New Version Available",
"confirmUpdate": "Confirm Update",
"rebootDescription": "Are you sure you want to reboot the system? This action cannot be undone.",
"rebooting": "Rebooting...",
"refreshLogs": "Refresh Logs",
@ -10,5 +11,11 @@
"systemLogs": "System Logs",
"systemReboot": "System Reboot",
"systemServices": "System Services",
"update": "Update",
"updateFailed": "Update failed",
"updateServerDescription": "Are you sure you want to update the server version from V{{current}} to V{{latest}}?",
"updateSuccess": "Update completed successfully",
"updateWebDescription": "Are you sure you want to update the web version from V{{current}} to V{{latest}}?",
"userUpdateSuccess": "User updated successfully",
"webVersion": "Web Version"
}

View File

@ -1,8 +1,9 @@
{
"adminUpdateSuccess": "管理端更新成功",
"cancel": "取消",
"confirmReboot": "确认重启",
"confirmSystemReboot": "确认系统重启",
"newVersionAvailable": "有新版本可用",
"confirmUpdate": "确认更新",
"rebootDescription": "确定要重启系统吗?此操作无法撤销。",
"rebooting": "重启中...",
"refreshLogs": "刷新日志",
@ -10,5 +11,11 @@
"systemLogs": "系统日志",
"systemReboot": "系统重启",
"systemServices": "系统服务",
"update": "更新",
"updateFailed": "更新失败",
"updateServerDescription": "确定要将服务器版本从 V{{current}} 更新到 V{{latest}} 吗?",
"updateSuccess": "更新成功",
"updateWebDescription": "确定要将前端版本从 V{{current}} 更新到 V{{latest}} 吗?",
"userUpdateSuccess": "用户端更新成功",
"webVersion": "前端版本"
}

View File

@ -1,7 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertDialog,
AlertDialogCancel,
@ -21,123 +20,109 @@ import {
CardTitle,
} from "@workspace/ui/components/card";
import { Icon } from "@workspace/ui/composed/icon";
import { getVersion, restartSystem } from "@workspace/ui/services/admin/tool";
import { getModuleConfig } from "@workspace/ui/services/admin/system";
import { restartSystem } from "@workspace/ui/services/admin/tool";
import { basicCheckServiceVersion } from "@workspace/ui/services/gateway/basicCheckServiceVersion";
import { basicUpdateService } from "@workspace/ui/services/gateway/basicUpdateService";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
import { toast } from "sonner";
import packageJson from "../../../../../../package.json";
import SystemLogsDialog from "./system-logs-dialog";
export default function SystemVersionCard() {
const { t } = useTranslation("tool");
const queryClient = useQueryClient();
const [openRestart, setOpenRestart] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [openUpdateWeb, setOpenUpdateWeb] = useState(false);
const [openUpdateServer, setOpenUpdateServer] = useState(false);
const [isUpdatingWeb, setIsUpdatingWeb] = useState(false);
const { data: versionInfo } = useQuery({
queryKey: ["getVersionInfo"],
const { data: moduleConfig } = useQuery({
queryKey: ["getModuleConfig"],
queryFn: async () => {
try {
const [webResponse, serverResponse, systemResponse] = await Promise.all(
[
fetch(
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/frontend/resolved?specifier=latest"
),
fetch(
"https://data.jsdelivr.com/v1/packages/gh/perfect-panel/server/resolved?specifier=latest"
),
getVersion(),
]
);
const webData = webResponse.ok ? await webResponse.json() : null;
const serverData = serverResponse.ok
? await serverResponse.json()
: null;
const systemData = systemResponse.data.data;
const rawVersion = (systemData?.version || "")
.replace(" Develop", "")
.trim();
const timeMatch = rawVersion.match(/\(([^)]+)\)/);
const timestamp = timeMatch ? timeMatch[1] : "";
const versionWithoutTime = rawVersion.replace(/\([^)]*\)/, "").trim();
const isDevelopment = !/^[Vv]?\d+\.\d+\.\d+(-[a-zA-Z]+(\.\d+)?)?$/.test(
versionWithoutTime
);
let displayVersion = versionWithoutTime;
if (
!(
isDevelopment ||
versionWithoutTime.startsWith("V") ||
versionWithoutTime.startsWith("v")
)
) {
displayVersion = `V${versionWithoutTime}`;
}
const lastUpdated = formatDate(new Date(timestamp || Date.now())) || "";
const systemInfo = {
isRelease: !isDevelopment,
version: displayVersion,
lastUpdated,
};
const latestReleases = {
web: webData
? {
version: webData.version,
url: `https://github.com/perfect-panel/frontend/releases/tag/v${webData.version}`,
}
: null,
server: serverData
? {
version: serverData.version,
url: `https://github.com/perfect-panel/server/releases/tag/v${serverData.version}`,
}
: null,
};
const hasNewVersion =
latestReleases.web &&
packageJson.version !== latestReleases.web.version.replace(/^v/, "");
const hasServerNewVersion =
latestReleases.server &&
systemInfo.version &&
systemInfo.version.replace(/^V/, "") !==
latestReleases.server.version.replace(/^v/, "");
return {
systemInfo,
latestReleases,
hasNewVersion,
hasServerNewVersion,
};
} catch (error) {
console.error("Failed to fetch version info:", error);
return {
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
latestReleases: { web: null, server: null },
hasNewVersion: false,
hasServerNewVersion: false,
};
}
const { data } = await getModuleConfig();
return data.data;
},
staleTime: 0,
});
const { data: serverVersionInfo } = useQuery({
queryKey: ["checkServerVersion", moduleConfig?.secret],
queryFn: async () => {
const { data } = await basicCheckServiceVersion({
service_name: moduleConfig!.service_name,
secret: moduleConfig!.secret,
});
return data.data;
},
enabled: !!moduleConfig?.secret,
staleTime: 0,
retry: 1,
retryDelay: 10_000,
initialData: {
systemInfo: { isRelease: true, version: "V1.0.0", lastUpdated: "" },
latestReleases: { web: null, server: null },
hasNewVersion: false,
hasServerNewVersion: false,
});
const { data: webVersionInfo } = useQuery({
queryKey: ["checkWebVersion", moduleConfig?.secret],
queryFn: async () => {
const { data } = await basicCheckServiceVersion({
service_name: "admin",
secret: moduleConfig!.secret,
});
return data.data;
},
enabled: !!moduleConfig?.secret,
staleTime: 0,
retry: 1,
});
const updateServerMutation = useMutation({
mutationFn: async (serviceName: string) => {
await basicUpdateService({
service_name: serviceName,
secret: moduleConfig!.secret,
});
},
onSuccess: () => {
toast.success(t("updateSuccess", "Update completed successfully"));
queryClient.invalidateQueries({ queryKey: ["checkServerVersion"] });
queryClient.invalidateQueries({ queryKey: ["getModuleConfig"] });
setOpenUpdateServer(false);
},
onError: () => {
toast.error(t("updateFailed", "Update failed"));
},
});
const { systemInfo, latestReleases, hasNewVersion, hasServerNewVersion } =
versionInfo;
const handleUpdateWeb = async () => {
if (!moduleConfig?.secret) return;
setIsUpdatingWeb(true);
try {
await basicUpdateService({
service_name: "admin",
secret: moduleConfig.secret,
});
toast.success(t("adminUpdateSuccess", "Admin updated successfully"));
await basicUpdateService({
service_name: "user",
secret: moduleConfig.secret,
});
toast.success(t("userUpdateSuccess", "User updated successfully"));
setOpenUpdateWeb(false);
window.location.reload();
} catch {
toast.error(t("updateFailed", "Update failed"));
} finally {
setIsUpdatingWeb(false);
}
};
const hasServerNewVersion = serverVersionInfo?.has_update ?? false;
const hasWebNewVersion = webVersionInfo?.has_update ?? false;
const isUpdatingServer = updateServerMutation.isPending;
return (
<Card className="gap-0 p-3">
@ -199,27 +184,55 @@ export default function SystemVersionCard() {
</div>
<div className="flex items-center space-x-2">
<Badge>V{packageJson.version}</Badge>
{hasNewVersion && (
<Link
className="flex items-center space-x-1"
rel="noopener noreferrer"
target="_blank"
to={
latestReleases?.web?.url ||
"https://github.com/perfect-panel/frontend/releases"
}
>
<Badge
className="animate-pulse px-2 py-0.5 text-xs"
variant="destructive"
>
{t("newVersionAvailable", "New Version Available")}
<Icon icon="mdi:open-in-new" />
</Badge>
</Link>
{hasWebNewVersion && webVersionInfo && (
<AlertDialog onOpenChange={setOpenUpdateWeb} open={openUpdateWeb}>
<AlertDialogTrigger asChild>
<Button
className="h-6 px-2 text-xs"
disabled={isUpdatingWeb}
size="sm"
variant="outline"
>
<Icon className="mr-1 h-3 w-3" icon="mdi:download" />
{t("update", "Update")} V{webVersionInfo.latest_version}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmUpdate", "Confirm Update")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"updateWebDescription",
"Are you sure you want to update the web version from V{{current}} to V{{latest}}?",
{
current: packageJson.version,
latest: webVersionInfo.latest_version,
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<Button disabled={isUpdatingWeb} onClick={handleUpdateWeb}>
{isUpdatingWeb && (
<Icon
className="mr-2 animate-spin"
icon="mdi:loading"
/>
)}
{t("confirmUpdate", "Confirm Update")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center">
<Icon className="mr-2 h-4 w-4 text-blue-600" icon="mdi:server" />
@ -228,27 +241,67 @@ export default function SystemVersionCard() {
</span>
</div>
<div className="flex items-center space-x-2">
<Badge variant={systemInfo?.isRelease ? "default" : "destructive"}>
{systemInfo?.version || "V1.0.0"}
<Badge>
V
{moduleConfig?.service_version ||
serverVersionInfo?.current_version ||
"1.0.0"}
</Badge>
{hasServerNewVersion && (
<Link
className="flex items-center space-x-1"
rel="noopener noreferrer"
target="_blank"
to={
latestReleases?.server?.url ||
"https://github.com/perfect-panel/server/releases"
}
{hasServerNewVersion && serverVersionInfo && moduleConfig && (
<AlertDialog
onOpenChange={setOpenUpdateServer}
open={openUpdateServer}
>
<Badge
className="animate-pulse px-2 py-0.5 text-xs"
variant="destructive"
>
{t("newVersionAvailable", "New Version Available")}
<Icon icon="mdi:open-in-new" />
</Badge>
</Link>
<AlertDialogTrigger asChild>
<Button
className="h-6 px-2 text-xs"
disabled={isUpdatingServer}
size="sm"
variant="outline"
>
<Icon className="mr-1 h-3 w-3" icon="mdi:download" />
{t("update", "Update")} V{serverVersionInfo.latest_version}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("confirmUpdate", "Confirm Update")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"updateServerDescription",
"Are you sure you want to update the server version from V{{current}} to V{{latest}}?",
{
current:
moduleConfig.service_version ||
serverVersionInfo.current_version,
latest: serverVersionInfo.latest_version,
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("cancel", "Cancel")}
</AlertDialogCancel>
<Button
disabled={isUpdatingServer}
onClick={() =>
updateServerMutation.mutate(moduleConfig.service_name)
}
>
{isUpdatingServer && (
<Icon
className="mr-2 animate-spin"
icon="mdi:loading"
/>
)}
{t("confirmUpdate", "Confirm Update")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>

View File

@ -1,2 +1,3 @@
/// <reference types="@workspace/ui/services/gateway/typings" />
/// <reference types="@workspace/ui/services/admin/typings" />
/// <reference types="@workspace/ui/services/common/typings" />

View File

@ -47,6 +47,7 @@ export default defineConfig({
target: "https://api.ppanel.dev",
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},

View File

@ -18,6 +18,6 @@ export default defineConfig({
"src/**/*.{js,jsx,ts,tsx}",
"../../packages/ui/src/**/*.{js,jsx,ts,tsx}",
], // Source files to scan
output: "public/locales/{{language}}/{{namespace}}.json", // Output path template
output: "public/assets/locales/{{language}}/{{namespace}}.json", // Output path template
},
});

View File

@ -23,6 +23,13 @@ const config = [
"https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/admin.json",
projectName: "admin",
},
{
...baseConfig,
schemaPath:
"https://raw.githubusercontent.com/perfect-panel/ppanel-docs/refs/heads/main/public/swagger/gateway.json",
apiPrefix: "",
projectName: "gateway",
},
];
export default config;

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
// API 更新时间:
// API 唯一标识:

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
// API 更新时间:
// API 唯一标识:

View File

@ -0,0 +1,22 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";
/** Check if there is a new version of the service GET /basic/check/version */
export async function basicCheckServiceVersion(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.basicCheckServiceVersionParams,
options?: { [key: string]: any }
) {
return request<{
code?: number;
data?: API.ServiceVersionResponse;
msg?: string;
}>("/basic/check/version", {
method: "GET",
params: {
...params,
},
...(options || {}),
});
}

View File

@ -0,0 +1,21 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";
/** Send heartbeat to the service GET /basic/heartbeat */
export async function basicHeartbeat(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.basicHeartbeatParams,
options?: { [key: string]: any }
) {
return request<{ code?: number; data?: API.HeartbeatResponse; msg?: string }>(
"/basic/heartbeat",
{
method: "GET",
params: {
...params,
},
...(options || {}),
}
);
}

View File

@ -0,0 +1,22 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";
/** Register a new service to the gateway POST /basic/register */
export async function basicRegisterService(
body: API.RegisterServiceRequest,
options?: { [key: string]: any }
) {
return request<{
code?: number;
data?: API.RegisterServiceResponse;
msg?: string;
}>("/basic/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
});
}

View File

@ -0,0 +1,22 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";
/** Update the service to the latest version POST /basic/update */
export async function basicUpdateService(
body: API.UpdateServiceRequest,
options?: { [key: string]: any }
) {
return request<{
code?: number;
data?: API.UpdateServiceResponse;
msg?: string;
}>("/basic/update", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
});
}

View File

@ -0,0 +1,14 @@
//
/* eslint-disable */
// API 更新时间:
// API 唯一标识:
import * as basicCheckServiceVersion from "./basicCheckServiceVersion";
import * as basicHeartbeat from "./basicHeartbeat";
import * as basicRegisterService from "./basicRegisterService";
import * as basicUpdateService from "./basicUpdateService";
export default {
basicCheckServiceVersion,
basicHeartbeat,
basicRegisterService,
basicUpdateService,
};

View File

@ -0,0 +1,77 @@
declare namespace API {
type basicCheckServiceVersionParams = {
/** 服务名称 */
service_name: string;
/** 通讯密钥 */
secret: string;
};
type basicHeartbeatParams = {
/** 服务名称 */
service_name: string;
/** 通讯密钥 */
secret: string;
};
type CheckServiceVersionRequest = true;
type HeartbeatRequest = true;
type HeartbeatResponse = {
/** 心跳是否成功 */
success: boolean;
};
type RegisterServiceRequest = {
/** 心跳地址 */
heartbeat_url: string;
/** 代理路径 */
proxy_path: string;
/** 服务代码仓库 */
repository: string;
/** 通讯密钥 */
secret: string;
/** 服务名称 */
service_name: string;
/** 服务地址 */
service_url: string;
/** 服务版本 */
service_version: string;
};
type RegisterServiceResponse = {
/** 返回信息 */
message: string;
/** 注册是否成功 */
success: boolean;
};
type ServiceVersionResponse = {
/** 当前版本 */
current_version: string;
/** 是否有更新 */
has_update: boolean;
/** 最新版本 */
latest_version: string;
};
type UpdateServiceRequest = {
/** 通讯密钥 */
secret: string;
/** 服务名称 */
service_name: string;
};
type UpdateServiceResponse = {
/** 是否更新出错 */
error: boolean;
/** 返回信息 */
message: string;
/** 是否正在更新 */
running: boolean;
/** 更新步骤 */
step: number;
/** 更新总步骤 */
total: number;
};
}

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
// API 更新时间:
// API 唯一标识:

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";

View File

@ -1,3 +1,4 @@
//
/* eslint-disable */
import request from "@workspace/ui/lib/request";