From 90345076be2f90b4a82a1d3adc621f8e3c3c1120 Mon Sep 17 00:00:00 2001 From: Zhang Minghan Date: Wed, 10 Jan 2024 17:40:32 +0800 Subject: [PATCH] feat: loading spinner and fix offline models --- app/package.json | 1 + app/pnpm-lock.yaml | 15 +++++ app/src-tauri/tauri.conf.json | 2 +- app/src/App.tsx | 4 +- app/src/admin/api/chart.ts | 11 ++-- app/src/assets/ui.less | 14 +++++ app/src/components/admin/InvitationTable.tsx | 17 +++++- .../admin/assemblies/BroadcastTable.tsx | 55 ++++++++++++------- app/src/components/ui/combo-box.tsx | 2 +- app/src/conf.ts | 2 +- app/src/events/spinner.ts | 13 +++++ app/src/router.tsx | 25 +++++---- app/src/routes/admin/Market.tsx | 26 +++++---- app/src/spinner.tsx | 45 +++++++++++++++ app/src/store/auth.ts | 17 ++++++ app/src/utils/app.ts | 1 - app/src/utils/base.ts | 8 +++ app/src/utils/loader.tsx | 47 ++++++++++++++++ app/src/utils/storage.ts | 23 +++++++- 19 files changed, 271 insertions(+), 57 deletions(-) create mode 100644 app/src/events/spinner.ts create mode 100644 app/src/spinner.tsx create mode 100644 app/src/utils/loader.tsx diff --git a/app/package.json b/app/package.json index fbbc451..6ad0dcf 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index ac9940d..98c4141 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -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'} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index 91b2382..2b6a6d9 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "chatnio", - "version": "3.8.2" + "version": "3.8.3" }, "tauri": { "allowlist": { diff --git a/app/src/App.tsx b/app/src/App.tsx index 08cf09b..4360479 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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 ( - + + ); diff --git a/app/src/admin/api/chart.ts b/app/src/admin/api/chart.ts index 801ad82..944f730 100644 --- a/app/src/admin/api/chart.ts +++ b/app/src/admin/api/chart.ts @@ -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 { const response = await axios.get("/admin/analytics/info"); @@ -65,17 +66,17 @@ export async function getErrorChart(): Promise { export async function getInvitationList( page: number, ): Promise { - 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( diff --git a/app/src/assets/ui.less b/app/src/assets/ui.less index 15baf7f..e6f4cc9 100644 --- a/app/src/assets/ui.less +++ b/app/src/assets/ui.less @@ -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; diff --git a/app/src/components/admin/InvitationTable.tsx b/app/src/components/admin/InvitationTable.tsx index 9909130..1fc1eab 100644 --- a/app/src/components/admin/InvitationTable.tsx +++ b/app/src/components/admin/InvitationTable.tsx @@ -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(false); const [page, setPage] = useState(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() { ) : (
-

{t("admin.empty")}

+ {loading ? ( + + ) : ( +

{t("admin.empty")}

+ )}
)}
diff --git a/app/src/components/admin/assemblies/BroadcastTable.tsx b/app/src/components/admin/assemblies/BroadcastTable.tsx index 8040712..e4a540f 100644 --- a/app/src/components/admin/assemblies/BroadcastTable.tsx +++ b/app/src/components/admin/assemblies/BroadcastTable.tsx @@ -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([]); + const [loading, setLoading] = useState(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())} />
- - - - ID - {t("admin.broadcast-content")} - {t("admin.poster")} - {t("admin.post-at")} - - - - {data.map((user, idx) => ( - - {user.index} - {extractMessage(user.content, 25)} - {user.poster} - {user.created_at} + {data.length ? ( +
+ + + ID + {t("admin.broadcast-content")} + {t("admin.poster")} + {t("admin.post-at")} - ))} - -
+ + + {data.map((user, idx) => ( + + {user.index} + {extractMessage(user.content, 25)} + {user.poster} + {user.created_at} + + ))} + + + ) : ( +
+ {loading ? ( + + ) : ( + t("admin.empty") + )} +
+ )} ); } diff --git a/app/src/components/ui/combo-box.tsx b/app/src/components/ui/combo-box.tsx index 3e1adf3..1ace595 100644 --- a/app/src/components/ui/combo-box.tsx +++ b/app/src/components/ui/combo-box.tsx @@ -60,7 +60,7 @@ export function Combobox({ - {t("conversation.empty")} + {t("admin.empty")} {valueList.map((key) => ( ({ + name: "spinner", +}); diff --git a/app/src/router.tsx b/app/src/router.tsx index 3f316f4..3b3943f 100644 --- a/app/src/router.tsx +++ b/app/src/router.tsx @@ -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( [ diff --git a/app/src/routes/admin/Market.tsx b/app/src/routes/admin/Market.tsx index a752543..da0630c 100644 --- a/app/src/routes/admin/Market.tsx +++ b/app/src/routes/admin/Market.tsx @@ -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) { diff --git a/app/src/spinner.tsx b/app/src/spinner.tsx new file mode 100644 index 0000000..017bc43 --- /dev/null +++ b/app/src/spinner.tsx @@ -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 ( + + {({ animationDuration, isFinished, progress }) => ( +
+ )} +
+ ); +} + +export default Spinner; diff --git a/app/src/store/auth.ts b/app/src/store/auth.ts index 5bb85fd..30ecd2a 100644 --- a/app/src/store/auth.ts +++ b/app/src/store/auth.ts @@ -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; diff --git a/app/src/utils/app.ts b/app/src/utils/app.ts index 3a984d8..bdd8bda 100644 --- a/app/src/utils/app.ts +++ b/app/src/utils/app.ts @@ -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; }); diff --git a/app/src/utils/base.ts b/app/src/utils/base.ts index fb03788..abab538 100644 --- a/app/src/utils/base.ts +++ b/app/src/utils/base.ts @@ -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; diff --git a/app/src/utils/loader.tsx b/app/src/utils/loader.tsx new file mode 100644 index 0000000..5720632 --- /dev/null +++ b/app/src/utils/loader.tsx @@ -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>( + factor: () => Promise<{ default: T }>, +): React.LazyExoticComponent { + /** + * 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); + }); + }); + }); +} diff --git a/app/src/utils/storage.ts b/app/src/utils/storage.ts index c75ce7e..e14207b 100644 --- a/app/src/utils/storage.ts +++ b/app/src/utils/storage.ts @@ -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) : []; }