feat(ui): new ConsoleLayout

This commit is contained in:
Fu Diwei 2025-07-15 20:03:44 +08:00
parent 3bd2bee321
commit 37ec20e5a1
16 changed files with 212 additions and 101 deletions

View File

@ -142,6 +142,7 @@ export default defineConfig(
...tailwindcssPlugin.configs["recommended-error"].rules,
"better-tailwindcss/enforce-consistent-line-wrapping": "off",
"better-tailwindcss/no-unregistered-classes": "off",
},
settings: {
"better-tailwindcss": {

View File

@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import { IconBook } from "@tabler/icons-react";
import { Typography } from "antd";
import { APP_DOCUMENT_URL } from "@/domain/app";
export type AppDocumentLinkButtonProps = {
className?: string;
style?: React.CSSProperties;
@ -15,9 +17,9 @@ const AppDocumentLinkButton = (props: AppDocumentLinkButtonProps) => {
const { t } = useTranslation();
return (
<Typography.Link className={className} style={style} type="secondary" href="https://docs.certimate.me" target="_blank">
<Typography.Link className={className} style={style} type="secondary" href={APP_DOCUMENT_URL} target="_blank">
<div className="flex items-center justify-center space-x-1">
{showIcon ? <IconBook size={16} /> : <></>}
{showIcon ? <IconBook size="1em" /> : <></>}
<span>{t("common.menu.document")}</span>
</div>
</Typography.Link>

View File

@ -7,14 +7,7 @@ import { IconLanguageEnZh, IconLanguageZhEn } from "@/components/icons";
import { localeNames, localeResources } from "@/i18n";
import { mergeCls } from "@/utils/css";
export type AppLocaleDropdownProps = {
children?: React.ReactNode;
trigger?: DropdownProps["trigger"];
};
const AppLocaleDropdown = (props: AppLocaleDropdownProps) => {
const { children, trigger = ["click"] } = props;
export const useAppLocaleMenuItems = () => {
const { i18n } = useTranslation();
const items: Required<MenuProps>["items"] = Object.keys(i18n.store.data).map((key) => {
@ -30,6 +23,19 @@ const AppLocaleDropdown = (props: AppLocaleDropdownProps) => {
};
});
return items;
};
export type AppLocaleDropdownProps = {
children?: React.ReactNode;
trigger?: DropdownProps["trigger"];
};
const AppLocaleDropdown = (props: AppLocaleDropdownProps) => {
const { children, trigger = ["click"] } = props;
const items = useAppLocaleMenuItems();
return (
<Dropdown menu={{ items }} trigger={trigger}>
{children}
@ -67,7 +73,7 @@ const AppLocaleLinkButton = (props: AppLocaleLinkButtonProps) => {
<AppLocaleDropdown trigger={["click", "hover"]}>
<Typography.Text className={mergeCls("cursor-pointer", className)} style={style} type="secondary">
<div className="flex items-center justify-center space-x-1">
{showIcon ? <AppLocaleIcon size={16} /> : <></>}
{showIcon ? <AppLocaleIcon size="1em" /> : <></>}
<span>{String(localeResources[i18n.language]?.name ?? t("common.menu.locale"))}</span>
</div>
</Typography.Text>

View File

@ -7,26 +7,19 @@ import { Dropdown, type DropdownProps, type MenuProps, Typography } from "antd";
import { useBrowserTheme } from "@/hooks";
import { mergeCls } from "@/utils/css";
export type AppThemeDropdownProps = {
children?: React.ReactNode;
trigger?: DropdownProps["trigger"];
};
const AppThemeDropdown = (props: AppThemeDropdownProps) => {
const { children, trigger = ["click"] } = props;
export const useAppThemeMenuItems = () => {
const { t } = useTranslation();
const { themeMode, setThemeMode } = useBrowserTheme();
const items: Required<MenuProps>["items"] = [
["light", t("common.theme.light"), <IconSun size={16} />],
["dark", t("common.theme.dark"), <IconMoon size={16} />],
["system", t("common.theme.system"), <IconSunMoon size={16} />],
["light", "common.theme.light", <IconSun size="1em" />],
["dark", "common.theme.dark", <IconMoon size="1em" />],
["system", "common.theme.system", <IconSunMoon size="1em" />],
].map(([key, label, icon]) => {
return {
key: key as string,
label: label as string,
label: t(label as string),
icon: icon as React.ReactElement,
onClick: () => {
if (key !== themeMode) {
@ -37,6 +30,19 @@ const AppThemeDropdown = (props: AppThemeDropdownProps) => {
};
});
return items;
};
export type AppThemeDropdownProps = {
children?: React.ReactNode;
trigger?: DropdownProps["trigger"];
};
const AppThemeDropdown = (props: AppThemeDropdownProps) => {
const { children, trigger = ["click"] } = props;
const items = useAppThemeMenuItems();
return (
<Dropdown menu={{ items }} trigger={trigger}>
{children}
@ -69,7 +75,7 @@ const AppThemeLinkButton = (props: AppThemeLinkButtonProps) => {
<AppThemeDropdown trigger={["click", "hover"]}>
<Typography.Text className={mergeCls("cursor-pointer", className)} style={style} type="secondary">
<div className="flex items-center justify-center space-x-1">
{showIcon ? <AppThemeIcon size={16} /> : <></>}
{showIcon ? <AppThemeIcon size="1em" /> : <></>}
<span>{t(`common.theme.${themeMode}`)}</span>
</div>
</Typography.Text>

View File

@ -1,7 +1,7 @@
import { memo } from "react";
import { Badge, Typography } from "antd";
import { version } from "@/domain/version";
import { APP_DOWNLOAD_URL, APP_VERSION } from "@/domain/app";
import { useVersionChecker } from "@/hooks";
export type AppVersionLinkButtonProps = {
@ -10,17 +10,37 @@ export type AppVersionLinkButtonProps = {
};
const AppVersionLinkButton = ({ className, style }: AppVersionLinkButtonProps) => {
return (
<AppVersionBadge>
<Typography.Link className={className} style={style} type="secondary" href={APP_DOWNLOAD_URL} target="_blank">
{APP_VERSION}
</Typography.Link>
</AppVersionBadge>
);
};
export type AppVersionBadgeProps = {
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
const AppVersionBadge = ({ className, style, children }: AppVersionBadgeProps) => {
const { hasNewVersion } = useVersionChecker();
return (
<Badge styles={{ indicator: { transform: "scale(0.75) translate(50%, -50%)" } }} count={hasNewVersion ? "NEW" : undefined}>
<Typography.Link className={className} style={style} type="secondary" href="https://github.com/certimate-go/certimate/releases" target="_blank">
{version}
</Typography.Link>
<Badge
className={className}
style={style}
styles={{ indicator: { transform: "scale(0.75) translate(50%, -50%)" } }}
count={hasNewVersion ? "NEW" : undefined}
>
{children}
</Badge>
);
};
export default {
LinkButton: memo(AppVersionLinkButton),
Badge: memo(AppVersionBadge),
};

View File

@ -33,7 +33,7 @@ const createIconComponent = (type: "outline" | "filled", iconName: string, iconA
...iconAttrs,
width: size,
height: size,
className: className,
className: ["icon", className],
...(type === "filled"
? {
fill: color,

8
ui/src/domain/app.ts Normal file
View File

@ -0,0 +1,8 @@
export const APP_REPO_URL = "https://github.com/certimate-go/certimate";
export const APP_DOWNLOAD_URL = APP_REPO_URL + "/releases";
export const APP_DOCUMENT_URL = "https://docs.certimate.me";
// fallback policy: .env > git tag > "v0.0.0-dev"
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || __APP_VERSION__ || "v0.0.0-dev";

View File

@ -1,2 +0,0 @@
// fallback policy: .env > git tag > "v0.0.0-dev"
export const version = import.meta.env.VITE_APP_VERSION || __APP_VERSION__ || "v0.0.0-dev";

View File

@ -24,9 +24,13 @@
--foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%;
}
}
.ant-btn > .ant-btn-icon {
/* Fix non-antd icon's position not correct */
@layer base {
/* Fix non-antd icon's position not correct */
.ant-btn > .ant-btn-icon,
svg.tabler-icon,
svg.icon {
line-height: 1;
}
}

View File

@ -1,6 +1,6 @@
import { useRequest } from "ahooks";
import { version } from "@/domain/version";
import { APP_VERSION } from "@/domain/app";
export type UseVersionCheckerReturns = {
hasNewVersion: boolean;
@ -42,12 +42,12 @@ const useVersionChecker = () => {
.then((res) => res.json())
.then((res) => Array.from(res));
const cIdx = releases.findIndex((e: any) => e.name === version);
const cIdx = releases.findIndex((e: any) => e.name === APP_VERSION);
if (cIdx === 0) {
return false;
}
const nIdx = releases.findIndex((e: any) => compareVersions(e.name, version) !== -1);
const nIdx = releases.findIndex((e: any) => compareVersions(e.name, APP_VERSION) !== -1);
if (cIdx !== -1 && cIdx <= nIdx) {
return false;
}

View File

@ -1,5 +1,5 @@
{
"access.page.title": "Authorization",
"access.page.title": "Credentials",
"access.nodata": "No accesses. Please create an credential first.",

View File

@ -12,7 +12,7 @@
"common.text.copied": "Copied",
"common.text.import_from_file": "Import from file ...",
"common.text.happy_browser": "The browser version is too low, and Certimate WebUI is not working well. Recommend using modern browsers such as Google Chrome v119.0 or higher.",
"common.text.happy_browser": "The browser version is too low to make Certimate WebUI working well. Recommend using modern browsers such as Google Chrome v119.0 or higher.",
"common.text.nodata": "No data available",
"common.text.operation_confirm": "Operation confirm",
"common.text.operation_succeeded": "Operation succeeded",
@ -23,6 +23,7 @@
"common.menu.document": "Document",
"common.menu.theme": "Change theme",
"common.menu.locale": "Change language",
"common.menu.gethelp": "Get help",
"common.menu.logout": "Log-out",
"common.theme.light": "Light",

View File

@ -23,6 +23,7 @@
"common.menu.document": "文档",
"common.menu.theme": "切换主题",
"common.menu.locale": "切换语言",
"common.menu.gethelp": "获取帮助",
"common.menu.logout": "退出登录",
"common.theme.light": "浅色",

View File

@ -12,5 +12,7 @@ body {
margin: 0;
padding: 0;
min-width: 320px;
min-height: 480px;
min-height: 100vh;
min-height: calc(min(480px, 100vh));
}

View File

@ -2,20 +2,23 @@ import { memo, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
CloudServerOutlined as CloudServerOutlinedIcon,
HomeOutlined as HomeOutlinedIcon,
NodeIndexOutlined as NodeIndexOutlinedIcon,
SafetyOutlined as SafetyOutlinedIcon,
SettingOutlined as SettingOutlinedIcon,
} from "@ant-design/icons";
import { IconLogout, IconMenu2 } from "@tabler/icons-react";
import { Alert, Button, Divider, Drawer, Layout, Menu, type MenuProps, Space, Tooltip, theme } from "antd";
IconBrandGithub,
IconCircuitChangeover,
IconDashboard,
IconFingerprint,
IconHelpCircle,
IconLogout,
IconMenu2,
IconSettings,
IconShieldCheckered,
} from "@tabler/icons-react";
import { Alert, Button, Drawer, Layout, Menu, type MenuProps, theme } from "antd";
import AppDocument from "@/components/AppDocument";
import AppLocale from "@/components/AppLocale";
import AppTheme from "@/components/AppTheme";
import AppLocale, { useAppLocaleMenuItems } from "@/components/AppLocale";
import AppTheme, { useAppThemeMenuItems } from "@/components/AppTheme";
import AppVersion from "@/components/AppVersion";
import Show from "@/components/Show";
import { APP_DOCUMENT_URL, APP_REPO_URL } from "@/domain/app";
import { useTriggerElement } from "@/hooks";
import { getAuthStore } from "@/repository/admin";
import { isBrowserHappy } from "@/utils/browser";
@ -27,63 +30,120 @@ const ConsoleLayout = () => {
const { token: themeToken } = theme.useToken();
const localeMenuItems = useAppLocaleMenuItems();
const themeMenuItems = useAppThemeMenuItems();
const handleLogoutClick = () => {
auth.clear();
navigate("/login");
};
const handleDocumentClick = () => {
window.open(APP_DOCUMENT_URL, "_blank");
};
const handleGitHubClick = () => {
window.open(APP_REPO_URL, "_blank");
};
const auth = getAuthStore();
if (!auth.isValid || !auth.isSuperuser) {
return <Navigate to="/login" />;
}
return (
<Layout className="h-screen" hasSider>
<Layout.Sider className="fixed top-0 left-0 z-20 h-full max-md:static max-md:hidden" width="256px" theme="light">
<div className="flex size-full flex-col items-center justify-between overflow-hidden">
<div className="w-full">
<SiderMenu />
</div>
<div className="w-full py-2 text-center">
<Space align="center" split={<Divider type="vertical" />} size={4}>
<AppDocument.LinkButton />
<AppVersion.LinkButton />
</Space>
</div>
</div>
</Layout.Sider>
<Layout className="h-screen">
<Show when={!isBrowserHappy()}>
<Alert message={t("common.text.happy_browser")} type="warning" showIcon closable />
</Show>
<Layout className="flex flex-col overflow-hidden pl-[256px] max-md:pl-0">
<Show when={!isBrowserHappy()}>
<Alert message={t("common.text.happy_browser")} type="warning" showIcon closable />
</Show>
<Layout.Header className="shadow-xs" style={{ background: themeToken.colorBgContainer, padding: 0 }}>
<div className="flex size-full items-center justify-between overflow-hidden px-4">
<div className="flex items-center gap-4">
<SiderMenuDrawer trigger={<Button className="md:hidden" icon={<IconMenu2 size={20} stroke="1.25" />} />} />
<Layout className="h-screen" hasSider>
<Layout.Sider className="z-20 h-full max-md:static max-md:hidden" width="256px" theme="light">
<div className="flex size-full flex-col items-center justify-between overflow-hidden select-none">
<div className="w-full">
<SiderMenu />
</div>
<div className="flex size-full grow items-center justify-end gap-4 overflow-hidden">
<AppTheme.Dropdown>
<Tooltip title={t("common.menu.theme")} mouseEnterDelay={2}>
<Button icon={<AppTheme.Icon size={20} stroke="1.25" />} />
</Tooltip>
</AppTheme.Dropdown>
<AppLocale.Dropdown>
<Tooltip title={t("common.menu.locale")} mouseEnterDelay={2}>
<Button icon={<AppLocale.Icon size={20} stroke="1.25" />} />
</Tooltip>
</AppLocale.Dropdown>
<Tooltip title={t("common.menu.logout")} mouseEnterDelay={2}>
<Button danger icon={<IconLogout size={20} stroke="1.25" />} onClick={handleLogoutClick} />
</Tooltip>
<div className="w-full">
<Menu
style={{ borderInlineEnd: "none" }}
items={[
{
type: "divider",
},
{
key: "theme",
icon: (
<span className="anticon">
<AppTheme.Icon size="1em" />
</span>
),
label: t("common.menu.theme"),
children: themeMenuItems,
},
{
key: "locale",
icon: (
<span className="anticon">
<AppLocale.Icon size="1em" />
</span>
),
label: t("common.menu.locale"),
children: localeMenuItems,
},
{
key: "document",
icon: (
<span className="anticon">
<IconHelpCircle size="1em" />
</span>
),
label: t("common.menu.gethelp"),
onClick: handleDocumentClick,
},
{
key: "logout",
danger: true,
icon: (
<span className="anticon">
<IconLogout size="1em" />
</span>
),
label: t("common.menu.logout"),
onClick: handleLogoutClick,
},
]}
mode="vertical"
selectable={false}
/>
</div>
</div>
</Layout.Header>
</Layout.Sider>
<Layout.Content className="flex-1 overflow-x-hidden overflow-y-auto">
<Outlet />
</Layout.Content>
<Layout className="flex flex-col overflow-hidden">
<Layout.Header className="shadow-xs md:hidden" style={{ background: themeToken.colorBgContainer, padding: 0 }}>
<div className="flex size-full items-center justify-between overflow-hidden px-4">
<div className="flex items-center gap-4">
<SiderMenuDrawer trigger={<Button icon={<IconMenu2 size={18} stroke="1.25" />} />} />
</div>
<div className="flex size-full grow items-center justify-end gap-4 overflow-hidden">
<AppTheme.Dropdown>
<Button icon={<AppTheme.Icon size={18} stroke="1.25" />} />
</AppTheme.Dropdown>
<AppLocale.Dropdown>
<Button icon={<AppLocale.Icon size={18} stroke="1.25" />} />
</AppLocale.Dropdown>
<AppVersion.Badge>
<Button icon={<IconBrandGithub size={18} stroke="1.25" />} onClick={handleGitHubClick} />
</AppVersion.Badge>
<Button danger icon={<IconLogout size={18} stroke="1.25" />} onClick={handleLogoutClick} />
</div>
</div>
</Layout.Header>
<Layout.Content className="flex-1 overflow-x-hidden overflow-y-auto">
<Outlet />
</Layout.Content>
</Layout>
</Layout>
</Layout>
);
@ -101,15 +161,15 @@ const SiderMenu = memo(({ onSelect }: { onSelect?: (key: string) => void }) => {
const MENU_KEY_ACCESSES = "/accesses";
const MENU_KEY_SETTINGS = "/settings";
const menuItems: Required<MenuProps>["items"] = [
[MENU_KEY_HOME, <HomeOutlinedIcon />, t("dashboard.page.title")],
[MENU_KEY_WORKFLOWS, <NodeIndexOutlinedIcon />, t("workflow.page.title")],
[MENU_KEY_CERTIFICATES, <SafetyOutlinedIcon />, t("certificate.page.title")],
[MENU_KEY_ACCESSES, <CloudServerOutlinedIcon />, t("access.page.title")],
[MENU_KEY_SETTINGS, <SettingOutlinedIcon />, t("settings.page.title")],
[MENU_KEY_HOME, <IconDashboard size="1em" />, t("dashboard.page.title")],
[MENU_KEY_WORKFLOWS, <IconCircuitChangeover size="1em" />, t("workflow.page.title")],
[MENU_KEY_CERTIFICATES, <IconShieldCheckered size="1em" />, t("certificate.page.title")],
[MENU_KEY_ACCESSES, <IconFingerprint size="1em" />, t("access.page.title")],
[MENU_KEY_SETTINGS, <IconSettings size="1em" />, t("settings.page.title")],
].map(([key, icon, label]) => {
return {
key: key as string,
icon: icon,
icon: <span className="anticon">{icon}</span>,
label: label,
onClick: () => {
navigate(key as string);
@ -143,12 +203,14 @@ const SiderMenu = memo(({ onSelect }: { onSelect?: (key: string) => void }) => {
return (
<>
<div className="flex w-full items-center gap-2 overflow-hidden px-4 font-semibold">
<div className="flex w-full items-center gap-2 overflow-hidden px-4">
<img src="/logo.svg" className="size-[36px]" />
<span className="h-[64px] w-[74px] truncate leading-[64px] dark:text-white">Certimate</span>
<span className="h-[64px] w-[81px] truncate text-base leading-[64px] font-semibold">Certimate</span>
<AppVersion.LinkButton className="text-xs" />
</div>
<div className="w-full grow overflow-x-hidden overflow-y-auto">
<Menu
style={{ borderInlineEnd: "none" }}
items={menuItems}
mode="vertical"
selectedKeys={menuSelectedKey ? [menuSelectedKey] : []}

View File

@ -14,7 +14,7 @@ import {
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
import { IconActivity, IconCircuitChangeover, IconShield, IconShieldExclamation, IconShieldX } from "@tabler/icons-react";
import { IconActivity, IconCircuitChangeover, IconShieldCheckered, IconShieldExclamation, IconShieldX } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { Button, Card, Col, Divider, Empty, Flex, Grid, Row, Space, Statistic, Table, type TableProps, Tag, Typography, notification, theme } from "antd";
import dayjs from "dayjs";
@ -200,7 +200,7 @@ const Dashboard = () => {
<Row className="justify-stretch" gutter={[16, 16]}>
<Col {...statisticsGridSpans}>
<StatisticCard
icon={<IconShield size={48} strokeWidth={1} color={themeToken.colorInfo} />}
icon={<IconShieldCheckered size={48} strokeWidth={1} color={themeToken.colorInfo} />}
label={t("dashboard.statistics.all_certificates")}
loading={statisticsLoading}
value={statistics?.certificateTotal ?? "-"}