banner lightbanner dark
约 4,500 字
15 分钟

客邸(Guest House)模块完整开发报告

摘要

本文详细记录了为 Cloudflare Workers 博客系统新增“客邸”模块的开发全过程。该模块用于独立展示多位作者文章,与主站完全隔离。通过新建 guest_authors 表和扩展 posts 表实现数据隔离。开发分为四个阶段:数据库迁移、管理界面、编辑器扩展和公开展示。重点解决了骨架屏闪烁、侧边栏标签三层隔离、样式统一等技术难题,实现了内容隔离、动态标签云和国际化功能。

一、项目背景与核心目标

1.1 项目背景

为基于 Cloudflare Workers + D1 + Hono + TanStack Start 构建的 Fuwari 主题博客系统新增“客邸”模块。该模块用于独立展示多位作者的文章,与博主主站内容完全隔离,同时共享文章表结构以复用现有编辑器和管理功能。

1.2 核心目标

内容隔离:主站归档、标签、前后篇、计数均不含客邸文章;客邸文章详情页标签跳转至客邸作者页

作者管理:管理员可新建/编辑/删除作者(姓名、头像、简介)

文章归属:编辑文章时可勾选“加入客邸”并选择作者

层级过滤:客邸区域支持按作者、标签的层层过滤

动态标签云:侧边栏标签云随页面层级动态切换数据源

导航入口:PC/移动端均显示“客邸”入口

国际化:所有界面文本支持中英文切换

二、架构设计

2.1 数据库设计

新建表 guest_authors

SQL
CREATE TABLE guest_authors (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  slug TEXT NOT NULL UNIQUE,
  bio TEXT,
  avatar TEXT,
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);

扩展 posts 表:

SQL
ALTER TABLE posts ADD COLUMN is_guest_post INTEGER NOT NULL DEFAULT 0;
ALTER TABLE posts ADD COLUMN guest_author_id INTEGER REFERENCES guest_authors(id) ON DELETE SET NULL;

2.2 前端路由结构

纯文本
/_public/
  ├── guest-house/
  │   ├── index.tsx              → 客邸主页(作者卡片网格 + 标签过滤)
  │   └── author.$slug.tsx       → 作者文章页(归档样式)
  └── post/$slug.tsx             → 文章详情页(公用,根据 isGuestPost 调整 UI)

2.3 数据流架构

管理员后台 → 作者 CRUD API → guest_authors 表

管理员后台 → 编辑器扩展 → 文章标记 isGuestPost + guestAuthorId

访客访问客邸 → 公开 API → 查询 isGuestPost = true 的文章 → 前端展示

侧边栏标签 → 根据路由动态选择数据源(主站/客邸/作者专属)

三、分阶段实现

3.1 第一阶段:数据库迁移与作者管理 API

实现内容

- 创建 guest_authors 表迁移文件

- 扩展 posts 表添加 is_guest_postguest_author_id 字段

- 实现作者 CRUD Server FunctionslistGuestAuthorsFn, createGuestAuthorFn, updateGuestAuthorFn, deleteGuestAuthorFn

- 创建查询 HooksguestAuthorsListQueryOptions, useCreateGuestAuthor 等)

关键文件

纯文本
src/lib/db/schema/guest-authors.table.ts    # 表定义
src/features/guest-authors/api/guest-authors.admin.api.ts  # 管理端 API
src/features/guest-authors/queries/index.ts  # 管理端查询 Hooks

3.2 第二阶段:管理界面

实现内容

- 创建 /admin/guest-authors 路由

- 实现作者管理表格(内联创建/编辑、删除确认、头像选择)

- 集成到管理侧边栏导航

关键文件

纯文本
src/routes/admin/guest-authors/index.tsx  # 管理界面路由
src/features/guest-authors/components/AvatarPicker.tsx  # 头像选择器

试错记录

- 问题:头像上传使用 AssetUploadField 报“资源路径必须以 favicon/、themes/default/ 或 themes/fuwari/ 开头”

- 解决:改用自定义 AvatarPicker 组件,直接从媒体库选择图片

