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 width450、浏览级 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.moderatereported 用report.bulkUpdateStatuscsam 走独立路径。所以可复用的是一个批量操作栏外壳全选 / 清除 / 计数由每个视图往里注入自己的动作。后文 Plan B 会看到落地时这一模型又被修正了一次action 页面最终没有采用先选后批量的工具栏而是改走卡片级即时动作 乐观变暗——这恰恰说明先调研、再设计、落地时敢于修正的价值。Plan Areview 家族的[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/minorstaff和/images/csamsenior。改法是把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. canAccesss 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.jsonSvelteKit 数据请求和子路径端点/images/csam/verdict都必须解析到 senior 角色——canAccess的前缀匹配正是为此设计的。而NAVIGATION中的路径与角色保持不变所以 senior 门禁改由导航角色经 pathname 门禁自然流转。Loadslug 校验 kind判别载荷服务端 load 先用IMAGE_VIEW_SLUGS常量校验 slug未知即 404再按 slugswitch到既有 service最后返回一个带kind判别字段的载荷。见 apps/moderator/src/routes/images/[slug]/page.server.tsminor/remixSource→kind: review-highlight额外附带promptHighlightminor 队列只高亮minor/young/age相关类别remixSource 高亮整段 promptpoi/tag/newUser/modRule/csam→kind: reviewreported→kind: reportedappeals→kind: appeal。同时modRule视图会额外用getModerationRuleDefinitions拉取规则定义并挂到条目上所有视图都会通过withModel3d补齐该图片是否为某 3D 模型唯一缩略图的关联信息。最终返回的载荷统一携带baselimit/level/tagIds/excludedTagIds与nextCursor。Page按kind分支渲染页面层对kind做判别每个分支渲染同一个ImageQueueGrid只是传入不同的cardsnippet。见 apps/moderator/src/routes/images/[slug]/page.sveltereview-highlightminor 视图显示Minor / Not minor、Acceptable minor徽章与PromptHighlightremixSource 显示Remix source — prompt flaggedreported卡片渲染举报原因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。它是泛型组件genericsT extends { id: number; url: string; type: MediaType; nsfwLevel?: number }完整 props 契约如下Prop类型说明itemsT[]队列条目type/url供EdgeMedia渲染civitaiUrlstring主站地址卡片角标外链${civitaiUrl}/images/${item.id}nextCursornumber \| string游标分页仅向前Back 沿 URL 中的 trail 回退编号模式忽略total/perPage/pagenumber编号分页三件套要传就全传——page必须来自服务端服务端已钳制从 URL 反推会渲染一个指向错误页面的分页器keyOf(item) string \| number键访问器默认取图片 idreported 队列按 report id 键控itemClass(item) string每卡 class用于乐观变暗如opacity-60cardSnippet[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 → 深红。分页有两种模式由数据自动判别编号分页totalperPage齐全时渲染 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.svelteacted: SvelteMapstring | 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-tagsTags Needing Reviewmoderate动作image-rating-reviewsetLevel动作downleveled-reviewsetLevel动作。故意不迁移的两个页面则划出了过度改造overreach的边界ingestion-error-review——动作在图片上方且无 aspect 盒网格也不同auto-fit 300px迁移会重塑其 UXimages/to-ingest——稠密小卡浏览画廊grid-cols-2..5、元数据比例、object-cover只读用途与审核卡不同。一句话总结边界共享路由不等于共享网格共享网格也不等于共享布局语义——只有形状真正一致的才值得统一。执行序列与当前状态计划文档给出的推进序列与落地状态提交号25ca66c4d5、c2444bfea6见原文档✅ 当前批次读取基础 统一导航 reported 计数 prompt 高亮 ——25ca66c4d5✅Plan Areview 家族[slug]统一 pathname 门禁 ——c2444bfea6✅Plan BImageQueueGrid原语 迁移 image-tags / image-rating-review / downleveled-reviewingestion-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 actionsaccept/blockaccept → 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.sveltereview 家族统一路由见 images/[slug] 与 页面层全局权限门禁见 hooks.server.ts。【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考