feat: 接入 mnote web tree shell 与主页链路整理
- 增加 mnote-web tree/command 支持与前端 MnoteWebTreeShell 集成 - 调整 sidebar、documents、runtime config 与 dev/prod server 配套逻辑 - 补充 homepage/tree shell smoke 脚本并更新 harness 进度文件
This commit is contained in:
@@ -2,33 +2,71 @@ import { useEffect, useState } from "react";
|
||||
|
||||
type Status = "idle" | "ok" | "error" | "disabled";
|
||||
|
||||
const HEALTH_CACHE_TTL_MS = 15_000;
|
||||
|
||||
let cachedStatus: Status = "idle";
|
||||
let cachedAt = 0;
|
||||
let inflightCheck: Promise<Status> | null = null;
|
||||
const listeners = new Set<(status: Status) => void>();
|
||||
|
||||
function publishStatus(status: Status) {
|
||||
cachedStatus = status;
|
||||
cachedAt = Date.now();
|
||||
listeners.forEach((listener) => listener(status));
|
||||
}
|
||||
|
||||
async function requestBackendHealth(): Promise<Status> {
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const response = await fetch("/api/backend/health", { signal: controller.signal });
|
||||
const payload = (await response.json().catch(() => null)) as { status?: Status } | null;
|
||||
return payload?.status ?? "error";
|
||||
} catch {
|
||||
return "error";
|
||||
} finally {
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBackendHealthFresh(force = false): Promise<Status> {
|
||||
const now = Date.now();
|
||||
if (!force && cachedStatus !== "idle" && now - cachedAt < HEALTH_CACHE_TTL_MS) {
|
||||
return cachedStatus;
|
||||
}
|
||||
if (!inflightCheck) {
|
||||
inflightCheck = requestBackendHealth()
|
||||
.then((status) => {
|
||||
publishStatus(status);
|
||||
return status;
|
||||
})
|
||||
.finally(() => {
|
||||
inflightCheck = null;
|
||||
});
|
||||
}
|
||||
return inflightCheck;
|
||||
}
|
||||
|
||||
export function useBackendHealth() {
|
||||
const [status, setStatus] = useState<Status>(() => {
|
||||
return "idle";
|
||||
});
|
||||
const [status, setStatus] = useState<Status>(() => cachedStatus);
|
||||
|
||||
useEffect(() => {
|
||||
let destroyed = false;
|
||||
const controller = new AbortController();
|
||||
const check = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/backend/health", { signal: controller.signal });
|
||||
const payload = (await response.json().catch(() => null)) as { status?: Status } | null;
|
||||
if (!destroyed) {
|
||||
setStatus(payload?.status ?? "error");
|
||||
}
|
||||
} catch {
|
||||
if (!destroyed) {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
};
|
||||
check();
|
||||
return () => {
|
||||
destroyed = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const listener = (nextStatus: Status) => {
|
||||
if (!destroyed) {
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
};
|
||||
listeners.add(listener);
|
||||
void ensureBackendHealthFresh().then((nextStatus) => {
|
||||
if (!destroyed) {
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
destroyed = true;
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BacklinkRecord } from "@/types/references";
|
||||
interface UseBacklinksOptions {
|
||||
workspaceId: string | null;
|
||||
documentId: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface RawBacklink {
|
||||
@@ -46,7 +47,7 @@ const fetchBacklinks = async (workspaceId: string, documentId: string): Promise<
|
||||
return (payload.backlinks ?? []).map(mapBacklink);
|
||||
};
|
||||
|
||||
export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) {
|
||||
export function useBacklinks({ workspaceId, documentId, enabled = true }: UseBacklinksOptions) {
|
||||
return useQuery({
|
||||
queryKey: ["page-backlinks", workspaceId, documentId],
|
||||
queryFn: () => {
|
||||
@@ -55,7 +56,7 @@ export function useBacklinks({ workspaceId, documentId }: UseBacklinksOptions) {
|
||||
}
|
||||
return fetchBacklinks(workspaceId, documentId);
|
||||
},
|
||||
enabled: Boolean(workspaceId && documentId),
|
||||
enabled: Boolean(enabled && workspaceId && documentId),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sid
|
||||
SidebarDatasetListQueryResult
|
||||
>;
|
||||
|
||||
export interface ConvexSidebarDataResult {
|
||||
data: SidebarInitialData | null;
|
||||
isLoading: boolean;
|
||||
isAuthLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
hasLiveSubscription: boolean;
|
||||
canUseHttpFallback: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convex 模式下的侧边栏数据 hook
|
||||
* 使用 Convex 的 useQuery 实现实时订阅,无需手动 refetch
|
||||
@@ -26,13 +37,8 @@ const sidebarDatasetListQuery = ((api as unknown as Record<string, unknown>).sid
|
||||
* 注意:此 hook 仅应在 Convex 模式下使用
|
||||
* 后端从 ctx.auth.getUserIdentity() 获取用户身份,前端无需传入 userId
|
||||
*/
|
||||
export function useConvexSidebarData(workspaceId: string): {
|
||||
data: SidebarInitialData | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
export function useConvexSidebarData(workspaceId: string): ConvexSidebarDataResult {
|
||||
const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();
|
||||
|
||||
// 显式转为 boolean,确保类型正确
|
||||
const shouldFetch = Boolean(isAuthenticated && workspaceId);
|
||||
@@ -81,13 +87,22 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
// 使用 === undefined 判断,因为 skip 时返回 undefined
|
||||
const isLoading = shouldFetch && sidebarDataset === undefined;
|
||||
const error = null;
|
||||
|
||||
// Convex 模式下数据自动实时同步,refetch 是空操作
|
||||
// Convex 的 useQuery 会自动实时同步,无需手动 refetch
|
||||
// 这个方法只是为了保持与 useSidebarData 的接口兼容
|
||||
const refetch = async () => {
|
||||
// 空操作 - Convex 会自动同步数据
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refetch };
|
||||
}
|
||||
const hasLiveSubscription = shouldFetch;
|
||||
const canUseHttpFallback = Boolean(workspaceId) && !isAuthLoading && !isAuthenticated;
|
||||
|
||||
// Convex 模式下数据自动实时同步,refetch 保持空操作以兼容旧接口。
|
||||
const refetch = async () => {
|
||||
// 空操作 - Convex 会自动同步数据
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isAuthLoading,
|
||||
isAuthenticated,
|
||||
hasLiveSubscription,
|
||||
canUseHttpFallback,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||
|
||||
export interface SidebarDataResult {
|
||||
data: SidebarInitialData;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<unknown>;
|
||||
source: "convex-live" | "http-fallback" | "initial";
|
||||
}
|
||||
|
||||
async function requestSidebarData(workspaceId: string): Promise<SidebarInitialData> {
|
||||
const response = await fetch(`/api/sidebar?workspaceId=${workspaceId}`, {
|
||||
@@ -16,13 +26,55 @@ async function requestSidebarData(workspaceId: string): Promise<SidebarInitialDa
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function useSidebarData(initialData: SidebarInitialData): UseQueryResult<SidebarInitialData> {
|
||||
export function useSidebarData(initialData: SidebarInitialData): SidebarDataResult {
|
||||
const workspaceId = initialData.activeWorkspaceId;
|
||||
const convexSidebar = useConvexSidebarData(workspaceId);
|
||||
const shouldUseHttpFallback =
|
||||
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
|
||||
|
||||
return useQuery({
|
||||
const httpQuery = useQuery({
|
||||
queryKey: ["sidebar", workspaceId],
|
||||
queryFn: () => requestSidebarData(workspaceId),
|
||||
initialData,
|
||||
staleTime: 1000 * 60,
|
||||
enabled: shouldUseHttpFallback,
|
||||
});
|
||||
|
||||
return useMemo<SidebarDataResult>(() => {
|
||||
const liveData = convexSidebar.data ?? httpQuery.data ?? initialData;
|
||||
const isLoading =
|
||||
convexSidebar.hasLiveSubscription
|
||||
? convexSidebar.isLoading
|
||||
: shouldUseHttpFallback
|
||||
? httpQuery.isLoading
|
||||
: false;
|
||||
const source: SidebarDataResult["source"] = convexSidebar.data
|
||||
? "convex-live"
|
||||
: shouldUseHttpFallback && httpQuery.data
|
||||
? "http-fallback"
|
||||
: "initial";
|
||||
const refetch = async () => {
|
||||
if (convexSidebar.hasLiveSubscription) {
|
||||
await convexSidebar.refetch();
|
||||
return liveData;
|
||||
}
|
||||
if (shouldUseHttpFallback) {
|
||||
return httpQuery.refetch();
|
||||
}
|
||||
return liveData;
|
||||
};
|
||||
|
||||
return {
|
||||
data: liveData,
|
||||
isLoading,
|
||||
error: convexSidebar.error ?? httpQuery.error ?? null,
|
||||
refetch,
|
||||
source,
|
||||
};
|
||||
}, [
|
||||
convexSidebar,
|
||||
httpQuery,
|
||||
initialData,
|
||||
shouldUseHttpFallback,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user