feat: loading spinner and fix offline models

This commit is contained in:
Zhang Minghan 2024-01-10 17:40:32 +08:00
parent 1884a9484a
commit 90345076be
19 changed files with 271 additions and 57 deletions

View File

@ -31,6 +31,7 @@
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.6",
"@reduxjs/toolkit": "^1.9.5",
"@tanem/react-nprogress": "^5.0.51",
"axios": "^1.5.0",
"chart.js": "^4.4.0",
"class-variance-authority": "^0.7.0",

15
app/pnpm-lock.yaml generated
View File

@ -62,6 +62,9 @@ dependencies:
'@reduxjs/toolkit':
specifier: ^1.9.5
version: 1.9.7(react-redux@8.1.3)(react@18.2.0)
'@tanem/react-nprogress':
specifier: ^5.0.51
version: 5.0.51(react-dom@18.2.0)(react@18.2.0)
axios:
specifier: ^1.5.0
version: 1.5.1
@ -1961,6 +1964,18 @@ packages:
resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==}
dev: true
/@tanem/react-nprogress@5.0.51(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-YxNUCpznuBVA+PhjEzFmxaa1czXgU+5Ojchw5JBK7DQS6SHIgNudpFohWpNBWMu2KWByGJ2OLH2OwbM/XyP18Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@babel/runtime': 7.23.2
hoist-non-react-statics: 3.3.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@tauri-apps/cli-darwin-arm64@1.5.6:
resolution: {integrity: sha512-NNvG3XLtciCMsBahbDNUEvq184VZmOveTGOuy0So2R33b/6FDkuWaSgWZsR1mISpOuP034htQYW0VITCLelfqg==}
engines: {node: '>= 10'}

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "chatnio",
"version": "3.8.2"
"version": "3.8.3"
},
"tauri": {
"allowlist": {

View File

@ -3,12 +3,14 @@ import store from "./store/index.ts";
import AppProvider from "./components/app/AppProvider.tsx";
import { AppRouter } from "./router.tsx";
import { Toaster } from "@/components/ui/sonner";
import Spinner from "@/spinner.tsx";
function App() {
return (
<Provider store={store}>
<AppProvider />
<Toaster />
<Spinner />
<AppProvider />
<AppRouter />
</Provider>
);

View File

@ -11,6 +11,7 @@ import {
UserResponse,
} from "@/admin/types.ts";
import axios from "axios";
import { getErrorMessage } from "@/utils/base.ts";
export async function getAdminInfo(): Promise<InfoResponse> {
const response = await axios.get("/admin/analytics/info");
@ -65,17 +66,17 @@ export async function getErrorChart(): Promise<ErrorChartResponse> {
export async function getInvitationList(
page: number,
): Promise<InvitationResponse> {
const response = await axios.get(`/admin/invitation/list?page=${page}`);
if (response.status !== 200) {
try {
const response = await axios.get(`/admin/invitation/list?page=${page}`);
return response.data as InvitationResponse;
} catch (e) {
return {
status: false,
message: "",
message: getErrorMessage(e),
data: [],
total: 0,
};
}
return response.data as InvitationResponse;
}
export async function generateInvitation(

View File

@ -2,6 +2,20 @@
color: hsl(var(--gold)) !important;
}
.spinner {
position: absolute;
top: 0;
left: 0;
height: 2px;
background: hsl(var(--text));
transition: 0.25s linear;
transition-property: width, opacity;
z-index: 1024;
user-select: none;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
.select-group {
display: flex;
flex-direction: row;

View File

@ -19,7 +19,13 @@ import { useTranslation } from "react-i18next";
import { useState } from "react";
import { InvitationForm, InvitationResponse } from "@/admin/types.ts";
import { Button } from "@/components/ui/button.tsx";
import { ChevronLeft, ChevronRight, Download, RotateCw } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Download,
Loader2,
RotateCw,
} from "lucide-react";
import { useEffectAsync } from "@/utils/hook.ts";
import { generateInvitation, getInvitationList } from "@/admin/api/chart.ts";
import { Input } from "@/components/ui/input.tsx";
@ -138,10 +144,13 @@ function InvitationTable() {
total: 0,
data: [],
});
const [loading, setLoading] = useState<boolean>(false);
const [page, setPage] = useState<number>(0);
async function update() {
setLoading(true);
const resp = await getInvitationList(page);
setLoading(false);
if (resp.status) setData(resp as InvitationResponse);
else
toast({
@ -201,7 +210,11 @@ function InvitationTable() {
</>
) : (
<div className={`empty`}>
<p>{t("admin.empty")}</p>
{loading ? (
<Loader2 className={`w-6 h-6 inline-block mr-1 animate-spin`} />
) : (
<p>{t("admin.empty")}</p>
)}
</div>
)}
<div className={`invitation-action`}>

View File

@ -18,7 +18,7 @@ import {
import { useTranslation } from "react-i18next";
import { extractMessage } from "@/utils/processor.ts";
import { Button } from "@/components/ui/button.tsx";
import { Plus, RotateCcw } from "lucide-react";
import { Loader2, Plus, RotateCcw } from "lucide-react";
import { useToast } from "@/components/ui/use-toast.ts";
import {
Dialog,
@ -100,9 +100,14 @@ function BroadcastTable() {
const { t } = useTranslation();
const init = useSelector(selectInit);
const [data, setData] = useState<BroadcastInfo[]>([]);
const [loading, setLoading] = useState<boolean>(false);
useEffectAsync(async () => {
if (!init) return;
setLoading(true);
setData(await getBroadcastList());
setLoading(false);
}, [init]);
return (
@ -122,26 +127,36 @@ function BroadcastTable() {
onCreated={async () => setData(await getBroadcastList())}
/>
</div>
<Table>
<TableHeader>
<TableRow className={`select-none whitespace-nowrap`}>
<TableHead>ID</TableHead>
<TableHead>{t("admin.broadcast-content")}</TableHead>
<TableHead>{t("admin.poster")}</TableHead>
<TableHead>{t("admin.post-at")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((user, idx) => (
<TableRow key={idx}>
<TableCell>{user.index}</TableCell>
<TableCell>{extractMessage(user.content, 25)}</TableCell>
<TableCell>{user.poster}</TableCell>
<TableCell>{user.created_at}</TableCell>
{data.length ? (
<Table>
<TableHeader>
<TableRow className={`select-none whitespace-nowrap`}>
<TableHead>ID</TableHead>
<TableHead>{t("admin.broadcast-content")}</TableHead>
<TableHead>{t("admin.poster")}</TableHead>
<TableHead>{t("admin.post-at")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{data.map((user, idx) => (
<TableRow key={idx}>
<TableCell>{user.index}</TableCell>
<TableCell>{extractMessage(user.content, 25)}</TableCell>
<TableCell>{user.poster}</TableCell>
<TableCell>{user.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className={`text-center select-none my-8`}>
{loading ? (
<Loader2 className={`w-6 h-6 inline-block mr-1 animate-spin`} />
) : (
t("admin.empty")
)}
</div>
)}
</div>
);
}

View File

@ -60,7 +60,7 @@ export function Combobox({
<PopoverContent className="w-[320px] max-w-[60vw] p-0" align={align}>
<Command>
<CommandInput placeholder={placeholder} />
<CommandEmpty>{t("conversation.empty")}</CommandEmpty>
<CommandEmpty>{t("admin.empty")}</CommandEmpty>
<CommandList>
{valueList.map((key) => (
<CommandItem

View File

@ -14,7 +14,7 @@ import React from "react";
import { syncSiteInfo } from "@/admin/api/info.ts";
import { getOfflineModels, loadPreferenceModels } from "@/utils/storage.ts";
export const version = "3.8.2";
export const version = "3.8.3";
export const dev: boolean = getDev();
export const deploy: boolean = true;
export let rest_api: string = getRestApi(deploy);

13
app/src/events/spinner.ts Normal file
View File

@ -0,0 +1,13 @@
import { EventCommitter } from "@/events/struct.ts";
export type SpinnerEvent = {
id: number;
type: boolean;
};
export const openSpinnerType = true;
export const closeSpinnerType = false;
export const spinnerEvent = new EventCommitter<SpinnerEvent>({
name: "spinner",
});

View File

@ -2,23 +2,24 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Home from "./routes/Home.tsx";
import NotFound from "./routes/NotFound.tsx";
import Auth from "./routes/Auth.tsx";
import { lazy, Suspense } from "react";
import { Suspense } from "react";
import { useDeeptrain } from "@/utils/env.ts";
import Register from "@/routes/Register.tsx";
import Forgot from "@/routes/Forgot.tsx";
import { lazyFactor } from "@/utils/loader.tsx";
const Generation = lazy(() => import("@/routes/Generation.tsx"));
const Sharing = lazy(() => import("@/routes/Sharing.tsx"));
const Article = lazy(() => import("@/routes/Article.tsx"));
const Generation = lazyFactor(() => import("@/routes/Generation.tsx"));
const Sharing = lazyFactor(() => import("@/routes/Sharing.tsx"));
const Article = lazyFactor(() => import("@/routes/Article.tsx"));
const Admin = lazy(() => import("@/routes/Admin.tsx"));
const Dashboard = lazy(() => import("@/routes/admin/DashBoard.tsx"));
const Market = lazy(() => import("@/routes/admin/Market.tsx"));
const Channel = lazy(() => import("@/routes/admin/Channel.tsx"));
const System = lazy(() => import("@/routes/admin/System.tsx"));
const Charge = lazy(() => import("@/routes/admin/Charge.tsx"));
const Users = lazy(() => import("@/routes/admin/Users.tsx"));
const Broadcast = lazy(() => import("@/routes/admin/Broadcast.tsx"));
const Admin = lazyFactor(() => import("@/routes/Admin.tsx"));
const Dashboard = lazyFactor(() => import("@/routes/admin/DashBoard.tsx"));
const Market = lazyFactor(() => import("@/routes/admin/Market.tsx"));
const Channel = lazyFactor(() => import("@/routes/admin/Channel.tsx"));
const System = lazyFactor(() => import("@/routes/admin/System.tsx"));
const Charge = lazyFactor(() => import("@/routes/admin/Charge.tsx"));
const Users = lazyFactor(() => import("@/routes/admin/Users.tsx"));
const Broadcast = lazyFactor(() => import("@/routes/admin/Broadcast.tsx"));
const router = createBrowserRouter(
[

View File

@ -40,18 +40,20 @@ type MarketForm = Model[];
const generateSeed = () => generateRandomChar(8);
const initialState: MarketForm = [{
id: "",
name: "",
free: false,
auth: false,
description: "",
high_context: false,
default: false,
tag: [],
avatar: modelImages[0],
seed: generateSeed(),
}];
const initialState: MarketForm = [
{
id: "",
name: "",
free: false,
auth: false,
description: "",
high_context: false,
default: false,
tag: [],
avatar: modelImages[0],
seed: generateSeed(),
},
];
function reducer(state: MarketForm, action: any): MarketForm {
switch (action.type) {

45
app/src/spinner.tsx Normal file
View File

@ -0,0 +1,45 @@
import { NProgress } from "@tanem/react-nprogress";
import { useDispatch, useSelector } from "react-redux";
import { decreaseTask, increaseTask, selectIsTasking } from "@/store/auth.ts";
import { useEffect } from "react";
import {
closeSpinnerType,
openSpinnerType,
spinnerEvent,
} from "@/events/spinner.ts";
function Spinner() {
const dispatch = useDispatch();
useEffect(() => {
spinnerEvent.bind((event) => {
switch (event.type) {
case openSpinnerType:
dispatch(increaseTask(event.id));
break;
case closeSpinnerType:
dispatch(decreaseTask(event.id));
break;
}
});
}, []);
const isAnimating = useSelector(selectIsTasking);
return (
<NProgress isAnimating={isAnimating}>
{({ animationDuration, isFinished, progress }) => (
<div
className={`spinner`}
style={{
opacity: isFinished ? 0 : 1,
transitionDuration: `${animationDuration}ms`,
width: `${progress * 100}vw`,
}}
></div>
)}
</NProgress>
);
}
export default Spinner;

View File

@ -13,6 +13,7 @@ export const authSlice = createSlice({
authenticated: false,
admin: false,
username: "",
tasks: [] as number[],
},
reducers: {
setToken: (state, action) => {
@ -39,6 +40,15 @@ export const authSlice = createSlice({
state.username = action.payload.username as string;
state.admin = action.payload.admin as boolean;
},
increaseTask: (state, action) => {
state.tasks.push(action.payload as number);
},
decreaseTask: (state, action) => {
state.tasks = state.tasks.filter((v) => v !== (action.payload as number));
},
clearTask: (state) => {
state.tasks = [];
},
logout: (state) => {
state.token = "";
state.authenticated = false;
@ -93,6 +103,10 @@ export const selectAuthenticated = (state: RootState) =>
export const selectUsername = (state: RootState) => state.auth.username;
export const selectInit = (state: RootState) => state.auth.init;
export const selectAdmin = (state: RootState) => state.auth.admin;
export const selectTasks = (state: RootState) => state.auth.tasks;
export const selectTasksLength = (state: RootState) => state.auth.tasks.length;
export const selectIsTasking = (state: RootState) =>
state.auth.tasks.length > 0;
export const {
setToken,
@ -102,5 +116,8 @@ export const {
setInit,
setAdmin,
updateData,
increaseTask,
decreaseTask,
clearTask,
} = authSlice.actions;
export default authSlice.reducer;

View File

@ -5,7 +5,6 @@ import { login } from "@/conf.ts";
export let event: BeforeInstallPromptEvent | undefined;
window.addEventListener("beforeinstallprompt", (e: Event) => {
// e.preventDefault();
console.debug(`[service] catch event from app install prompt`);
event = e as BeforeInstallPromptEvent;
});

View File

@ -61,6 +61,14 @@ export function generateRandomChar(n: number): string {
.join("");
}
export function generateInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
export function generateListNumber(n: number): number {
return generateInt(Math.pow(10, n - 1), Math.pow(10, n) - 1);
}
export function isUrl(value: string): boolean {
value = value.trim();
if (!value.length) return false;

47
app/src/utils/loader.tsx Normal file
View File

@ -0,0 +1,47 @@
import React from "react";
import {
closeSpinnerType,
openSpinnerType,
spinnerEvent,
} from "@/events/spinner.ts";
import { generateListNumber } from "@/utils/base.ts";
export function lazyFactor<T extends React.ComponentType<any>>(
factor: () => Promise<{ default: T }>,
): React.LazyExoticComponent<T> {
/**
* Lazy load factor
* @see https://reactjs.org/docs/code-splitting.html#reactlazy
*
* @example
* lazyFactor(() => import("./factor.tsx"));
*/
return React.lazy(() => {
return new Promise((resolve, reject) => {
const task = generateListNumber(6);
const id = setTimeout(
() =>
spinnerEvent.emit({
id: task,
type: openSpinnerType,
}),
1000,
);
factor()
.then((module) => {
clearTimeout(id);
spinnerEvent.emit({
id: task,
type: closeSpinnerType,
});
resolve(module);
})
.catch((error) => {
console.warn(`[factor] cannot load factor: ${error}`);
reject(error);
});
});
});
}

View File

@ -30,7 +30,28 @@ export function setOfflineModels(models: Model[]): void {
setMemory("model_offline", JSON.stringify(models));
}
export function parseOfflineModels(models: string): Model[] {
const parsed = JSON.parse(models);
if (!Array.isArray(parsed)) return [];
return parsed
.map((item): Model | null => {
if (!item || typeof item !== "object") return null;
return {
id: item.id || "",
name: item.name || "",
description: item.description || "",
free: item.free || false,
auth: item.auth || false,
default: item.default || false,
high_context: item.high_context || false,
avatar: item.avatar || "",
tag: item.tag || [],
} as Model;
})
.filter((item): item is Model => item !== null);
}
export function getOfflineModels(): Model[] {
const memory = getMemory("model_offline");
return memory.length ? (JSON.parse(memory) as Model[]) : [];
return memory.length ? parseOfflineModels(memory) : [];
}