news 2026/9/18 20:29:45

CivitAI Moderator 图片审核队列统一化(Image Queue Unification):从 9 个手写网格到 ImageQueueGrid 共享原语

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CivitAI Moderator 图片审核队列统一化(Image Queue Unification):从 9 个手写网格到 ImageQueueGrid 共享原语

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]staffImageReviewGrid是(mutations 当时待定)
Reported/images/reported并入[slug]staffImageReviewGrid
Appeals/images/appeals并入[slug]seniorImageReviewGrid
CSAM/images/csam并入[slug]seniorImageReviewGrid
Image Tags/image-tagsimages/tagsstaff手写moderate
Image Ratings/image-rating-reviewimages/ratingsstaff手写setLevel
Downleveled/downleveled-reviewimages/downleveledstaff手写setLevel
Ingestion Errors/ingestion-error-reviewimages/ingestion-errorsstaff手写resolve
Images to Ingest/images/to-ingestimages/to-ingeststaff手写

注意:计划文档写作时的 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 设计的事实:

  1. 选择是逐标签页的,绝不跨标签页——useEffect(deselectAll, [viewType])在每次切换标签时清空选择。因此不存在需要上提(hoist)的跨视图选择,选择状态可以放在页面/网格层面,仅作用于单一视图。
  2. 工具栏是共享 UI,但动作是视图感知的——review 标签用image.moderate,reported 用report.bulkUpdateStatus,csam 走独立路径。所以可复用的是一个批量操作栏外壳(全选 / 清除 / 计数),由每个视图往里注入自己的动作。

后文 Plan B 会看到,落地时这一模型又被修正了一次:action 页面最终没有采用"先选后批量"的工具栏,而是改走卡片级即时动作 + 乐观变暗——这恰恰说明"先调研、再设计、落地时敢于修正"的价值。

Plan A:review 家族的[slug]路由统一

Plan A 的目标是把reported/appeals/csam折叠进/images/[slug],与六个 review 模式(minorremixSourcepoitagnewUsermodRule)并排。它分四步,每一步都能在当前源码中找到对应实现。

门禁:从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/remixSourcekind: 'review-highlight',额外附带promptHighlight(minor 队列只高亮minor/young/age相关类别,remixSource 高亮整段 prompt);
  • poi/tag/newUser/modRule/csamkind: 'review'
  • reportedkind: 'reported'
  • appealskind: 'appeal'

同时,modRule视图会额外用getModerationRuleDefinitions拉取规则定义并挂到条目上;所有视图都会通过withModel3d补齐"该图片是否为某 3D 模型唯一缩略图"的关联信息。最终返回的载荷统一携带baselimit/level/tagIds/excludedTagIds)与nextCursor

Page:按kind分支渲染

页面层对kind做判别,每个分支渲染同一个ImageQueueGrid,只是传入不同的cardsnippet。见 apps/moderator/src/routes/images/[slug]/+page.svelte:

  • review-highlight:minor 视图显示Minor / Not minorAcceptable 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类型说明
itemsT[]队列条目;type/urlEdgeMedia渲染
civitaiUrlstring主站地址,卡片角标外链${civitaiUrl}/images/${item.id}
nextCursornumber \| string游标分页:仅向前,Back 沿 URL 中的 trail 回退;编号模式忽略
total/perPage/pagenumber编号分页三件套,要传就全传——page必须来自服务端(服务端已钳制),从 URL 反推会渲染一个指向错误页面的分页器
keyOf(item) => string \| number键访问器,默认取图片 id(reported 队列按 report id 键控)
itemClass(item) => string每卡 class,用于乐观变暗(如opacity-60
cardSnippet<[T]>卡片正文,由页面注入
selectedSelectionSet传入即启用多选:图片本体成为选择目标,右下角箭头作为跳出到站点的出口
empty/endLabelstring \| null空态文案 / 队列末尾文案;endLabel: null用于"上限截断批次"场景,避免与截断警告矛盾
minColumnnumber默认 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连选由suppressShiftSelectiontoggle处理,右上角出现SelectionCheckbox,选中卡片加ring-2 ring-primary高亮,右下角的外链箭头保留为"去主站看大图"的出口。

在 [slug] 页面中,乐观交互的完整链条是(+page.svelte):

  • acted: SvelteMap<string | number, string>记录"卡片键 → 裁定结果"(乐观地调暗卡片并显示结果,翻页时清空);
  • keyOfItemselected一致,在 reported 队列按 report id 键控——因为getReportedImageQueue每个举报返回一行,同一图片的两个举报是两张卡,按图片 id 键控会把两张一起标记;
  • cardClass对已操作的卡返回opacity-60实现变暗;
  • 批量提交走optimisticEnhancer,提交失败时整批一起回滚——部分成功的批量在页面上无法与未操作的区分,回滚优于假装成功。

这直接回应了计划文档的开放问题:"Plan B 的选择/动作能力是现在投机建设,还是等第一个 action 页面迁移后再定 API?"——当前源码显示采纳了延后的建议:image-tags页面(tags/+page.svelte)作为第一个消费者,用自己的SelectionSetresolved乐观 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),只读,用途与审核卡不同。

一句话总结边界:共享"路由"不等于共享"网格",共享"网格"也不等于共享"布局语义"——只有形状真正一致的才值得统一。

执行序列与当前状态

计划文档给出的推进序列与落地状态(提交号25ca66c4d5c2444bfea6见原文档):

  1. ✅ 当前批次:读取基础 + 统一导航 + reported 计数 + prompt 高亮 ——25ca66c4d5
  2. Plan A:review 家族[slug]统一 + pathname 门禁 ——c2444bfea6
  3. Plan BImageQueueGrid原语 + 迁移 image-tags / image-rating-review / downleveled-review;ingestion-error 与 to-ingest 保留为有意分歧;
  4. ⏭️下一步(原文档计划):review 家族(当时仍只读)的 per-view mutations——review 模式的 approve/block/delete、reported 的bulkSetReportStatus、appeals 的resolveEntityAppeal,做成卡片级即时动作并渲染进[slug]卡片,ImageQueueGriditemClass变暗能力正是为它们准备的。

从当前仓库源码看,第 4 步已在 images/[slug]/+page.server.ts 落地为完整的 form actions:accept/blockaccept → Unactionedblock → Actioned联动举报状态)、resolveAppealsetRating/setFlag修正性动作,不清除needsReview,图片留在队列里仍需被接受或移除)、unpublishModel3d,以及批量版bulkAccept/bulkBlock/bulkResolveAppeal(批量申诉会先快照申诉人再去重后逐人发邮件,而非按图片重复发送)。批量setRating还实现了"顺序执行 + 失败点名"的降级策略——部分成功且审核员看不见的批量,比一次拒绝更糟。

开放问题与最终建议

计划文档在结尾留下两个开放问题(@ai:标注),供后续维护者决策:

  1. pathname 门禁的全局影响:Plan A 把全局门禁从route.id改为pathname,对图片路由已验证;但非图片路由(全部是静态路由,pathname === route.id)是否要做一遍快速核对?建议:对NAVIGATION中每条路径做一次改造前后的canAccess对照表,断言静态路由无差异。
  2. 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 20:23:40

计算机组成原理期末复习:知识靶向图与硬件思维训练

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华