- 问题:创建/编辑作者后保存失败,提示 Invalid input: expected object, received undefined

- 根因:Server Function 的 mutationFn 未正确包装 data 字段

- 解决:修改 mutation 的 mutationFn(input) => createGuestAuthorFn({ data: input })

3.3 第三阶段:编辑器扩展

实现内容

- 扩展 PostEditorData 类型isGuestPost, guestAuthorId

- 在 PostEditorMetadata 中添加“加入客邸”复选框和作者下拉选择器

- 扩展 PostUpdateSchema 允许新字段

- 修改 updatePost 服务处理客邸字段

- 修改 useAutoSaveusePostActions 的脏检测逻辑

关键文件

纯文本
src/features/posts/components/post-editor/types.ts  # 类型扩展
src/features/posts/components/post-editor/post-editor-metadata.tsx  # UI
src/features/posts/schema/posts.schema.ts  # Schema 扩展
src/features/posts/services/posts.service.ts  # 服务层保存逻辑
src/features/posts/components/post-editor/hooks/use-auto-save.ts  # 脏检测
src/features/posts/components/post-editor/hooks/use-post-actions.ts  # 发布状态

试错记录

- 问题:勾选客邸后刷新页面,复选框和作者选择器回显异常

- 根因:edit.$id.tsxinitialData 未包含 isGuestPostguestAuthorId

- 解决:在 initialData 中添加 isGuestPost: (post as any).isGuestPost ?? falseguestAuthorId: (post as any).guestAuthorId ?? null

- 问题:自动保存后无法发布(发布按钮不可点)

- 根因:usePostActionsisDirty 计算未包含客邸字段

- 解决:在 isDirtyuseMemo 中添加 post.isGuestPost !== kvSnapshot.isGuestPost || post.guestAuthorId !== kvSnapshot.guestAuthorId

- 问题:下拉框选中项显示空白

- 根因:<select><option> 缺少文字颜色样式

- 解决:添加 text-foregroundbg-background

3.4 第四阶段:公开 API 与前端展示

实现内容

- 实现公开查询:作者列表、作者详情、客邸文章分页(支持作者和标签过滤)、作者专属标签

- 创建查询 Hooks

- 实现客邸主页组件(作者卡片网格)

- 实现作者文章页组件(作者信息 + 归档样式文章列表)

- 改造文章详情页(面包屑、落款、前后篇隔离、评论区/相关文章隐藏)

- 动态侧边栏标签云

- 导航栏添加入口

关键文件

纯文本
src/features/guest-authors/api/public.api.ts  # 公开 API
src/features/guest-authors/queries/public.ts  # 公开查询 Hooks
themes/fuwari/pages/guest-house/index.tsx  # 客邸主页
themes/fuwari/pages/guest-house/author.tsx  # 作者文章页
themes/fuwari/pages/post/page.tsx  # 文章详情页改造
themes/fuwari/pages/post/components/post-meta.tsx  # 标签链接隔离
themes/fuwari/components/tags.tsx  # 侧边栏动态标签云

四、核心试错与技术实现

4.1 骨架屏实现与页面闪烁修复

目标:消除客邸主页、作者页、文章详情页首次加载时的白屏闪烁,提供平滑的加载过渡。

骨架屏组件设计

新建 src/features/theme/themes/fuwari/pages/guest-house/skeleton.tsx,包含:

- 介绍卡片骨架:标题、简介、统计文本占位

- 作者卡片网格骨架:头像圆形、姓名、简介、文章数占位,共 4 个卡片模拟首屏常见状态

TSX
export function GuestHousePageSkeleton() {
  return (
    <div className="flex flex-col gap-4">
      <div className="fuwari-card-base p-6">
        <Skeleton className="h-8 w-24 mb-2" />
        <Skeleton className="h-4 w-64 mb-4" />
        <Skeleton className="h-4 w-48" />
      </div>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        {Array.from({ length: 4 }).map((_, i) => (
          <div key={i} className="fuwari-card-base p-5 flex items-center gap-4">
            <Skeleton className="h-14 w-14 rounded-full" />
            <div className="flex-1 space-y-2">
              <Skeleton className="h-5 w-32" />
              <Skeleton className="h-3 w-48" />
            </div>
            <Skeleton className="h-6 w-10" />
          </div>
        ))}
      </div>
    </div>
  );
}

