add admin pages

This commit is contained in:
Zhang Minghan 2023-11-06 22:41:29 +08:00
parent 5c08d04b89
commit 610464c493
21 changed files with 805 additions and 50 deletions

50
app/src/admin/colors.ts Normal file
View File

@ -0,0 +1,50 @@
export const modelColorMapper: Record<string, string> = {
"gpt-3.5-turbo": "#34bf49",
"gpt-3.5-turbo-instruct": "#34bf49",
"gpt-3.5-turbo-0613": "#34bf49",
"gpt-3.5-turbo-0301": "#34bf49",
dalle: "#e4e5e5",
"gpt-3.5-turbo-16k": "#0abf53",
"gpt-3.5-turbo-16k-0613": "#0abf53",
"gpt-3.5-turbo-16k-0301": "#0abf53",
"gpt-4": "#8e43e7",
"gpt-4-0613": "#8e43e7",
"gpt-4-0314": "#8e43e7",
"gpt-4-v": "#8e43e7",
"gpt-4-dalle": "#8e43e7",
"gpt-4-32k": "#8329f1",
"gpt-4-32k-0613": "#8329f1",
"gpt-4-32k-0314": "#8329f1",
"claude-1": "#ff9d3b",
"claude-1-100k": "#ff9d3b",
"claude-slack": "#ff9d3b",
"claude-2": "#ff840b",
"claude-2-100k": "#ff840b",
"spark-desk-v1.5": "#06b3e8",
"spark-desk-v2": "#06b3e8",
"spark-desk-v3": "#06b3e8",
"chat-bison-001": "#f82a53",
"bing-creative": "#2673e7",
"bing-balance": "#2673e7",
"bing-precise": "#2673e7",
"zhipu-chatglm-pro": "#008272",
"zhipu-chatglm-std": "#008272",
"zhipu-chatglm-lite": "#008272",
"qwen-plus": "#615ced",
"qwen-plus-net": "#615ced",
"qwen-turbo": "#716cfd",
"qwen-turbo-net": "#716cfd",
};
export function getModelColor(model: string): string {
return modelColorMapper[model] || "#000000";
}

22
app/src/admin/types.ts Normal file
View File

@ -0,0 +1,22 @@
export type ModelChartResponse = {
date: string[];
value: {
model: string;
data: number[];
}[];
};
export type RequestChartResponse = {
date: string[];
value: number[];
};
export type BillingChartResponse = {
date: string[];
value: number[];
};
export type ErrorChartResponse = {
date: string[];
value: number[];
};

View File

@ -1,4 +1,5 @@
@import "menu"; @import "menu";
@import "dashboard";
.admin-page { .admin-page {
position: relative; position: relative;

View File

@ -0,0 +1,120 @@
.dashboard {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0.5rem 0;
& > * {
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
}
.info-boxes {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
height: max-content;
padding: 1rem 2rem;
@media (max-width: 668px) {
flex-direction: column;
padding: 1rem;
.info-box {
width: calc(100% - 1rem) !important;
max-width: none !important;
margin: 0 0.5rem 1rem !important;
&:last-child {
margin-bottom: 0;
}
}
}
.info-box {
display: flex;
flex-direction: row;
flex-grow: 1;
height: max-content;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
background: hsl(var(--background-container));
border: 1px solid hsl(var(--border));
user-select: none;
max-width: 460px;
margin-right: 1.5rem;
margin-left: auto;
&:last-child {
margin-right: auto;
}
& > * {
flex-shrink: 0;
}
.box-wrapper {
flex-grow: 1;
.box-title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.box-value {
font-size: 1.5rem;
font-weight: 600;
&.money::after,
.box-subvalue {
font-size: 1rem;
font-weight: normal;
margin-left: 0.5rem;
content: 'CNY';
}
}
}
.box-icon {
width: max-content;
height: max-content;
transform: translate(0.25rem, 0.25rem);
border-radius: 0.25rem;
svg {
width: 2rem;
height: 2rem;
stroke-width: 1.5;
}
}
}
}
.chart-boxes {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 1rem 1.5rem;
.chart-box {
width: calc(50% - 1rem);
height: max-content;
max-height: 420px;
padding: 1rem 2rem;
margin: 0.5rem;
border-radius: var(--radius);
background: hsl(var(--background-container));
border: 1px solid hsl(var(--border));
user-select: none;
box-shadow: 0 0 1rem 0 hsla(var(--foreground), 0.1);
}
}

View File

@ -12,15 +12,13 @@
transition-property: width, background, box-shadow, opacity; transition-property: width, background, box-shadow, opacity;
border-right: 0; border-right: 0;
opacity: 0; opacity: 0;
pointer-events: none;
&.close {
display: none;
}
&.open { &.open {
width: 260px; width: 260px;
border-right: 1px solid hsl(var(--border)); border-right: 1px solid hsl(var(--border));
opacity: 1; opacity: 1;
pointer-events: all;
} }
.menu-item { .menu-item {
@ -43,17 +41,42 @@
} }
&.active { &.active {
background: var(--conversation-card-hover); background: var(--conversation-card-active);
} }
& > * { & > * {
flex-shrink: 0; flex-shrink: 0;
} }
& svg { .menu-item-title {
font-size: 1rem;
}
.menu-item-icon {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
} }
@media (max-width: 668px) {
&.open {
width: 100%;
border-right: 0;
}
}
}
.admin-content {
flex-grow: 1;
height: max-content;
min-height: calc(100vh - 56px);
overflow-x: hidden;
overflow-y: auto;
touch-action: pan-y;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
} }

View File

@ -36,6 +36,8 @@ const LanguageMap: Record<string, string> = {
html: "htmlbars", html: "htmlbars",
js: "javascript", js: "javascript",
ts: "typescript", ts: "typescript",
jsx: "javascript",
tsx: "typescript",
rs: "rust", rs: "rust",
}; };

