CivitAI Moderator 图片审核队列统一化(Image Queue Unification):从 9 个手写网格到 ImageQueueGrid 共享原语
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
本技术指南以 docs/moderator-app/image-queue-unification.md 为骨架,结合 CivitAI 审核工作台(moderator spoke)的实际源码,完整还原一次大型前端去重改造的决策过程与落地细节。文章覆盖:如何识别"看起来共享实则双轨实现"的 9 个图片审核页面、如何区分路由级判别与组件级共享两条统一轴、ImageQueueGrid共享网格原语的完整契约,以及"哪些页面故意不迁移"的边界判断。读完你既能照搬这套 Plan A → Plan B 的拆分方法论,也能直接复用ImageQueueGrid的组件设计。
背景:审核工作台为什么需要统一化
审核工作台(apps/moderator,一个 SvelteKit 应用)目前拥有约 10 个图片网格类审核界面。它们在视觉与交互层面高度趋同——共享卡片网格布局、浏览级别(browsing-level)筛选、游标分页(cursor pagination)——但这份"共享形状"实际是用两种互不兼容的方式实现的:
- 一部分页面复用
ImageReviewGrid组件; - 另一部分页面各自手写一套几乎相同的网格代码。
结果是同一个"卡片网格 + 300px 列宽 + 翻页"的模式在代码库里被反复重写。统一化的目的不是为重构而重构,而是把真正被重复的原始图元(primitive)抽出来,让每个页面去组合它,同时识别出哪些页面共享的只是表象、其工作流本质上各不相同——前者值得统一,后者强行统一只会制造"上帝路由"。
调研:九张页面里什么真正共享、什么已经分歧
改造的第一步是全面盘点。原计划文档给出了一张 9 行调研表,对照当前 apps/moderator/src/routes/images 目录下的实际路由结构,可以得到完整图景:
| 页面 | 规划中的 URL | 实际仓库路由 | 角色 | 网格 | 动作 | 只读? |
|---|---|---|---|---|---|---|
| Review 模式 | /images/[slug] | images/[slug] | staff | ImageReviewGrid | — | 是(mutations 当时待定) |
| Reported | /images/reported | 并入[slug] | staff | ImageReviewGrid | — | 是 |
| Appeals | /images/appeals | 并入[slug] | senior | ImageReviewGrid | — | 是 |
| CSAM | /images/csam | 并入[slug] | senior | ImageReviewGrid | — | 是 |
| Image Tags | /image-tags | images/tags | staff | 手写 | moderate | 否 |
| Image Ratings | /image-rating-review | images/ratings | staff | 手写 | setLevel | 否 |
| Downleveled | /downleveled-review | images/downleveled | staff | 手写 | setLevel | 否 |
| Ingestion Errors | /ingestion-error-review | images/ingestion-errors | staff | 手写 | resolve | 否 |
| Images to Ingest | /images/to-ingest | images/to-ingest | staff | 手写 | — | 是 |
注意:计划文档写作时的 URL(如
/image-tags、/image-rating-review)在落地后统一收敛到了/images/*命名空间,reported/appeals/csam则被 Plan A 折叠进了动态路由/images/[slug]。
从这张表可以自然分出两个干净的组:
- Review 家族(前 4 行):全部使用
ImageReviewGrid、全部只读、本质上是同一条概念队列(不同筛选视角),且都挂在/images访问父级下。彼此差异仅限于查询参数和单卡详情。 - Action 页面(后 5 行):每个页面都手写了同一套网格(
minmax(300px,1fr)自动填充列、aspect-[4/5]图片卡、EdgeMedia width=450、浏览级 chips),再叠加各自的SvelteMap选择逻辑和enhance动作表单。它们是各自独立的工作流——有自己的 mutation、自己的顶层 URL。
这个分组直接决定了下文两条统一轴的适用范围。
两条统一轴:路由级判别 vs 组件级共享
统一化并不是"一揽子合并",而是被拆成两条不同性质、不同适用范围、不同收益的轴:
轴 A —— 路由级判别(Route-level discrimination)。用一个[slug]路由承载多个视图,载荷(payload)以kind字段判别,每类视图渲染各自的卡片分支。它"便宜且清晰",但只在视图之间共享查询家族、条目形状与访问父级、且以读取为主时才成立。仅适用于 review 家族。
轴 B —— 组件级共享(Component-level sharing)。把ImageReviewGrid提升为唯一的图片队列网格(即ImageQueueGrid),让每个页面组合它而不是重新实现它。这与路由无关,是真正的 DRY 收益所在——它覆盖了共享路由永远覆盖不到的 action 页面。
计划文档明确划了一条红线:把 action 页面折叠进共享路由是越界(out of scope)。它们共享的是网格而不是路由,硬合并会产出一个带五套动作集合的"上帝路由",代码一行都省不下来。
关键在于:A 和 B 不是二选一,而是互补且有序执行(A → B)。A 合并 review 的路由;B 在每个页面(包括 A 不碰的那些)上去重网格。B 将是 review 家族 mutations 的落点,因此"Plan A → Plan B → review mutations"是一条完整的推进主线。
遗留 action 模型的两条关键发现
在设计 B 之前,计划文档先考察了遗留的/moderator/images.tsx(一个带共享选择 store 和单个批量操作工具栏的标签页页面),得出两个驱动 B 设计的事实:
- 选择是逐标签页的,绝不跨标签页——
useEffect(deselectAll, [viewType])在每次切换标签时清空选择。因此不存在需要上提(hoist)的跨视图选择,选择状态可以放在页面/网格层面,仅作用于单一视图。 - 工具栏是共享 UI,但动作是视图感知的——review 标签用
image.moderate,reported 用report.bulkUpdateStatus,csam 走独立路径。所以可复用的是一个批量操作栏外壳(全选 / 清除 / 计数),由每个视图往里注入自己的动作。
后文 Plan B 会看到,落地时这一模型又被修正了一次:action 页面最终没有采用"先选后批量"的工具栏,而是改走卡片级即时动作 + 乐观变暗——这恰恰说明"先调研、再设计、落地时敢于修正"的价值。
Plan A:review 家族的[slug]路由统一
Plan A 的目标是把reported/appeals/csam折叠进/images/[slug],与六个 review 模式(minor、remixSource、poi、tag、newUser、modRule)并排。它分四步,每一步都能在当前源码中找到对应实现。
门禁:从route.id改为pathname
这是唯一能让 senior 级视图安全地住在[slug]下、又不造成权限回退的改动。全局门禁原来以event.route.id(路由模板,如/images/[slug])为键——所有 slug 共享同一条权限判定,无法区分/images/minor(staff)和/images/csam(senior)。改法是把canAccess的键换成event.url.pathname,同时保留route.id &&守卫,让静态资源保持不设防。
当前实现位于 apps/moderator/src/hooks.server.ts:
// Global role-tier gate — one place covering loads, actions, and endpoints. Keyed on the concrete // pathname (not route.id) so a dynamic route like /images/[slug] gates per-slug: /images/csam → // senior, /images/minor → staff. canAccess's prefix match resolves `__data.json` data requests and // sub-path endpoints to the right nav entry too. The `route.id &&` guard keeps static assets ungated. if ( event.route.id && !event.url.pathname.startsWith('/api/') && !canAccess(result.user, event.url.pathname) ) { const denied = `/?denied=${encodeURIComponent(event.url.pathname)}`; return new Response(null, { status: 303, headers: { location: denied } }); }计划文档特别验证过的关键路径:/images/csam/__data.json(SvelteKit 数据请求)和子路径端点/images/csam/verdict都必须解析到 senior 角色——canAccess的前缀匹配正是为此设计的。而NAVIGATION中的路径与角色保持不变,所以 senior 门禁改由导航角色经 pathname 门禁自然流转。
Load:slug 校验 +kind判别载荷
服务端 load 先用IMAGE_VIEW_SLUGS常量校验 slug(未知即 404),再按 slugswitch到既有 service,最后返回一个带kind判别字段的载荷。见 apps/moderator/src/routes/images/[slug]/+page.server.ts:
minor/remixSource→kind: 'review-highlight',额外附带promptHighlight(minor 队列只高亮minor/young/age相关类别,remixSource 高亮整段 prompt);poi/tag/newUser/modRule/csam→kind: 'review';reported→kind: 'reported';appeals→kind: 'appeal'。
同时,modRule视图会额外用getModerationRuleDefinitions拉取规则定义并挂到条目上;所有视图都会通过withModel3d补齐"该图片是否为某 3D 模型唯一缩略图"的关联信息。最终返回的载荷统一携带base(limit/level/tagIds/excludedTagIds)与nextCursor。
Page:按kind分支渲染
页面层对kind做判别,每个分支渲染同一个ImageQueueGrid,只是传入不同的cardsnippet。见 apps/moderator/src/routes/images/[slug]/+page.svelte:
review-highlight:minor 视图显示Minor / Not minor、Acceptable minor徽章与PromptHighlight;remixSource 显示Remix source — prompt flagged;reported:卡片渲染举报原因(reason)、+N others计数、举报人(用户名链接到User Lookup而非个人主页)、reportDetailEntries详情条目;appeal:卡片渲染移除原因、申诉原文、触发该移除的历史举报;- 其余:按
view再分poi/tag/newUser/modRule/csam的徽章分支(modRule还有"View rule definition"弹层)。
每个卡片末尾都渲染共用的model3dAffordance——当条目是 3D 模型唯一缩略图时,提供"查看父模型 + 一键取消发布"。
删除旧路由
最后删除/images/reported、/images/appeals、/images/csam三个独立路由目录。由于NAVIGATION路径与角色未动,senior 门禁现在完全经由 pathname 门禁提供。
计划文档对 Plan A 的规模评估是"小":条目形状都已存在,门禁改动一行 + 重新验证。
Plan B:共享ImageQueueGrid原语(已完成)
Plan B 是本次改造真正的核心成果。落地过程中有一处对调研结论的重要修正:action 页面实际上根本没有使用"选择 + 批量操作"——它们走的是卡片级即时动作 + 乐观变暗(用一个SvelteMap记录已操作 id 来调暗卡片,invalidateAll: false不整页失效)。因此遗留的"先选后批量"工具栏并不是共享需求,那种批量栏属于投机设计。真正被重复的原始图元是:图片卡外壳 + 300px 网格 + Next 翻页,卡片内容由页面提供。
组件契约:ImageQueueGrid.svelte
共享原语提取为 apps/moderator/src/lib/components/ImageQueueGrid.svelte。它是泛型组件(generics="T extends { id: number; url: string; type: MediaType; nsfwLevel?: number }"),完整 props 契约如下:
| Prop | 类型 | 说明 |
|---|---|---|
items | T[] | 队列条目;type/url供EdgeMedia渲染 |
civitaiUrl | string | 主站地址,卡片角标外链${civitaiUrl}/images/${item.id} |
nextCursor | number \| string | 游标分页:仅向前,Back 沿 URL 中的 trail 回退;编号模式忽略 |
total/perPage/page | number | 编号分页三件套,要传就全传——page必须来自服务端(服务端已钳制),从 URL 反推会渲染一个指向错误页面的分页器 |
keyOf | (item) => string \| number | 键访问器,默认取图片 id(reported 队列按 report id 键控) |
itemClass | (item) => string | 每卡 class,用于乐观变暗(如opacity-60) |
card | Snippet<[T]> | 卡片正文,由页面注入 |
selected | SelectionSet | 传入即启用多选:图片本体成为选择目标,右下角箭头作为跳出到站点的出口 |
empty/endLabel | string \| null | 空态文案 / 队列末尾文案;endLabel: null用于"上限截断批次"场景,避免与截断警告矛盾 |
minColumn | number | 默认 300,网格旁有其他列时可调小 |
网格、卡片与两种分页模式
网格本体是响应式自动填充布局(repeat(auto-fill, minmax({minColumn}px, 1fr))),卡片为aspect-[4/5]的图片区 + 由页面 snippet 提供的CardContent正文。图片用EdgeMedia width={450}渲染并object-contain缩放,左上角按nsfwLevel显示对应浏览级别徽章(RATING_BADGE映射 PG → 绿、PG13 → 黄、R → 橙、X → 红、XXX → 紫、Blocked → 深红)。
分页有两种模式,由数据自动判别:
- 编号分页:
total+perPage齐全时渲染 NumberedPager,适合查询本身已统计匹配数的队列; - 游标分页:否则渲染
First / Back / Page N / Next,游标可为数字或字符串。Back 的实现是"URL trail"——每次 Next 把游标writeCursorTrail追加进 query 参数,Back 截掉最后一个,First 则clearPaging清空。计划文档特别点出一个边界:bookmark 或旧链接带来的"无 trail 的 cursor"会被判为第 1 页,可能渲染出空态——此时paged派生值仍为真,网格会额外渲染"Back to the first page"按钮,保证空态不是死胡同。
选择与乐观变暗(当前实现已采纳)
ImageQueueGrid内置了对SelectionSet的支持(来自@civitai/ui/hooks/selection-set.svelte.js):传入selected后,整张图片变成点击选择的目标,Shift连选由suppressShiftSelection与toggle处理,右上角出现SelectionCheckbox,选中卡片加ring-2 ring-primary高亮,右下角的外链箭头保留为"去主站看大图"的出口。
在 [slug] 页面中,乐观交互的完整链条是(+page.svelte):
acted: SvelteMap<string | number, string>记录"卡片键 → 裁定结果"(乐观地调暗卡片并显示结果,翻页时清空);keyOfItem与selected一致,在 reported 队列按 report id 键控——因为getReportedImageQueue每个举报返回一行,同一图片的两个举报是两张卡,按图片 id 键控会把两张一起标记;cardClass对已操作的卡返回opacity-60实现变暗;- 批量提交走
optimisticEnhancer,提交失败时整批一起回滚——部分成功的批量在页面上无法与未操作的区分,回滚优于假装成功。
这直接回应了计划文档的开放问题:"Plan B 的选择/动作能力是现在投机建设,还是等第一个 action 页面迁移后再定 API?"——当前源码显示采纳了延后的建议:image-tags页面(tags/+page.svelte)作为第一个消费者,用自己的SelectionSet、resolved乐观 Map 和bulkSubmit塑造了这套 API。
迁移的三个页面与"故意不迁移"的两个页面
三个卡片形状完全匹配的页面被迁移,手写网格/卡片/Next 被ImageQueueGrid替换,load 与 action 逻辑不动,卡片网格从此只在一处定义:
- image-tags(Tags Needing Review,
moderate动作); - image-rating-review(
setLevel动作); - downleveled-review(
setLevel动作)。
故意不迁移的两个页面,则划出了"过度改造(overreach)"的边界:
- ingestion-error-review——动作在图片上方且无 aspect 盒,网格也不同(
auto-fit 300px);迁移会重塑其 UX; - images/to-ingest——稠密小卡浏览画廊(
grid-cols-2..5、元数据比例、object-cover),只读,用途与审核卡不同。
一句话总结边界:共享"路由"不等于共享"网格",共享"网格"也不等于共享"布局语义"——只有形状真正一致的才值得统一。
执行序列与当前状态
计划文档给出的推进序列与落地状态(提交号25ca66c4d5、c2444bfea6见原文档):
- ✅ 当前批次:读取基础 + 统一导航 + reported 计数 + prompt 高亮 ——
25ca66c4d5; - ✅Plan A:review 家族
[slug]统一 + pathname 门禁 ——c2444bfea6; - ✅Plan B:
ImageQueueGrid原语 + 迁移 image-tags / image-rating-review / downleveled-review;ingestion-error 与 to-ingest 保留为有意分歧; - ⏭️下一步(原文档计划):review 家族(当时仍只读)的 per-view mutations——review 模式的 approve/block/delete、reported 的
bulkSetReportStatus、appeals 的resolveEntityAppeal,做成卡片级即时动作并渲染进[slug]卡片,ImageQueueGrid的itemClass变暗能力正是为它们准备的。
从当前仓库源码看,第 4 步已在 images/[slug]/+page.server.ts 落地为完整的 form actions:accept/block(accept → Unactioned、block → Actioned联动举报状态)、resolveAppeal、setRating/setFlag(修正性动作,不清除needsReview,图片留在队列里仍需被接受或移除)、unpublishModel3d,以及批量版bulkAccept/bulkBlock/bulkResolveAppeal(批量申诉会先快照申诉人再去重后逐人发邮件,而非按图片重复发送)。批量setRating还实现了"顺序执行 + 失败点名"的降级策略——部分成功且审核员看不见的批量,比一次拒绝更糟。
开放问题与最终建议
计划文档在结尾留下两个开放问题(@ai:标注),供后续维护者决策:
- pathname 门禁的全局影响:Plan A 把全局门禁从
route.id改为pathname,对图片路由已验证;但非图片路由(全部是静态路由,pathname === route.id)是否要做一遍快速核对?建议:对NAVIGATION中每条路径做一次改造前后的canAccess对照表,断言静态路由无差异。 ImageReviewGrid上的选择/动作能力:是否现在投机建设,还是等第一个 action 页面迁移再定 API?建议(已被采纳):延后,先迁移image-tags,让真实消费者塑造 API。
曾被质疑的"跨视图批量操作"问题已解决:遗留页面每次切换标签都会deselectAll,不存在跨视图选择,因此选择保持在拥有网格的页面内、只作用于单一视图;仅当未来出现全新的跨标签工作流时才值得重新审视。
延伸阅读:完整的改造计划文档见 docs/moderator-app/image-queue-unification.md;共享原语实现见 ImageQueueGrid.svelte;review 家族统一路由见 images/[slug] 与 页面层;全局权限门禁见 hooks.server.ts。
【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考