闪烁问题根因分析

初始实现中,客邸主页和作者页使用了 useSuspenseInfiniteQuery,依赖路由级别的 pendingComponent 显示骨架屏。然而实际表现出现白屏闪烁,骨架屏无法稳定显示。经过逐层排查,发现两个根因:

1. 布局 Suspense 边界冲突:_public 布局中虽未显式包裹 <Suspense>,但 TanStack Router 的渲染机制在某些条件下会导致路由自身的 Suspense 边界与父级边界相互作用,使 pendingComponent 短暂失效,造成白屏。

2. 侧边栏 Tags 组件挂起冒泡:侧边栏 <Tags /> 使用 useSuspenseQuery 获取标签数据,但该组件并未被独立的 <Suspense> 包裹。当标签数据挂起时,异步状态向上冒泡到路由层面,触发了整个页面的 pendingComponent 替换,导致整个页面骨架屏闪现,视觉上呈现“闪烁”。

修复方案

为侧边栏 Tags 添加独立 Suspense 边界:在 themes/fuwari/components/sidebar/index.tsx 中,将 <Tags /> 包裹在 <Suspense fallback={<TagsSkeleton />}> 内,确保标签加载状态只影响标签区域,不会波及整个页面。

TSX
<div className="sticky top-4 fuwari-onload-animation" style={{ animationDelay: "250ms" }}>
  <Suspense fallback={<TagsSkeleton />}>
    <Tags />
  </Suspense>
</div>

客邸主页和作者页改用 useInfiniteQuery 手动加载态:放弃 useSuspenseInfiniteQuery,改用 useInfiniteQuery,并在组件内部通过 isLoading 判断显式返回 <GuestHousePageSkeleton />。这样做完全避开了路由 Suspense 边界的不确定性,骨架屏渲染完全由组件自主控制。

TSX
const {
  data: postsData,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
  isLoading: postsLoading,
} = useInfiniteQuery(guestPostsInfiniteQueryOptions({ tagName: search.tagName, limit: 12 }));
if (authorsLoading || postsLoading) {
  return <GuestHousePageSkeleton />;
}

文章详情页移除 sessionLoading 提前返回:原 page.tsx 中有一段 if (sessionLoading) return <div>加载中...</div>,这会导致页面在 session 加载时完全卸载,随后重新挂载,形成闪烁。移除该段代码,改为让组件容忍 session 加载状态,仅对依赖 session 的功能(如编辑按钮)做条件渲染。

最终效果:所有页面首次加载时稳定显示骨架屏,数据就绪后平滑切换,无任何白屏或闪烁。客邸文章详情页也不再出现“加载中...”文字的闪现。

4.2 第三层标签隔离:侧边栏精准过滤与链接生成

目标

在客邸文章详情页,侧边栏标签云应仅显示该文章作者的标签,且点击标签跳转到该作者页并过滤该标签。这是继主站隔离、客邸主页隔离之后的第三层隔离,也是整个模块中最复杂的部分。

核心挑战

侧边栏 Tags 组件需要动态感知当前文章是否为客邸文章,并获取其作者 slug,然而文章详情页的路由是 /post/$slug,URL 中不包含任何客邸相关路径,无法通过简单的 URL 匹配判断。

此外,该组件运行在完全独立的渲染树中(侧边栏),与文章正文组件没有直接的 props 传递关系,必须通过全局状态或路由信息间接获取数据。

试错过程

方案一:从路由 loaderData 获取

最初的设想是利用 TanStack Router 的 loaderData。通过 routerState.matches 找到文章路由,读取 loaderData.post.isGuestPostguestAuthorSlug。然而部署后几乎全部失效—loaderData 往往为 undefined,原因是路由器对 matches 的序列化在服务端与客户端不一致,且某些情况下(如客户端导航) matches 信息不完整,导致获取不到数据。

