fix(*): 模型与数据同步 - 完善鉴权、供应商模型和本地持久化能力
This commit is contained in:
@@ -6,14 +6,11 @@ import { WorkspaceCommandPalette } from "@/components/layout/workspace-command-p
|
||||
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
|
||||
import { WorkspaceTopBar } from "@/components/layout/workspace-top-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { refreshFeatureAvailability } from "@/lib/user-session";
|
||||
import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const user = useUserStore((state) => state.user);
|
||||
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
|
||||
const [desktopSidebarCollapsed, setDesktopSidebarCollapsed] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
@@ -60,17 +57,6 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener("workspace:navigate", handleWorkspaceNavigation);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const refresh = () => void refreshFeatureAvailability().catch((error) => console.warn("功能开放状态刷新失败", error));
|
||||
const timer = window.setInterval(refresh, 30_000);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
|
||||
|
||||
@@ -8,12 +8,13 @@ import { useLocalDreaminaModelBootstrap } from "@/stores/use-local-dreamina-mode
|
||||
import { useLocalRuntimeBootstrap } from "@/stores/use-local-runtime-store";
|
||||
|
||||
export function ClientRootInit({ children }: { children: ReactNode }) {
|
||||
useLocalRuntimeBootstrap();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const localRuntimeConfigured = config.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false);
|
||||
useLocalRuntimeBootstrap(localRuntimeConfigured);
|
||||
useLocalDreaminaModelBootstrap();
|
||||
const { message } = App.useApp();
|
||||
const handledConfigParams = useRef(false);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const config = useConfigStore((state) => state.config);
|
||||
|
||||
useEffect(() => {
|
||||
const interactiveSelector = 'button, [role="button"], a, [class*="card"], [class*="Card"]';
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Tag } from "antd";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { AnnouncementTimelineModal } from "@/components/ui/aceternity/announcement-timeline-modal";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { getAnnouncementFeed, markAnnouncementsRead, type SystemAnnouncement } from "@/services/api/announcements";
|
||||
import { getAnnouncementFeed, markAnnouncementsRead } from "@/services/api/announcements";
|
||||
|
||||
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 60_000;
|
||||
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
|
||||
|
||||
type AnnouncementFeed = Awaited<ReturnType<typeof getAnnouncementFeed>>;
|
||||
|
||||
type SystemAnnouncementCenterProps = {
|
||||
userId: string;
|
||||
@@ -20,63 +24,31 @@ type SystemAnnouncementCenterProps = {
|
||||
|
||||
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const activeUserIdRef = useRef(userId);
|
||||
activeUserIdRef.current = userId;
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [announcements, setAnnouncements] = useState<SystemAnnouncement[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async (showLoading = false) => {
|
||||
if (!userId) return null;
|
||||
const requestUserId = userId;
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const feed = await getAnnouncementFeed();
|
||||
if (activeUserIdRef.current !== requestUserId) return null;
|
||||
setAnnouncements(feed.announcements || []);
|
||||
setUnreadCount(Math.max(0, feed.unreadCount || 0));
|
||||
setError("");
|
||||
return feed;
|
||||
} catch (requestError) {
|
||||
if (activeUserIdRef.current !== requestUserId) return null;
|
||||
setError(requestError instanceof Error ? requestError.message : "读取公告失败");
|
||||
return null;
|
||||
} finally {
|
||||
if (showLoading && activeUserIdRef.current === requestUserId) setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
setAnnouncements([]);
|
||||
setUnreadCount(0);
|
||||
setError("");
|
||||
if (!userId) return;
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), ANNOUNCEMENT_REFRESH_INTERVAL_MS);
|
||||
const onFocus = () => void refresh();
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [refresh, userId]);
|
||||
const queryKey = ["system-announcements", userId] as const;
|
||||
const feedQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: getAnnouncementFeed,
|
||||
enabled: Boolean(userId),
|
||||
staleTime: ANNOUNCEMENT_CACHE_TTL_MS,
|
||||
refetchInterval: ANNOUNCEMENT_REFRESH_INTERVAL_MS,
|
||||
// 公告在打开面板时会显式 refetch;不把浏览器 focus 变成每个工作区实例的请求触发器。
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const announcements = feedQuery.data?.announcements || [];
|
||||
const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0);
|
||||
const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : "";
|
||||
|
||||
const openAnnouncements = async () => {
|
||||
setOpen(true);
|
||||
const feed = await refresh(announcements.length === 0);
|
||||
const feed = (await feedQuery.refetch()).data;
|
||||
if (!feed?.unreadCount) return;
|
||||
try {
|
||||
const result = await markAnnouncementsRead(feed.announcements.map((announcement) => announcement.id));
|
||||
const nextUnreadCount = Math.max(0, result.unreadCount || 0);
|
||||
setUnreadCount(nextUnreadCount);
|
||||
if (nextUnreadCount > 0) void refresh();
|
||||
queryClient.setQueryData<AnnouncementFeed>(queryKey, (current) => current ? { ...current, unreadCount: nextUnreadCount } : current);
|
||||
if (nextUnreadCount > 0) void queryClient.invalidateQueries({ queryKey });
|
||||
} catch {
|
||||
// 已读状态是辅助读路径,失败时保留角标,下一次打开或轮询会继续尝试同步。
|
||||
}
|
||||
@@ -118,7 +90,7 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
|
||||
</span>
|
||||
) : null}
|
||||
</motion.button>
|
||||
<AnnouncementTimelineModal open={open} announcements={announcements} loading={loading} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void refresh(true)} />
|
||||
<AnnouncementTimelineModal open={open} announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type ComponentType, type SVGProps } from "react";
|
||||
import { useEffect, useMemo, useState, type ComponentType, type SVGProps } from "react";
|
||||
import { Input, Popover } from "antd";
|
||||
import { Cpu, Search, X } from "lucide-react";
|
||||
|
||||
@@ -8,22 +8,53 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type LobeIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
|
||||
|
||||
// 只加载各图标的 Mono 组件;完整 icons barrel 会经由 Avatar/Combine 与 @lobehub/ui 形成循环依赖,Bun 测试环境会触发未初始化导出。
|
||||
const iconModules = typeof import.meta.glob === "function"
|
||||
? import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { eager: true, import: "default" })
|
||||
: {};
|
||||
const iconRegistry = Object.fromEntries(
|
||||
// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块,
|
||||
// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。
|
||||
const iconModules = import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
|
||||
const iconLoaders = Object.fromEntries(
|
||||
Object.entries(iconModules)
|
||||
.map(([path, icon]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], icon])
|
||||
.filter((entry): entry is [string, LobeIconComponent] => Boolean(entry[0] && entry[1])),
|
||||
) as Record<string, LobeIconComponent>;
|
||||
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader])
|
||||
.filter((entry): entry is [string, () => Promise<LobeIconComponent>] => Boolean(entry[0] && entry[1])),
|
||||
) as Record<string, () => Promise<LobeIconComponent>>;
|
||||
const iconRegistry = new Map<string, LobeIconComponent>();
|
||||
const iconLoadPromises = new Map<string, Promise<LobeIconComponent | undefined>>();
|
||||
const iconOptions = toc
|
||||
.filter((item) => item.group === "model" || item.group === "provider")
|
||||
.map((item) => ({ id: item.id, title: item.fullTitle || item.title }))
|
||||
.filter((item) => Boolean(iconRegistry[item.id]));
|
||||
.filter((item) => Boolean(iconLoaders[item.id]));
|
||||
|
||||
function loadIcon(icon?: string) {
|
||||
if (!icon) return Promise.resolve(undefined);
|
||||
const cached = iconRegistry.get(icon);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
const existing = iconLoadPromises.get(icon);
|
||||
if (existing) return existing;
|
||||
const loader = iconLoaders[icon];
|
||||
if (!loader) return Promise.resolve(undefined);
|
||||
const promise = loader()
|
||||
.then((module) => {
|
||||
const loaded = (module as unknown as { default?: LobeIconComponent }).default || module;
|
||||
iconRegistry.set(icon, loaded);
|
||||
return loaded;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
iconLoadPromises.set(icon, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function ModelLogo({ icon, size = 18, className }: { icon?: string; size?: number; className?: string }) {
|
||||
const Icon = icon ? iconRegistry[icon] : undefined;
|
||||
const [Icon, setIcon] = useState<LobeIconComponent | undefined>(() => icon ? iconRegistry.get(icon) : undefined);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIcon(icon ? iconRegistry.get(icon) : undefined);
|
||||
if (!icon || !iconLoaders[icon]) return () => { cancelled = true; };
|
||||
void loadIcon(icon).then((loaded) => {
|
||||
if (!cancelled) setIcon(loaded);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [icon]);
|
||||
if (!Icon) return <Cpu className={cn("shrink-0 text-foreground/45", className)} size={size} aria-hidden />;
|
||||
return <Icon size={size} className={cn("shrink-0", className)} aria-hidden />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user