feat: 优化交互
This commit is contained in:
@@ -195,6 +195,8 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
|
||||
| 登录页入场 | 点阵场淡入 → 卡片上浮 → 标语逐字打出 | variants `delayChildren / staggerChildren`、`TypeText` |
|
||||
| 资源卡片 | 角标准星:悬停出现,移到下一张时滑过去(120ms 内穿过间隙不隐藏),离开消失;键盘聚焦同样跟随;虚拟列表重新测量后自动贴回 | `useFollowHighlight` + `Reticle`(`bg-corner-brackets`) |
|
||||
| 视频封面 | 光标附近的点阵亮成翠绿 | `useSpotlight`(`useMotionTemplate` 径向遮罩)+ `bg-dot-grid` 的 `--dot-color / --dot-size` |
|
||||
| 上次播放 | 打开过的视频封面底色染成翠绿、点阵常亮并标「上次播放」;关掉弹窗后仍保留,换看另一个时转移过去(只标一个,刷新页面后清除) | `LibraryList` 持有 `lastPlayed`,经行 prop `playedFileId` 传给 `FileCard`;封面用 `data-played` 切换样式 |
|
||||
| 图片封面加载 | 图片就绪前,翠绿点阵被一道光带反复扫亮并显示「加载中」;加载超过 0.15s 才出现,缓存命中不闪;就绪后图片淡入、覆盖层淡出;减弱动态效果时只留静态点阵与转圈 | `ImageCover` + `IMAGE_LOADING`(`animate` 驱动 MotionValue 生成遮罩,不触发 React 渲染) |
|
||||
| 卡片行 | 行挂载时(首屏、筛选、展开)按列依次浮现 | `FileGrid animated` + `FileGridItem` |
|
||||
| 工具栏数字 | 资源数、文件数、总大小、来源计数滚动到新值 | `AnimatedNumber` |
|
||||
| 分组标题 | 24 小时内加入的资源显示呼吸的 NEW | `NewBadge`,以数据拉取时间为「现在」 |
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
FileIcon,
|
||||
HistoryIcon,
|
||||
HourglassIcon,
|
||||
ImageOffIcon,
|
||||
MonitorPlayIcon,
|
||||
@@ -24,7 +25,9 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger
|
||||
} from "@/components/ui/tooltip";
|
||||
import { preloadVideoPlayer } from "@/components/video-player-loader";
|
||||
import { SPOTLIGHT } from "@/constants/motion";
|
||||
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
|
||||
import { useCopy } from "@/hooks/use-copy";
|
||||
import type { LibraryFileView } from "@/hooks/use-library";
|
||||
import { useOrigin } from "@/hooks/use-origin";
|
||||
@@ -34,6 +37,7 @@ import { formatBytes } from "@/utils/format";
|
||||
import type { DownloadStatus } from "@/types/torbox";
|
||||
import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media";
|
||||
|
||||
import { ImageCover } from "./image-cover";
|
||||
import { CARD_FOCUS_PROPS } from "./library-dom";
|
||||
|
||||
const ExtLabel = ({ ext }: { ext: string }) => (
|
||||
@@ -57,18 +61,40 @@ const PlaceholderCover = ({
|
||||
/**
|
||||
* 视频封面:点击在网页内播放。点阵底纹上叠一层翠绿点阵,用跟随鼠标的径向遮罩只露出光标附近的部分。
|
||||
* 两层同格叠放、内边距放在内容层,保证点阵对齐;按钮内只能放行内元素,层都用 span。
|
||||
* played:刚看过的那个视频,底色染上翠绿并标出「上次播放」,关掉弹窗后一眼能找回。
|
||||
*/
|
||||
const VideoCover = ({ ext, onPlay }: { ext: string; onPlay: () => void }) => {
|
||||
const VideoCover = ({
|
||||
ext,
|
||||
played,
|
||||
onPlay
|
||||
}: {
|
||||
ext: string;
|
||||
played: boolean;
|
||||
onPlay: () => void;
|
||||
}) => {
|
||||
const { maskImage, opacity, handlers } = useSpotlight(SPOTLIGHT);
|
||||
// 指向或聚焦时提前下载播放器;浏览器解不了的格式会走 HLS,一并预热引擎
|
||||
const preload = () => preloadVideoPlayer(!BROWSER_VIDEO_EXT.has(ext));
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="播放"
|
||||
className="group/cover grid aspect-video bg-muted bg-dot-grid *:col-start-1 *:row-start-1"
|
||||
className="group/cover grid aspect-video bg-muted bg-dot-grid transition-colors duration-300 *:col-start-1 *:row-start-1 data-[played]:bg-[color-mix(in_oklab,var(--primary)_16%,var(--muted))]"
|
||||
data-played={played ? "" : undefined}
|
||||
onClick={onPlay}
|
||||
{...handlers}
|
||||
onPointerEnter={(event) => {
|
||||
handlers.onPointerEnter(event);
|
||||
preload();
|
||||
}}
|
||||
onFocus={preload}
|
||||
>
|
||||
{/* 看过的封面:整片点阵常亮为翠绿 */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-dot-grid opacity-0 transition-opacity duration-300 [--dot-color:var(--primary)] [--dot-size:1.5px] group-data-[played]/cover:opacity-45"
|
||||
/>
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.5px]"
|
||||
@@ -81,7 +107,18 @@ const VideoCover = ({ ext, onPlay }: { ext: string; onPlay: () => void }) => {
|
||||
<span className="flex size-11 items-center justify-center self-center rounded-full bg-primary text-primary-foreground transition-[scale,box-shadow] group-hover/cover:scale-110 group-hover/cover:shadow-[0_0_0_6px] group-hover/cover:shadow-primary/20">
|
||||
<PlayIcon className="size-5 fill-current" aria-hidden="true" />
|
||||
</span>
|
||||
{played ? (
|
||||
<motion.span
|
||||
className="flex h-5 items-center gap-1 font-mono text-xs text-primary"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<HistoryIcon className="size-3.5" aria-hidden="true" />
|
||||
上次播放
|
||||
</motion.span>
|
||||
) : (
|
||||
<span className="h-5" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -155,9 +192,17 @@ type FileCardProps = {
|
||||
status: DownloadStatus;
|
||||
/** 打开文件:视频进入网页播放器,图片进入预览灯箱;两者都由列表统一渲染 */
|
||||
onOpen: () => void;
|
||||
/** 这是最近播放的视频,封面高亮 */
|
||||
played?: boolean;
|
||||
};
|
||||
|
||||
export const FileCard = ({ file, token, status, onOpen }: FileCardProps) => {
|
||||
export const FileCard = ({
|
||||
file,
|
||||
token,
|
||||
status,
|
||||
onOpen,
|
||||
played = false
|
||||
}: FileCardProps) => {
|
||||
const origin = useOrigin();
|
||||
const copy = useCopy();
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
@@ -182,24 +227,15 @@ export const FileCard = ({ file, token, status, onOpen }: FileCardProps) => {
|
||||
/>
|
||||
);
|
||||
} else if (file.kind === "video") {
|
||||
cover = <VideoCover ext={file.ext} onPlay={onOpen} />;
|
||||
cover = <VideoCover ext={file.ext} played={played} onPlay={onOpen} />;
|
||||
} else if (file.kind === "image" && !imageFailed) {
|
||||
cover = (
|
||||
<button
|
||||
type="button"
|
||||
title="预览图片"
|
||||
className="flex aspect-video overflow-hidden bg-muted"
|
||||
onClick={onOpen}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ImageCover
|
||||
src={streamUrl}
|
||||
alt={file.displayName}
|
||||
loading="lazy"
|
||||
className="size-full object-cover transition-transform hover:scale-105"
|
||||
onOpen={onOpen}
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
cover = (
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// 图片封面:加载中用点阵扫光 + 转圈提示,加载完成后淡入
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AnimatePresence,
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform
|
||||
} from "motion/react";
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { IMAGE_LOADING } from "@/constants/motion";
|
||||
|
||||
/** 加载中的覆盖层:翠绿点阵上有一道光带从左扫到右,中间是转圈与文字 */
|
||||
const LoadingOverlay = () => {
|
||||
const reduced = useReducedMotion();
|
||||
const progress = useMotionValue(0);
|
||||
// 光带在 -40% ~ 140% 之间移动,起止都在封面之外
|
||||
const maskImage = useTransform(progress, (value) => {
|
||||
const head = value * 180 - 40;
|
||||
return `linear-gradient(100deg, transparent ${head - 40}%, #000 ${head - 15}%, transparent ${head}%)`;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced) return;
|
||||
const controls = animate(progress, 1, IMAGE_LOADING.sweep);
|
||||
return () => controls.stop();
|
||||
}, [reduced, progress]);
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className="grid grid-cols-1 grid-rows-1 *:col-start-1 *:row-start-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: IMAGE_LOADING.showDelay } }}
|
||||
exit={{ opacity: 0, transition: IMAGE_LOADING.fade }}
|
||||
>
|
||||
<span className="bg-dot-grid opacity-60 [--dot-color:var(--primary)] [--dot-size:1px]" />
|
||||
<motion.span
|
||||
className="bg-dot-grid [--dot-color:var(--primary)] [--dot-size:1.8px]"
|
||||
style={{ maskImage: reduced ? undefined : maskImage }}
|
||||
/>
|
||||
<span className="flex items-center justify-center">
|
||||
<span className="flex items-center gap-1.5 rounded-full bg-background/70 py-1 pr-2.5 pl-2 font-mono text-xs text-primary backdrop-blur-sm">
|
||||
<Spinner className="size-3.5" />
|
||||
加载中
|
||||
</span>
|
||||
</span>
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
type ImageCoverProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
onOpen: () => void;
|
||||
onError: () => void;
|
||||
};
|
||||
|
||||
/** 图片封面:点击打开预览灯箱;图片就绪前显示加载状态,就绪后淡入 */
|
||||
export const ImageCover = ({ src, alt, onOpen, onError }: ImageCoverProps) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="预览图片"
|
||||
className="grid aspect-video grid-cols-1 grid-rows-1 overflow-hidden bg-muted *:col-start-1 *:row-start-1"
|
||||
aria-busy={!loaded}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<AnimatePresence>{!loaded && <LoadingOverlay />}</AnimatePresence>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
className="size-full object-cover opacity-0 transition-[opacity,scale] duration-300 hover:scale-105 data-[loaded]:opacity-100"
|
||||
data-loaded={loaded ? "" : undefined}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={onError}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -35,7 +35,7 @@ export const ItemDownloadStatus = ({ item }: { item: LibraryItemView }) => {
|
||||
className="hidden w-20 sm:flex"
|
||||
/>
|
||||
<span className="font-mono tabular-nums min-w-8">{percent}%</span>
|
||||
<span>{label}</span>
|
||||
<span className="max-sm:hidden">{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
// 资源库图片预览:一个资源分组(抽屉)内的图片为一组,只在组内切换
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { ImageLightbox, type LightboxImage } from "@/components/image-lightbox";
|
||||
import type { LightboxImage } from "@/components/image-lightbox";
|
||||
import type { LibraryItemView } from "@/hooks/use-library";
|
||||
import { useOrigin } from "@/hooks/use-origin";
|
||||
import { buildStreamUrl } from "@/utils/media";
|
||||
|
||||
import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
|
||||
|
||||
/** 灯箱连同样式较大,第一次预览图片时才加载 */
|
||||
const ImageLightbox = dynamic(
|
||||
() => import("@/components/image-lightbox").then((mod) => mod.ImageLightbox),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type LibraryImagePreviewProps = {
|
||||
/** 列表当前展示的资源(已筛选、排序),组内图片顺序与抽屉中一致 */
|
||||
items: LibraryItemView[];
|
||||
@@ -51,6 +58,11 @@ export const LibraryImagePreview = ({
|
||||
? files.findIndex((file) => file.id === target.fileId)
|
||||
: -1;
|
||||
|
||||
// 首次打开后保持挂载,关闭时才有退场动画
|
||||
const [activated, setActivated] = useState(false);
|
||||
if (index >= 0 && !activated) setActivated(true);
|
||||
if (!activated) return null;
|
||||
|
||||
return (
|
||||
<ImageLightbox
|
||||
images={images}
|
||||
|
||||
@@ -145,6 +145,8 @@ type RowViewProps = {
|
||||
index: number;
|
||||
columns: number;
|
||||
token: string;
|
||||
/** 本行里最近播放的视频(文件 id),没有则为 null */
|
||||
playedFileId: number | null;
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
measure: (element: HTMLDivElement | null) => void;
|
||||
onToggle: (row: HeaderRow, index: number) => void;
|
||||
@@ -157,6 +159,7 @@ const RowView = memo(function RowView({
|
||||
index,
|
||||
columns,
|
||||
token,
|
||||
playedFileId,
|
||||
scrollRef,
|
||||
measure,
|
||||
onToggle,
|
||||
@@ -202,6 +205,7 @@ const RowView = memo(function RowView({
|
||||
file={file}
|
||||
token={token}
|
||||
status={row.status}
|
||||
played={file.id === playedFileId}
|
||||
onOpen={() =>
|
||||
onOpen({
|
||||
itemKey: row.itemKey,
|
||||
@@ -376,6 +380,13 @@ const VirtualRows = ({
|
||||
// 全屏、缩放窗口会改变列数并重建卡片行,放在卡片里会被卸载、中断播放
|
||||
const [opened, setOpened] = useState<OpenedFile | null>(null);
|
||||
const closeOpened = useCallback(() => setOpened(null), []);
|
||||
// 最近播放的视频:关掉弹窗后封面保持高亮,方便找回刚看的是哪个;换一个视频后转移过去
|
||||
const [lastPlayed, setLastPlayed] = useState<LibraryFileTarget | null>(null);
|
||||
const openFile = useCallback((file: OpenedFile) => {
|
||||
setOpened(file);
|
||||
if (file.kind === "video")
|
||||
setLastPlayed({ itemKey: file.itemKey, fileId: file.fileId });
|
||||
}, []);
|
||||
const changePreview = useCallback(
|
||||
(target: LibraryFileTarget | null) =>
|
||||
setOpened(target && { ...target, kind: "image" }),
|
||||
@@ -424,19 +435,27 @@ const VirtualRows = ({
|
||||
virtualizer.measurementsCache,
|
||||
virtualizer.getTotalSize()
|
||||
);
|
||||
const renderRow = (item: VirtualItem) => (
|
||||
const renderRow = (item: VirtualItem) => {
|
||||
const row = rows[item.index];
|
||||
return (
|
||||
<RowView
|
||||
key={item.key}
|
||||
row={rows[item.index]}
|
||||
row={row}
|
||||
index={item.index}
|
||||
columns={columns}
|
||||
token={token}
|
||||
playedFileId={
|
||||
row.type === "cards" && row.itemKey === lastPlayed?.itemKey
|
||||
? lastPlayed.fileId
|
||||
: null
|
||||
}
|
||||
scrollRef={scrollRef}
|
||||
measure={virtualizer.measureElement}
|
||||
onToggle={onToggleHeader}
|
||||
onOpen={setOpened}
|
||||
onOpen={openFile}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const renderPiece = (piece: RowPiece) =>
|
||||
piece.type === "row" ? (
|
||||
renderRow(piece.item)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 资源库视频网页播放弹窗
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DownloadIcon, MonitorPlayIcon } from "lucide-react";
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
importVideoPlayer,
|
||||
preloadVideoPlayer
|
||||
} from "@/components/video-player-loader";
|
||||
import { BROWSER_VIDEO_EXT } from "@/constants/torbox";
|
||||
import type { LibraryItemView } from "@/hooks/use-library";
|
||||
import { useOrigin } from "@/hooks/use-origin";
|
||||
@@ -27,13 +31,10 @@ import { findLibraryItem, type LibraryFileTarget } from "./library-rows";
|
||||
const PLAYER_CLASS = "aspect-video max-h-[calc(100dvh-13rem)] w-full";
|
||||
|
||||
/** 播放器较大,打开弹窗时才加载 */
|
||||
const VideoPlayer = dynamic(
|
||||
() => import("@/components/video-player").then((mod) => mod.VideoPlayer),
|
||||
{
|
||||
const VideoPlayer = dynamic(importVideoPlayer, {
|
||||
ssr: false,
|
||||
loading: () => <Skeleton className={PLAYER_CLASS} />
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
type LibraryVideoDialogProps = {
|
||||
items: LibraryItemView[];
|
||||
@@ -72,6 +73,13 @@ export const LibraryVideoDialog = ({
|
||||
? buildHlsUrl(origin, item.source, item.id, file.id, token)
|
||||
: null;
|
||||
|
||||
// 播放器与 HLS 引擎并行下载,不等播放器渲染出来再请求引擎
|
||||
const ready = streamUrl !== null;
|
||||
const needsHls = hlsUrl !== null;
|
||||
useEffect(() => {
|
||||
if (ready) preloadVideoPlayer(needsHls);
|
||||
}, [ready, needsHls]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={target !== null && streamUrl !== null}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { useAnimate } from "motion/react";
|
||||
import { EyeIcon, EyeOffIcon, KeyRoundIcon, LogInIcon } from "lucide-react";
|
||||
|
||||
@@ -31,12 +30,10 @@ import {
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { SCRAMBLE, SHAKE } from "@/constants/motion";
|
||||
import { useLogin } from "@/hooks/use-auth";
|
||||
import { isUnauthorized } from "@/lib/axios";
|
||||
|
||||
const FORM_ID = "login-form";
|
||||
|
||||
const isUnauthorized = (error: unknown) =>
|
||||
isAxiosError(error) && error.response?.status === 401;
|
||||
|
||||
/** 表单只通过回调对外通知,不关心外部如何呈现(如背景点阵场) */
|
||||
type LoginFormProps = {
|
||||
/** 演示模式的访问密钥,传入时在输入框下方提示 */
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 视频播放器的按需加载入口:播放器与 hls.js 引擎都是独立 chunk,这里统一加载并支持提前预热
|
||||
// 同一个 import() 只会请求一次 chunk,预热后 next/dynamic 直接命中缓存
|
||||
|
||||
export const importVideoPlayer = () =>
|
||||
import("@/components/video-player").then((mod) => mod.VideoPlayer);
|
||||
|
||||
export const importHlsJsVideo = () =>
|
||||
import("@videojs/react/media/hlsjs-video").then((mod) => mod.HlsJsVideo);
|
||||
|
||||
/** 提前下载播放器 chunk;需要 HLS 时并行下载引擎,避免点开后串行等待 */
|
||||
export const preloadVideoPlayer = (hls: boolean) => {
|
||||
void importVideoPlayer();
|
||||
if (hls) void importHlsJsVideo();
|
||||
};
|
||||
@@ -7,17 +7,14 @@ import { I18nProvider } from "@videojs/react";
|
||||
import zhCN from "@videojs/react/i18n/locales/zh-CN";
|
||||
import { Video, VideoPlayer as Player } from "@videojs/react/video";
|
||||
|
||||
import { importHlsJsVideo } from "@/components/video-player-loader";
|
||||
import { VideoSkin } from "@/components/videojs/video/skin";
|
||||
|
||||
/**
|
||||
* HLS 播放用 hls.js 引擎:Video.js 自带的 HlsVideo 不支持 MPEG-TS 分片。
|
||||
* hls.js 体积较大,只在播放 HLS 时加载。
|
||||
*/
|
||||
const HlsJsVideo = dynamic(
|
||||
() =>
|
||||
import("@videojs/react/media/hlsjs-video").then((mod) => mod.HlsJsVideo),
|
||||
{ ssr: false }
|
||||
);
|
||||
const HlsJsVideo = dynamic(importHlsJsVideo, { ssr: false });
|
||||
|
||||
type VideoPlayerProps = {
|
||||
src: string;
|
||||
|
||||
@@ -140,3 +140,11 @@ export const RIPPLES = {
|
||||
} as const satisfies Record<string, RipplePreset>;
|
||||
|
||||
export type RippleName = keyof typeof RIPPLES;
|
||||
|
||||
/** 图片封面加载中:点阵被一道光带扫亮 */
|
||||
export const IMAGE_LOADING = {
|
||||
/** 加载超过这个时间(秒)才出现,缓存命中时不闪一下 */
|
||||
showDelay: 0.15,
|
||||
fade: { duration: 0.3 },
|
||||
sweep: { duration: 1.4, ease: "linear", repeat: Infinity, repeatDelay: 0.2 }
|
||||
} as const;
|
||||
|
||||
@@ -29,4 +29,8 @@ BaseClientService.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
/** 请求是否因登录态无效(401)失败 */
|
||||
export const isUnauthorized = (error: unknown) =>
|
||||
axios.isAxiosError(error) && error.response?.status === 401;
|
||||
|
||||
export default BaseClientService;
|
||||
|
||||
Reference in New Issue
Block a user