方案二:通过共享状态传递

尝试在文章页组件中将 guestAuthorSlug 写入全局状态(如 React Context 或 TanStack Query 缓存),再由侧边栏消费。这样做违背了最小侵入原则,引入了不必要的跨组件耦合,且需要额外维护状态同步,在页面切换时容易出现残留旧值。

方案三:独立查询文章数据(首次尝试)

Tags 组件直接调用 postBySlugQuery(slug) 获取文章对象。此查询会命中 TanStack Query 缓存(文章详情页已请求过),不会产生额外网络开销,数据稳定。初步测试通过,并能正确显示客邸标签。

致命问题:缓存导致标签不更新

当管理员修改作者 slug 后postBySlugQuery 仍返回缓存的旧文章数据(包括旧的 guestAuthorSlug),而该查询的 staleTime 默认为一定时间,即使调用 invalidateQueries 也未能强制其重新获取(因为查询键并未变化,且组件未卸载)。这导致 Tags 一直使用旧 slug 查询标签,若新 slug 对应的作者有标签,也不会显示;若旧 slug 无标签,则始终显示“暂无标签”。

最终方案:专用轻量级查询 + staleTime:0

为彻底摆脱缓存干扰,我们新增了一个专门用于获取客邸元数据的查询 postGuestAuthorSlugQuery。它只返回 isGuestPostguestAuthorSlug 两个字段,并设置 staleTime: 0,确保每次 Tags 组件挂载(或 slug 变化)时都实时向服务端请求最新数据。数据层使用 Drizzle 的 findFirst 配合 with: { guestAuthor: { columns: { slug: true } } } 高效完成,服务端负载极低。

同时,为了避免 Hydration 错误干扰(该错误曾导致整个页面重置,标签消失),我们对公开路由设置了 ssr: false,使整个页面转为纯客户端渲染,彻底避开 SSR 与 CSR 的不一致问题。

最终实现

数据层 src/features/posts/data/posts.data.ts

TypeScript
export async function getPostGuestAuthorSlug(db: DB, slug: string) {
  const post = await db.query.PostsTable.findFirst({
    where: eq(PostsTable.slug, slug),
    with: { guestAuthor: { columns: { slug: true } } },
    columns: { isGuestPost: true, guestAuthorId: true },
  });
  if (!post) return null;
  return {
    isGuestPost: post.isGuestPost ?? false,
    guestAuthorSlug: post.guestAuthor?.slug ?? null,
  };
}

API 层 src/features/posts/api/posts.public.api.ts

TypeScript
export const getPostGuestAuthorSlugFn = createServerFn()
  .middleware([dbMiddleware])
  .inputValidator(z.object({ slug: z.string() }))
  .handler(async ({ data, context }) => {
    return await PostRepo.getPostGuestAuthorSlug(context.db, data.slug);
  });

查询 Options src/features/posts/queries/index.ts

TypeScript
export function postGuestAuthorSlugQuery(slug: string) {
  return queryOptions({
    queryKey: ["posts", "guest-author-slug", slug],
    queryFn: () => getPostGuestAuthorSlugFn({ data: { slug } }),
    staleTime: 0, // 强制实时
  });
}

Tags 组件核心逻辑 themes/fuwari/components/tags.tsx

TSX
const { data: postInfo } = useQuery({
  ...postGuestAuthorSlugQuery(slug),
  enabled: !!slug && !isGuestHouse,
});

const isGuestPost = !!(postInfo?.isGuestPost);
const guestAuthorSlug = postInfo?.guestAuthorSlug;
let queryOptions;
if (authorSlug) {
  queryOptions = guestAuthorTagsQueryOptions(authorSlug);
} else if (isGuestPost && guestAuthorSlug) {
  queryOptions = guestAuthorTagsQueryOptions(guestAuthorSlug);
} else if (isGuestHouse || isGuestPost) {
  queryOptions = guestHouseTagsQueryOptions();
} else {
  queryOptions = tagsQueryOptions;
}

