mirror of
https://github.com/coaidev/coai.git
synced 2025-05-20 05:20:15 +09:00
add admin pages
This commit is contained in:
parent
5c08d04b89
commit
610464c493
50
app/src/admin/colors.ts
Normal file
50
app/src/admin/colors.ts
Normal 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
22
app/src/admin/types.ts
Normal 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[];
|
||||||
|
};
|
@ -1,4 +1,5 @@
|
|||||||
@import "menu";
|
@import "menu";
|
||||||
|
@import "dashboard";
|
||||||
|
|
||||||
.admin-page {
|
.admin-page {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
120
app/src/assets/admin/dashboard.less
Normal file
120
app/src/assets/admin/dashboard.less
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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 = {
|
||||||
|
105
app/src/components/admin/ChartBox.tsx
Normal file
105
app/src/components/admin/ChartBox.tsx
Normal 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;
|
51
app/src/components/admin/InfoBox.tsx
Normal file
51
app/src/components/admin/InfoBox.tsx
Normal 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;
|
@ -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;
|
||||||
|
71
app/src/components/admin/assemblies/BillingChart.tsx
Normal file
71
app/src/components/admin/assemblies/BillingChart.tsx
Normal 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;
|
71
app/src/components/admin/assemblies/ErrorChart.tsx
Normal file
71
app/src/components/admin/assemblies/ErrorChart.tsx
Normal 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;
|
77
app/src/components/admin/assemblies/ModelChart.tsx
Normal file
77
app/src/components/admin/assemblies/ModelChart.tsx
Normal 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;
|
72
app/src/components/admin/assemblies/RequestChart.tsx
Normal file
72
app/src/components/admin/assemblies/RequestChart.tsx
Normal 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;
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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
5
app/src/events/theme.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { EventCommitter } from "@/events/struct.ts";
|
||||||
|
|
||||||
|
export const themeEvent = new EventCommitter<string>({
|
||||||
|
name: "theme",
|
||||||
|
});
|
@ -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;
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
@ -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;
|
||||||
|
13
app/src/routes/admin/DashBoard.tsx
Normal file
13
app/src/routes/admin/DashBoard.tsx
Normal 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;
|
Loading…
Reference in New Issue
Block a user