fix(*): 模型与数据同步 - 完善鉴权、供应商模型和本地持久化能力

This commit is contained in:
ddCat
2026-08-20 22:58:31 +08:00
parent 36666a53a2
commit 11ee5aafec
30 changed files with 688 additions and 203 deletions

View File

@@ -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")}>

View File

@@ -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"]';

View File

@@ -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()} />
</>
);
}

View File

@@ -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 />;
}