链接生成同样根据 authorSlugguestAuthorSlug 动态决定跳转路径,确保点击标签进入对应的作者页并附带标签参数。

4.3 时间线归档样式统一

客邸主页的标签过滤视图和客邸作者页的文章列表,均使用主题提供的 <theme.PostsPage> 组件渲染,与主站 /posts 页面保持完全一致的时间线归档风格,不再显示大标题和简介卡片。这确保了视觉体验的连贯性,避免客邸区域显得突兀。

修改文件guest-house/index.tsxguest-house/author.tsx 中,删除原先的 PostCard 直接渲染,统一替换为:

TSX
<theme.PostsPage
  posts={allPosts}
  hasNextPage={hasNextPage}
  fetchNextPage={fetchNextPage}
  isFetchingNextPage={isFetchingNextPage}
/>

4.4 面包屑与落款美化

客邸文章详情页:面包屑“客邸 / 作者名”内嵌于文章卡片内部<div className="fuwari-card-base ..."> 内),视觉上更加协调。

落款:“寄存于客邸 · 作者名”使用国际化文本 m.guest_house_resident(),仅在客邸文章显示,分隔线与正文自然过渡。

客邸作者页:面包屑“← 客邸”移入作者信息卡片内部,位于头像和作者名上方,避免与顶部导航栏疏离。

4.5 导航栏入口与防重复

问题:PC 端导航栏曾出现两个“客邸”链接,导致视觉混乱。

根因:themes/fuwari/layouts/navbar.tsx 中既通过 navOptions.map() 渲染了菜单项(已包含客邸),又硬编码了一个 <Link to="/guest-house">客邸</Link>

修复:删除硬编码链接,仅保留 {navOptions.map(...)} 渲染。同时确认 _public/route.tsx_user/route.tsxnavOptions 数组中已添加“客邸”条目,保证移动端和桌面端一致。

4.6 文章卡片标签链接隔离

客邸文章卡片(如在客邸主页、作者页列表中)的标签链接同样需要隔离。修改 themes/fuwari/components/post-card.tsx,在标签渲染部分判断 post.isGuestPost,若为客邸文章则生成指向客邸作者页的链接:

TSX
if (post.isGuestPost && (post as any).guestAuthorSlug) {
  return (
    <Link
      to="/guest-house/author/$slug"
      params={{ slug: (post as any).guestAuthorSlug }}
      search={{ tagName: name }}
      ...
    />
  );
}
// 否则指向 /posts

这确保了无论在哪个页面,客邸文章的标签都指向正确的作者页。

4.7 试错过程总结

问题

初始方案

失效原因

最终方案

页面闪烁

依赖路由 pendingComponent

布局 Suspense 冲突 + Tags 挂起冒泡

手动 useInfiniteQuery + 独立 Tags Suspense

侧边栏标签导致整体闪烁

Tags 无 Suspense 边界

挂起状态向上冒泡

为 Tags 添加 <Suspense> 包裹

客邸文章详情页标签链接错误

post.isGuestPost 判断

guestAuthorSlug 可能丢失

独立查询文章数据获取真实作者

客邸文章详情页侧边栏显示全站标签

通过路由 loaderData 获取

数据不稳定

独立 postBySlugQuery 查询,精准提取作者 Slug

标签过滤列表样式不统一

卡片式列表 PostCard

与归档页风格不匹配

改用 <theme.PostsPage>

桌面端两个客邸入口

navOptions 中添加

Navbar 硬编码链接未删除

移除硬编码,统一由 navOptions 渲染

文章详情页“加载中”闪烁

sessionLoading 提前返回

页面卸载重载

移除提前返回,条件渲染依赖 session 的功能

4.8 美化后的最终体验

加载体验:骨架屏平滑过渡,无任何白屏闪烁。

标签隔离:三层精准过滤(主站 / 客邸主页 / 客邸作者),点击跳转零误差。

视觉风格:归档样式、面包屑、落款完全符合 Fuwari 主题设计,客邸区域与主站浑然一体。