View File

@ -3,6 +3,7 @@ import { Moon, Sun } from "lucide-react";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { getMemory, setMemory } from "@/utils/memory.ts"; import { getMemory, setMemory } from "@/utils/memory.ts";
import { themeEvent } from "@/events/theme.ts";
type Theme = "dark" | "light" | "system"; type Theme = "dark" | "light" | "system";
@ -27,6 +28,7 @@ export function activeTheme(theme: Theme) {
root.classList.add(theme); root.classList.add(theme);
setMemory("theme", theme); setMemory("theme", theme);
themeEvent.emit(theme);
} }
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {

View File

@ -0,0 +1,105 @@
import ModelChart from "@/components/admin/assemblies/ModelChart.tsx";
import { useEffect, useState } from "react";
import {BillingChartResponse, ErrorChartResponse, ModelChartResponse, RequestChartResponse} from "@/admin/types.ts";
import {ArcElement, Chart, Filler, LineElement, PointElement} from "chart.js";
import {
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js";
import { useSelector } from "react-redux";
import { selectMenu } from "@/store/menu.ts";
import { getMemory } from "@/utils/memory.ts";
import { themeEvent } from "@/events/theme.ts";
import RequestChart from "@/components/admin/assemblies/RequestChart.tsx";
import BillingChart from "@/components/admin/assemblies/BillingChart.tsx";
import ErrorChart from "@/components/admin/assemblies/ErrorChart.tsx";
Chart.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
ArcElement,
PointElement,
LineElement,
Filler,
);
function resize(task: number): number {
Object.values(Chart.instances).forEach((chart) => {
chart.resize();
});
return Number(
setTimeout(() => {
clearTimeout(task);
window.addEventListener("resize", () => {
Object.values(Chart.instances).forEach((chart) => {
chart.resize();
});
});
}, 500),
);
}
function ChartBox() {
const open = useSelector(selectMenu);
let timeout: number = 0;
useEffect(() => {
timeout = resize(timeout);
return () => {
clearTimeout(timeout);
};
}, [open]);
useEffect(() => {
}, []);
const [dark, setDark] = useState<boolean>(getMemory("theme") === "dark");
themeEvent.bind((theme: string) => setDark(theme === "dark"));
const [model, setModel] = useState<ModelChartResponse>({
date: [], value: [],
});
const [request, setRequest] = useState<RequestChartResponse>({
date: [], value: [],
});
const [billing, setBilling] = useState<BillingChartResponse>({
date: [], value: [],
});
const [error, setError] = useState<ErrorChartResponse>({
date: [], value: [],
});
return (
<div className={`chart-boxes`}>
<div className={`chart-box`}>
<ModelChart labels={model.date} datasets={model.value} dark={dark} />
</div>
<div className={`chart-box`}>
<RequestChart labels={request.date} datasets={request.value} dark={dark} />
</div>
<div className={`chart-box`}>
<BillingChart labels={billing.date} datasets={billing.value} dark={dark} />
</div>
<div className={`chart-box`}>
<ErrorChart labels={error.date} datasets={error.value} dark={dark} />
</div>
</div>
);
}
export default ChartBox;

View File

@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { CircleDollarSign, Users2, Wallet } from "lucide-react";
function InfoBox() {
const { t } = useTranslation();
const [form, setForm] = useState({
today: 0,
month: 0,
users: 0,
});
return (
<div className={`info-boxes`}>
<div className={`info-box`}>
<div className={`box-wrapper`}>
<div className={`box-title`}>{t("admin.billing-today")}</div>
<div className={`box-value money`}>{form.today}</div>
</div>
<div className={`box-icon`}>
<CircleDollarSign />
</div>
</div>
<div className={`info-box`}>
<div className={`box-wrapper`}>
<div className={`box-title`}>{t("admin.billing-month")}</div>
<div className={`box-value money`}>{form.month}</div>
</div>
<div className={`box-icon`}>
<Wallet />
</div>
</div>
<div className={`info-box`}>
<div className={`box-wrapper`}>
<div className={`box-title`}>{t("admin.subscription-users")}</div>
<div className={`box-value`}>
{form.users}
<span className={`box-subvalue`}>{t("admin.seat")}</span>
</div>
</div>
<div className={`box-icon`}>
<Users2 />
</div>
</div>
</div>
);
}
export default InfoBox;

View File

@ -1,50 +1,54 @@
import {useSelector} from "react-redux"; import { useSelector } from "react-redux";
import {selectMenu} from "@/store/menu.ts"; import { selectMenu } from "@/store/menu.ts";
import React, {useEffect, useMemo, useState} from "react"; import React, { useMemo } from "react";
import {LayoutDashboard, Settings} from "lucide-react"; import { LayoutDashboard, Settings } from "lucide-react";
import router from "@/router.tsx"; import router from "@/router.tsx";
import {useLocation} from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
type MenuItemProps = { type MenuItemProps = {
title: string; title: string;
icon: React.ReactNode; icon: React.ReactNode;
path: string; path: string;
} };
function MenuItem({ title, icon, path }: MenuItemProps) { function MenuItem({ title, icon, path }: MenuItemProps) {
const location = useLocation(); const location = useLocation();
const active = useMemo(() => ( const active = useMemo(
location.pathname === `/admin${path}` || (location.pathname + "/") === `/admin${path}` () =>
), [location.pathname, path]); location.pathname === `/admin${path}` ||
location.pathname + "/" === `/admin${path}`,
[location.pathname, path],
);
return ( return (
<div className={`menu-item ${active ? "active" : ""}`} <div
onClick={() => router.navigate(`/admin${path}`)} className={`menu-item ${active ? "active" : ""}`}
onClick={() => router.navigate(`/admin${path}`)}
> >
<div className={`menu-item-icon`}> <div className={`menu-item-icon`}>{icon}</div>
{icon} <div className={`menu-item-title`}>{title}</div>
</div>
<div className={`menu-item-title`}>
{title}
</div>
</div> </div>
) );
} }
function MenuBar() { function MenuBar() {
const { t } = useTranslation();
const open = useSelector(selectMenu); const open = useSelector(selectMenu);
const [close, setClose] = useState(false);
useEffect(() => {
if (open) setClose(false);
else setTimeout(() => setClose(true), 200);
}, [open]);
return ( return (
<div className={`admin-menu ${open ? "open" : ""} ${close ? "close" : ""}`}> <div className={`admin-menu ${open ? "open" : ""}`}>
<MenuItem title={"Dashboard"} icon={<LayoutDashboard />} path={"/"} /> <MenuItem
<MenuItem title={"Dashboard"} icon={<Settings />} path={"/config"} /> title={t("admin.dashboard")}
icon={<LayoutDashboard />}
path={"/"}
/>
<MenuItem
title={t("admin.settings")}
icon={<Settings />}
path={"/config"}
/>
</div> </div>
) );
} }
export default MenuBar; export default MenuBar;

View File

@ -0,0 +1,71 @@
import {useTranslation} from "react-i18next";
import {useMemo} from "react";
import { Line } from "react-chartjs-2";
type BillingChartProps = {
labels: string[];
datasets: number[];
dark?: boolean;
};
function BillingChart({ labels, datasets, dark }: BillingChartProps) {
const { t } = useTranslation();
const data = useMemo(() => {
return {
labels,
datasets: [
{
label: 'CNY',
fill: true,
data: datasets,
backgroundColor: "rgba(255,205,111,0.78)",
},
],
};
}, [labels, datasets]);
const options = useMemo(() => {
const text = dark ? "#fff" : "#000";
return {
scales: {
x: {
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
y: {
beginAtZero: true,
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
},
plugins: {
title: {
display: false,
},
legend: {
display: true,
labels: {
color: text,
},
},
},
color: text,
borderWidth: 0,
};
}, [dark]);
return (
<div className={`chart`}>
<p className={`mb-2`}>{t("admin.billing-chart")}</p>
<Line id={`billing-chart`} data={data} options={options} />
</div>
);
}
export default BillingChart;

View File

@ -0,0 +1,71 @@
import {useTranslation} from "react-i18next";
import {useMemo} from "react";
import { Line } from "react-chartjs-2";
type ErrorChartProps = {
labels: string[];
datasets: number[];
dark?: boolean;
};
function ErrorChart({ labels, datasets, dark }: ErrorChartProps) {
const { t } = useTranslation();
const data = useMemo(() => {
return {
labels,
datasets: [
{
label: t("admin.times"),
fill: true,
data: datasets,
backgroundColor: "rgba(255,85,85,0.6)",
},
],
};
}, [labels, datasets]);
const options = useMemo(() => {
const text = dark ? "#fff" : "#000";
return {
scales: {
x: {
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
y: {
beginAtZero: true,
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
},
plugins: {
title: {
display: false,
},
legend: {
display: true,
labels: {
color: text,
},
},
},
color: text,
borderWidth: 0,
};
}, [dark]);
return (
<div className={`chart`}>
<p className={`mb-2`}>{t("admin.error-chart")}</p>
<Line id={`error-chart`} data={data} options={options} />
</div>
);
}
export default ErrorChart;

View File

@ -0,0 +1,77 @@
import { Bar } from "react-chartjs-2";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { getModelColor } from "@/admin/colors.ts";
type ModelChartProps = {
labels: string[];
datasets: {
model: string;
data: number[];
}[];
dark?: boolean;
};
function ModelChart({ labels, datasets, dark }: ModelChartProps) {
const { t } = useTranslation();
const data = useMemo(() => {
return {
labels,
datasets: datasets.map((dataset) => {
return {
label: dataset.model,
data: dataset.data,
backgroundColor: getModelColor(dataset.model),
};
}),
};
}, [labels, datasets]);
const options = useMemo(() => {
const text = dark ? "#fff" : "#000";
return {
scales: {
x: {
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
y: {
beginAtZero: true,
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
},
plugins: {
title: {
display: false,
},
legend: {
display: true,
labels: {
color: text,
},
},
},
color: text,
borderWidth: 0,
defaultFontColor: text,
defaultFontSize: 16,
defaultFontFamily: "Andika",
};
}, [dark]);
return (
<>
<p className={`mb-2`}>{t("admin.model-chart")}</p>
<Bar id={`model-chart`} data={data} options={options} />
</>
);
}
export default ModelChart;

View File

@ -0,0 +1,72 @@
import {useTranslation} from "react-i18next";
import {useMemo} from "react";
import { Line } from "react-chartjs-2";
type RequestChartProps = {
labels: string[];
datasets: number[];
dark?: boolean;
};
function RequestChart({ labels, datasets, dark }: RequestChartProps) {
const { t } = useTranslation();
const data = useMemo(() => {
return {
labels,
datasets: [
{
label: t('admin.requests'),
fill: true,
data: datasets,
borderColor: "rgba(109,179,255,1)",
backgroundColor: "rgba(109,179,255,0.5)",
},
],
};
}, [labels, datasets]);
const options = useMemo(() => {
const text = dark ? "#fff" : "#000";
return {
scales: {
x: {
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
y: {
beginAtZero: true,
stacked: true,
grid: {
drawBorder: false,
display: false,
},
},
},
plugins: {
title: {
display: false,
},
legend: {
display: true,
labels: {
color: text,
},
},
},
color: text,
borderWidth: 0,
};
}, [dark]);
return (
<div className={`chart`}>
<p className={`mb-2`}>{t("admin.request-chart")}</p>
<Line id={`request-chart`} data={data} options={options} />
</div>
);
}
export default RequestChart;

View File

@ -19,11 +19,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog.tsx"; } from "@/components/ui/dialog.tsx";
import { getLanguage } from "@/i18n.ts";
function ChatSpace() { function ChatSpace() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const subscription = useSelector(isSubscribedSelector); const subscription = useSelector(isSubscribedSelector);
const cn = getLanguage() === "cn";
return ( return (
<div className={`chat-product`}> <div className={`chat-product`}>
<Button variant={`outline`} onClick={() => setOpen(true)}> <Button variant={`outline`} onClick={() => setOpen(true)}>
@ -85,19 +88,21 @@ function ChatSpace() {
href={`https://docs.chatnio.net/ai-mo-xing-ji-ji-fei`} href={`https://docs.chatnio.net/ai-mo-xing-ji-ji-fei`}
target={`_blank`} target={`_blank`}
> >
{t("pricing")}
</a> </a>
</p> </p>
<p> {cn && (
<p>
<a
href={`http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm`} <a
target={`_blank`} href={`http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm`}
> target={`_blank`}
>
</a>
使 </a>
</p> 使
</p>
)}
</div> </div>
</div> </div>
); );

View File

@ -16,7 +16,7 @@ import {
updateConversationList, updateConversationList,
} from "@/conversation/history.ts"; } from "@/conversation/history.ts";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import {selectMenu, setMenu} from "@/store/menu.ts"; import { selectMenu, setMenu } from "@/store/menu.ts";
import { import {
Copy, Copy,
Eraser, Eraser,

5
app/src/events/theme.ts Normal file
View File

@ -0,0 +1,5 @@
import { EventCommitter } from "@/events/struct.ts";
export const themeEvent = new EventCommitter<string>({
name: "theme",
});

View File

@ -29,6 +29,7 @@ const resources = {
"Request failed. Please check your network and try again.", "Request failed. Please check your network and try again.",
close: "Close", close: "Close",
edit: "Edit", edit: "Edit",
pricing: "Model Pricing",
conversation: { conversation: {
title: "Conversation", title: "Conversation",
empty: "Empty", empty: "Empty",
@ -255,6 +256,20 @@ const resources = {
"Failed to generate article, please check your network and try again.", "Failed to generate article, please check your network and try again.",
"download-format": "Download {{name}} format", "download-format": "Download {{name}} format",
}, },
admin: {
dashboard: "Dashboard",
settings: "Settings",
"billing-today": "Billing Today",
"billing-month": "Billing Month",
"subscription-users": "Subscription Users",
seat: "Seat",
"model-chart": "Model Usage Statistics",
"request-chart": "Request Statistics",
"billing-chart": "Revenue Statistics",
"error-chart": "Error Statistics",
requests: "Requests",
times: "Times",
},
}, },
}, },
cn: { cn: {
@ -278,6 +293,7 @@ const resources = {
"request-failed": "请求失败,请检查您的网络并重试。", "request-failed": "请求失败,请检查您的网络并重试。",
close: "关闭", close: "关闭",
edit: "编辑", edit: "编辑",
pricing: "模型定价表",
conversation: { conversation: {
title: "对话", title: "对话",
empty: "空空如也", empty: "空空如也",
@ -491,6 +507,20 @@ const resources = {
"generate-failed-prompt": "文章生成失败,请检查您的网络并重试。", "generate-failed-prompt": "文章生成失败,请检查您的网络并重试。",
"download-format": "下载 {{name}} 格式", "download-format": "下载 {{name}} 格式",
}, },
admin: {
dashboard: "仪表盘",
settings: "设置",
"billing-today": "今日入账",
"billing-month": "本月入账",
"subscription-users": "订阅用户",
seat: "位",
"model-chart": "模型使用统计",
"request-chart": "请求量统计",
"billing-chart": "收入统计",
"error-chart": "错误统计",
requests: "请求量",
times: "异常次数",
},
}, },
}, },
ru: { ru: {
@ -516,6 +546,7 @@ const resources = {
"Ошибка запроса. Пожалуйста, проверьте свою сеть и попробуйте еще раз.", "Ошибка запроса. Пожалуйста, проверьте свою сеть и попробуйте еще раз.",
close: "Закрыть", close: "Закрыть",
edit: "Редактировать", edit: "Редактировать",
pricing: "Тарифы моделей",
conversation: { conversation: {
title: "Разговор", title: "Разговор",
empty: "Пусто", empty: "Пусто",
@ -743,6 +774,20 @@ const resources = {
"Не удалось сгенерировать статью. Пожалуйста, проверьте свою сеть и попробуйте еще раз.", "Не удалось сгенерировать статью. Пожалуйста, проверьте свою сеть и попробуйте еще раз.",
"download-format": "Загрузить {{name}} формат", "download-format": "Загрузить {{name}} формат",
}, },
admin: {
dashboard: "Панель управления",
settings: "Настройки",
"billing-today": "Сегодняшний доход",
"billing-month": "Доход за месяц",
"subscription-users": "Подписчики",
seat: "место",
"model-chart": "Статистика использования моделей",
"request-chart": "Статистика запросов",
"billing-chart": "Статистика доходов",
"error-chart": "Статистика ошибок",
requests: "Запросы",
times: "Количество ошибок",
},
}, },
}, },
}; };
@ -753,7 +798,7 @@ i18n
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
resources, resources,
lng: getStorage(), lng: getLanguage(),
fallbackLng: "en", fallbackLng: "en",
interpolation: { interpolation: {
escapeValue: false, // react already safes from xss escapeValue: false, // react already safes from xss
@ -763,7 +808,7 @@ i18n
export default i18n; export default i18n;
export function getStorage(): string { export function getLanguage(): string {
const storage = getMemory("language"); const storage = getMemory("language");
if (storage && supportedLanguages.includes(storage)) { if (storage && supportedLanguages.includes(storage)) {
return storage; return storage;

View File

@ -7,7 +7,9 @@ import { lazy, Suspense } from "react";
const Generation = lazy(() => import("@/routes/Generation.tsx")); const Generation = lazy(() => import("@/routes/Generation.tsx"));
const Sharing = lazy(() => import("@/routes/Sharing.tsx")); const Sharing = lazy(() => import("@/routes/Sharing.tsx"));
const Article = lazy(() => import("@/routes/Article.tsx")); const Article = lazy(() => import("@/routes/Article.tsx"));
const Admin = lazy(() => import("@/routes/Admin.tsx")); const Admin = lazy(() => import("@/routes/Admin.tsx"));
const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx"));
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -60,7 +62,17 @@ const router = createBrowserRouter([
<Admin /> <Admin />
</Suspense> </Suspense>
), ),
children: [], children: [
{
id: "admin-dashboard",
path: "",
element: (
<Suspense>
<Dashboard />
</Suspense>
),
},
],
ErrorBoundary: NotFound, ErrorBoundary: NotFound,
}, },
]); ]);

View File

@ -1,12 +1,16 @@
import "@/assets/admin/all.less"; import "@/assets/admin/all.less";
import MenuBar from "@/components/admin/MenuBar.tsx"; import MenuBar from "@/components/admin/MenuBar.tsx";
import { Outlet } from "react-router-dom";
function Admin() { function Admin() {
return ( return (
<div className={`admin-page`}> <div className={`admin-page`}>
<MenuBar /> <MenuBar />
<div className={`admin-content`}>
<Outlet />
</div>
</div> </div>
) );
} }
export default Admin; export default Admin;

View File

@ -0,0 +1,13 @@
import InfoBox from "@/components/admin/InfoBox.tsx";
import ChartBox from "@/components/admin/ChartBox.tsx";
function DashBoard() {
return (
<div className={`dashboard`}>
<InfoBox />
<ChartBox />
</div>
);
}
export default DashBoard;