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:
lix-2026
2026-04-17 23:36:24 +08:00
parent cfc3af8984
commit d8de820d93
40 changed files with 4668 additions and 4143 deletions
+62 -24
View File
@@ -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;
}
+3 -2
View File
@@ -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,
};
}
+55 -3
View File
@@ -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,
]);
}