可维护性:侧边栏采用“查询即真相”策略,避免缓存/序列化导致的幽灵数据;组件复用 theme.PostsPage 和现有查询 Hooks,降低耦合。

五、最终效果

5.1 功能清单

  • 管理后台作者 CRUD

  • 编辑器客邸标记与作者选择

  • 客邸主页(作者卡片网格 + 标签过滤)

  • 作者文章页(归档样式)

  • 文章详情页(面包屑/落款/前后篇)

  • 客邸文章隐藏评论区/相关文章

  • 侧边栏动态标签云(三层隔离)

  • 主站完全排除客邸文章

  • 导航栏入口(PC/移动端一致)

  • 骨架屏加载体验

  • 国际化

5.2 技术亮点

数据隔离策略:通过 excludeGuestPosts 参数精确控制,而非分支逻辑

路由感知组件:Tags 组件根据 useRouterState 动态选择数据源和链接生成规则

“查询即真相”:客邸文章标签通过独立查询文章数据获取作者信息,避免缓存/序列化问题

最小侵入原则:复用现有文章表、编辑器PostsPage 归档组件,仅增量和配置化修改

六、修改文件清单(核心 20+ 文件)

文件

修改内容

src/lib/db/schema/guest-authors.table.ts

新建作者表

src/lib/db/schema/posts.table.ts

新增 isGuestPost, guestAuthorId 字段

src/features/guest-authors/api/guest-authors.admin.api.ts

作者管理 CRUD

src/features/guest-authors/api/public.api.ts

公开查询 API

src/features/guest-authors/queries/index.ts

管理端查询 Hooks

src/features/guest-authors/queries/public.ts

公开查询 Hooks

src/features/guest-authors/components/AvatarPicker.tsx

头像选择器

src/routes/admin/guest-authors/index.tsx

管理界面路由

src/features/posts/data/helper.ts

buildPostWhereClause 增加 excludeGuestPosts 参数

src/features/posts/data/posts.data.ts

findPostBySlug 关联 guestAuthor,透传参数

src/features/posts/services/posts.service.ts

服务层客邸查询、前后篇隔离

src/features/posts/schema/posts.schema.ts

Schema 扩展容错

src/features/posts/components/post-editor/types.ts

类型扩展

src/features/posts/components/post-editor/post-editor-metadata.tsx

客邸 UI

src/features/posts/components/post-editor/hooks/use-auto-save.ts

脏检测

src/features/posts/components/post-editor/hooks/use-post-actions.ts

发布状态

src/features/tags/data/tags.data.ts

标签查询排除客邸

themes/fuwari/components/tags.tsx

侧边栏动态标签云

themes/fuwari/pages/guest-house/index.tsx

客邸主页

themes/fuwari/pages/guest-house/author.tsx

作者文章页

themes/fuwari/pages/post/page.tsx

文章详情页改造

themes/fuwari/pages/post/components/post-meta.tsx

标签链接隔离

themes/fuwari/layouts/navbar.tsx

移除硬编码链接

messages/zh.json, messages/en.json

国际化词条

七、总结

1. 缓存是魔鬼:Vite 缓存、KV 缓存、浏览器缓存多次导致修改后不生效,排查时务必先清缓存。

2. 数据流追溯是关键:标签链接、面包屑、前后篇等问题都通过沿着数据流层层回溯(前端状态 → 服务层 → 数据层 → 数据库)最终定位根因。

3. 最小侵入设计:通过参数控制而非分支逻辑,让主站和客邸查询复用同一套代码,减少维护成本。

4. Hook 顺序维护:React 组件中所有 Hooks 必须在顶层调用,条件渲染需通过三元表达式而非提前 return。

5. 骨架屏边界处理:Suspense 边界不能随意嵌套,需明确每个异步操作的挂起范围,避免整个页面被替换。

注:本功能基于Flare Stack Blog开发,以下是原仓库与博主仓库链接。

du2333
/flare-stack-blog
正在加载...

htbenwarp
/flare-stack-blog
正在加载...


END