feat: Admin/Workbench 双入口架构
- 抽取 ConsoleShell 共享外壳(Layout/Header/Sider/主题切换/侧边栏折叠) - Sidebar 纯化为接受 menuItems prop 的展示组件 - Admin 管理台:/ 总览 + /projects 项目管理 - Workbench 工作台:/workbench/:projectId 项目作用域 - WorkbenchProjectGate 入口守卫(loading/error/archived/不存在拦截) - ProjectContext 提供当前项目上下文 - 项目管理表格 active 行增加'进入工作台'按钮 - 项目名称 trim 后最多 10 字符(前后端一致) - Workbench 总览页展示项目 Descriptions - Header 区分:管理台显示副标题,工作台显示项目名 + 返回管理台按钮 - 28/28 前端测试通过 - 文档更新:frontend.md ConsoleShell 规范、usage.md 双入口说明
This commit is contained in:
@@ -1,80 +1,13 @@
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { APP } from "../shared/app";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { useMeta } from "./hooks/use-meta";
|
||||
import { useSidebarCollapsed } from "./hooks/use-sidebar-collapsed";
|
||||
import { type ThemePreference, useThemePreference } from "./hooks/use-theme-preference";
|
||||
import { AppRoutes } from "./routes";
|
||||
|
||||
const { Content, Header, Sider } = Layout;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ label: "系统", value: "system" },
|
||||
{ label: "明亮", value: "light" },
|
||||
{ label: "黑暗", value: "dark" },
|
||||
] as const;
|
||||
|
||||
export function App() {
|
||||
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { collapsed, setCollapsed } = useSidebarCollapsed();
|
||||
const { data: meta } = useMeta();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = APP.title;
|
||||
document.querySelector('meta[name="description"]')?.setAttribute("content", APP.description);
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (value: number | string) => {
|
||||
setThemePreference(value as ThemePreference);
|
||||
};
|
||||
|
||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||
|
||||
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={{ algorithm: themeAlgorithm }}>
|
||||
<AntApp>
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<span className="app-brand-group">
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-header-right">
|
||||
<Segmented
|
||||
onChange={handleThemeChange}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
value={themePreference}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Sider
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={64}
|
||||
collapsible
|
||||
onCollapse={(collapsed) => setCollapsed(collapsed)}
|
||||
theme="light"
|
||||
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
width={232}
|
||||
>
|
||||
<Sidebar />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<AppRoutes />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
return <AppRoutes />;
|
||||
}
|
||||
|
||||
5
src/web/components/ConsoleShell/ConsoleOutlet.tsx
Normal file
5
src/web/components/ConsoleShell/ConsoleOutlet.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export function ConsoleOutlet() {
|
||||
return <Outlet />;
|
||||
}
|
||||
73
src/web/components/ConsoleShell/ConsoleShell.tsx
Normal file
73
src/web/components/ConsoleShell/ConsoleShell.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import { App as AntApp, ConfigProvider, Layout, Segmented, theme } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
|
||||
import type { ConsoleShellProps } from "./types";
|
||||
|
||||
import { APP } from "../../../shared/app";
|
||||
import { useMeta } from "../../hooks/use-meta";
|
||||
import { useSidebarCollapsed } from "../../hooks/use-sidebar-collapsed";
|
||||
import { useThemePreference } from "../../hooks/use-theme-preference";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
import { ConsoleOutlet } from "./ConsoleOutlet";
|
||||
|
||||
const { Content, Header, Sider } = Layout;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ label: "系统", value: "system" },
|
||||
{ label: "明亮", value: "light" },
|
||||
{ label: "黑暗", value: "dark" },
|
||||
] as const;
|
||||
|
||||
export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProps) {
|
||||
const { effectiveTheme, preference: themePreference, setPreference: setThemePreference } = useThemePreference();
|
||||
const { collapsed, setCollapsed } = useSidebarCollapsed();
|
||||
const { data: meta } = useMeta();
|
||||
|
||||
const versionDisplay = meta?.version ? `v${meta.version}` : null;
|
||||
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={{ algorithm: themeAlgorithm }}>
|
||||
<AntApp>
|
||||
<Layout className="app-layout">
|
||||
<Header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<span className="app-brand-group">
|
||||
<span className="app-brand">{APP.title}</span>
|
||||
{versionDisplay && <span className="app-version">{versionDisplay}</span>}
|
||||
<span className="app-console-title">{title}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="app-header-right">
|
||||
{headerExtra}
|
||||
<Segmented
|
||||
onChange={(value) => setThemePreference(value)}
|
||||
options={THEME_OPTIONS.map((option) => ({ label: option.label, value: option.value }))}
|
||||
value={themePreference}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Layout>
|
||||
<Sider
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={64}
|
||||
collapsible
|
||||
onCollapse={(c) => setCollapsed(c)}
|
||||
theme="light"
|
||||
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
width={232}
|
||||
>
|
||||
<Sidebar menuItems={menuItems} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content className="app-content">
|
||||
<ConsoleOutlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
9
src/web/components/ConsoleShell/types.ts
Normal file
9
src/web/components/ConsoleShell/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { MenuItemConfig } from "../../menu";
|
||||
|
||||
export interface ConsoleShellProps {
|
||||
headerExtra?: ReactNode;
|
||||
menuItems: readonly MenuItemConfig[];
|
||||
title: ReactNode;
|
||||
}
|
||||
@@ -3,30 +3,34 @@ import type { MenuProps } from "antd";
|
||||
import { Menu } from "antd";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { MENU_ITEMS } from "../../menu";
|
||||
import type { MenuItemConfig } from "../../menu";
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
menuItems: readonly MenuItemConfig[];
|
||||
}
|
||||
|
||||
export function Sidebar({ menuItems }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const currentItem = MENU_ITEMS.find((item) => item.path === currentPath);
|
||||
const currentItem = menuItems.find((item) => item.path === currentPath);
|
||||
const selectedKeys = currentItem ? [currentItem.value] : [];
|
||||
|
||||
const menuItems: MenuItem[] = MENU_ITEMS.map((item) => ({
|
||||
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
|
||||
icon: item.icon,
|
||||
key: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
|
||||
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||
const item = MENU_ITEMS.find((i) => i.value === key);
|
||||
const item = menuItems.find((i) => i.value === key);
|
||||
if (item) {
|
||||
void navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
return <Menu items={menuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
|
||||
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
|
||||
}
|
||||
|
||||
6
src/web/consoles/admin/AdminConsoleLayout.tsx
Normal file
6
src/web/consoles/admin/AdminConsoleLayout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
|
||||
import { ADMIN_MENU_ITEMS } from "./menu";
|
||||
|
||||
export function AdminConsoleLayout() {
|
||||
return <ConsoleShell menuItems={ADMIN_MENU_ITEMS} title="管理台" />;
|
||||
}
|
||||
9
src/web/consoles/admin/menu.tsx
Normal file
9
src/web/consoles/admin/menu.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { MenuItemConfig } from "../../menu";
|
||||
|
||||
export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
|
||||
] as const;
|
||||
17
src/web/consoles/workbench/ProjectContext.tsx
Normal file
17
src/web/consoles/workbench/ProjectContext.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
const ProjectContext = createContext<null | Project>(null);
|
||||
|
||||
export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) {
|
||||
return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCurrentProject(): Project {
|
||||
const project = useContext(ProjectContext);
|
||||
if (!project) {
|
||||
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
37
src/web/consoles/workbench/WorkbenchConsoleLayout.tsx
Normal file
37
src/web/consoles/workbench/WorkbenchConsoleLayout.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
import { Button } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
|
||||
import { ProjectProvider, useCurrentProject } from "./ProjectContext";
|
||||
import { getWorkbenchMenuItems } from "./routes";
|
||||
|
||||
interface WorkbenchConsoleLayoutProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function WorkbenchConsoleLayout({ project }: WorkbenchConsoleLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const menuItems = getWorkbenchMenuItems(project.id);
|
||||
|
||||
return (
|
||||
<ProjectProvider project={project}>
|
||||
<ConsoleShell
|
||||
headerExtra={
|
||||
<Button icon={<HomeOutlined />} onClick={() => void navigate("/")} size="small" type="link">
|
||||
返回管理台
|
||||
</Button>
|
||||
}
|
||||
menuItems={menuItems}
|
||||
title={<WorkbenchTitle />}
|
||||
/>
|
||||
</ProjectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkbenchTitle() {
|
||||
const project = useCurrentProject();
|
||||
return <>工作台 · {project.name}</>;
|
||||
}
|
||||
47
src/web/consoles/workbench/WorkbenchProjectGate.tsx
Normal file
47
src/web/consoles/workbench/WorkbenchProjectGate.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Alert, Button, Spin } from "antd";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { useProject } from "../../hooks/use-projects";
|
||||
import { WorkbenchConsoleLayout } from "./WorkbenchConsoleLayout";
|
||||
|
||||
export function WorkbenchProjectGate() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: project, error, isLoading } = useProject(projectId ?? "");
|
||||
|
||||
if (!projectId) {
|
||||
return <WorkbenchUnavailable onBack={() => void navigate("/")} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project || project.status === "archived") {
|
||||
return <WorkbenchUnavailable onBack={() => void navigate("/")} />;
|
||||
}
|
||||
|
||||
return <WorkbenchConsoleLayout project={project} />;
|
||||
}
|
||||
|
||||
function WorkbenchUnavailable({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<div className="app-unavailable">
|
||||
<Alert
|
||||
action={
|
||||
<Button onClick={onBack} size="small" type="primary">
|
||||
返回管理台
|
||||
</Button>
|
||||
}
|
||||
description="请确认项目是否存在且未归档。"
|
||||
showIcon
|
||||
title="项目不存在或不可访问"
|
||||
type="error"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/web/consoles/workbench/routes.ts
Normal file
22
src/web/consoles/workbench/routes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { DashboardOutlined } from "@ant-design/icons";
|
||||
import { createElement } from "react";
|
||||
|
||||
import type { MenuItemConfig } from "../../menu";
|
||||
|
||||
export const WORKBENCH_MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardOutlined), label: "总览", path: "", value: "overview" },
|
||||
] as const;
|
||||
|
||||
export function buildWorkbenchPath(projectId: string, relativePath = ""): string {
|
||||
const base = `/workbench/${projectId}`;
|
||||
if (!relativePath || relativePath === "/") return base;
|
||||
const normalized = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
|
||||
return `${base}${normalized}`;
|
||||
}
|
||||
|
||||
export function getWorkbenchMenuItems(projectId: string): readonly MenuItemConfig[] {
|
||||
return WORKBENCH_MENU_ITEMS.map((item) => ({
|
||||
...item,
|
||||
path: buildWorkbenchPath(projectId, item.path || "/"),
|
||||
}));
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { DashboardOutlined, FolderOutlined } from "@ant-design/icons";
|
||||
import { createElement } from "react";
|
||||
|
||||
export interface MenuItemConfig {
|
||||
icon: ReactElement;
|
||||
label: string;
|
||||
path: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const MENU_ITEMS: readonly MenuItemConfig[] = [
|
||||
{ icon: createElement(DashboardOutlined), label: "仪表盘", path: "/", value: "dashboard" },
|
||||
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
|
||||
] as const;
|
||||
|
||||
@@ -18,8 +18,8 @@ export function DashboardPage() {
|
||||
|
||||
return (
|
||||
<Space size="large" vertical>
|
||||
<Typography.Title level={2}>欢迎使用 {APP.title}</Typography.Title>
|
||||
<Typography.Paragraph>在此构建你的应用。以下是 /api/meta 的返回数据(前后端联调示例):</Typography.Paragraph>
|
||||
<Typography.Title level={2}>总览</Typography.Title>
|
||||
<Typography.Paragraph>欢迎使用 {APP.title}。以下是 /api/meta 的返回数据(前后端联调示例):</Typography.Paragraph>
|
||||
{isLoading && <Spin size="large" />}
|
||||
{error && <Alert description={error.message} showIcon title="加载失败" type="error" />}
|
||||
{meta && (
|
||||
|
||||
@@ -73,9 +73,9 @@ export function ProjectFormModal({
|
||||
<Form.Item
|
||||
label="项目名称"
|
||||
name="name"
|
||||
rules={[{ message: "项目名称不能为空", required: true, whitespace: true }]}
|
||||
rules={[{ max: 10, message: "项目名称不能超过 10 个字符", required: true, whitespace: true }]}
|
||||
>
|
||||
<Input maxLength={100} placeholder="请输入项目名称" />
|
||||
<Input maxLength={10} placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
<Form.Item label="项目描述" name="description">
|
||||
<Input.TextArea autoSize={{ minRows: 5 }} maxLength={500} placeholder="请输入项目描述" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
import { DeleteOutlined, EditOutlined, InboxOutlined, RedoOutlined } from "@ant-design/icons";
|
||||
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
|
||||
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { Project, ProjectListResponse } from "../../../../shared/api";
|
||||
|
||||
@@ -60,6 +61,7 @@ export function ProjectTable({
|
||||
pageSize,
|
||||
}: ProjectTableProps) {
|
||||
const { message } = AntApp.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
@@ -95,6 +97,14 @@ export function ProjectTable({
|
||||
if (record.status === "active") {
|
||||
return (
|
||||
<Space size="small">
|
||||
<Button
|
||||
icon={<LoginOutlined />}
|
||||
onClick={() => void navigate(`/workbench/${record.id}`)}
|
||||
size="small"
|
||||
type="link"
|
||||
>
|
||||
进入工作台
|
||||
</Button>
|
||||
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
|
||||
编辑
|
||||
</Button>
|
||||
@@ -130,7 +140,7 @@ export function ProjectTable({
|
||||
);
|
||||
},
|
||||
title: "操作",
|
||||
width: 180,
|
||||
width: 280,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
22
src/web/pages/workbench/index.tsx
Normal file
22
src/web/pages/workbench/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Card, Descriptions, Space, Typography } from "antd";
|
||||
|
||||
import { useCurrentProject } from "../../consoles/workbench/ProjectContext";
|
||||
|
||||
export function WorkbenchOverviewPage() {
|
||||
const project = useCurrentProject();
|
||||
|
||||
const items = [
|
||||
{ children: project.name, key: "name", label: "项目名称" },
|
||||
{ children: project.description || "暂无描述", key: "description", label: "项目描述" },
|
||||
{ children: project.status === "active" ? "进行中" : "已归档", key: "status", label: "状态" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Space size="large" vertical>
|
||||
<Typography.Title level={2}>总览</Typography.Title>
|
||||
<Card>
|
||||
<Descriptions column={1} items={items} title={project.name} />
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Route, Routes } from "react-router";
|
||||
|
||||
import { AdminConsoleLayout } from "./consoles/admin/AdminConsoleLayout";
|
||||
import { WorkbenchProjectGate } from "./consoles/workbench/WorkbenchProjectGate";
|
||||
import { NotFoundPage } from "./pages/404";
|
||||
import { DashboardPage } from "./pages/dashboard";
|
||||
import { ProjectsPage } from "./pages/projects";
|
||||
import { WorkbenchOverviewPage } from "./pages/workbench";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<ProjectsPage />} path="/projects" />
|
||||
<Route element={<AdminConsoleLayout />}>
|
||||
<Route element={<DashboardPage />} path="/" />
|
||||
<Route element={<ProjectsPage />} path="/projects" />
|
||||
</Route>
|
||||
<Route element={<WorkbenchProjectGate />} path="/workbench/:projectId">
|
||||
<Route element={<WorkbenchOverviewPage />} path="" />
|
||||
</Route>
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -52,3 +52,23 @@ body {
|
||||
.app-content {
|
||||
padding: var(--ant-padding-xl) var(--ant-padding-xl);
|
||||
}
|
||||
|
||||
.app-console-title {
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: var(--ant-font-size);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.app-unavailable {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user