Files
ComfyUI_frontend/src/platform/missingModel/components/MissingModelCard.vue
jaeone94 c36da042d0 Redesign error tab cards with summary hero and unified sections (#12828)
## Summary

Redesigns the Errors tab cards to match the new Figma error-panel spec
(file `Czv0JcCfcUiizeEZURevpq`): every error group is now wrapped in a
single bordered card led by an error-count summary hero, with each
category rendered as a collapsible section whose count lives in a
circular badge rather than a parenthetical title suffix.

> Rebased onto `main` after #12793 was merged. This PR now contains only
the error-card redesign slice.

## Changes

- **What**:
- **New `ErrorCardSection.vue`** — the shared section shell used by
every error type. Renders a 32px header (circular count badge + neutral
title + `actions` slot + collapse chevron) and a `TransitionCollapse`
body. Replaces the per-group `PropertiesAccordionItem`, dropping the old
octagon-alert icon + red title + `(n)` suffix and the sticky-header
behavior.
- **`TabErrors.vue`** — wraps all groups in one `rounded-lg` card
bordered with `secondary-background`. Adds a summary **hero** (large
severity-colored total count, vertical divider, "N Errors detected /
Resolve before running the workflow"). Moves the per-group action
buttons (Install All / Replace All / missing-model Refresh) into the
section's `actions` slot. Adds `getGroupCount()` / `totalErrorCount` and
switches content background to `interface-panel-surface`. Most of the
line count here is re-indentation from the template restructure, not
behavior change.
- **`missingErrorResolver.ts`** — drops the `formatCountTitle` helper so
display titles are `"Missing Models"` instead of `"Missing Models (4)"`;
the badge now carries the count. Toast titles/messages are untouched.
- **`ErrorNodeCard.vue`** — restyles the runtime/validation error-log
box to the Figma spec: borderless `base-foreground/5` surface, `ERROR
LOG` header, 12px non-mono body at 50% opacity, inset footer divider
with Get Help / Find on GitHub links.
- **Row components** (`MissingModelRow`, `MissingPackGroupRow`,
`SwapNodeGroupRow`, `MissingMediaCard`, `MissingNodeCard`,
`MissingModelCard`) — align spacing, fonts, badges, and button sizes
with Figma: 12px row labels, `size="sm"` (24px) action buttons, 16px
count badges (`rounded-sm`, `secondary-background-hover`, 9px), 32px
reference-row heights, `px-3` card padding. Model-name wrapping is kept
independent of its count badge and link button so they never reflow into
the metadata sub-label.
- **i18n** — adds `errorsDetected` (pluralized), `resolveBeforeRun`,
`expand`, `collapse` to `en/main.json`.
- **Breaking**: None. No store, composable, action, or data-flow changes
— all handlers and emitted events are preserved. The only user-visible
copy change is the removal of the `(n)` count suffix from section
titles.

## Review Focus

- **Title copy change**: `"Missing Models (4)"` → `"Missing Models"`.
Search-filter matching against the old `(n)` string no longer applies,
but the count is shown by the badge and the hero total.
- **Sticky header removed**: section headers no longer pin to the top on
scroll (intentional per the new design).
- **Collapse click target**: the old single-button header (which nested
action buttons inside a `<button>` — invalid HTML) is split into a
separate title button and chevron button. Behavior is unchanged and
accessibility improves; the empty space beside an action button no
longer toggles collapse.
- All semantic colors map to existing design-system tokens (no `dark:`
variants, no hardcoded hex). Verified the artifact hex values match the
tokens (e.g. `#262729` = `secondary-background`, `#e04e48` =
`destructive-background-hover`, `#171718` = `interface-panel-surface`).

## Follow-up

This PR intentionally keeps the error-count ownership cleanup out of the
current diff so the card redesign remains reviewable. A follow-up PR
will centralize error counting around a single source of truth so the
Errors tab summary hero, section badges, and any overlay surfaces cannot
drift from one another.

That follow-up will also address the current count mismatch in the
ErrorOverlay and continue the ErrorOverlay redesign there, instead of
expanding this PR after review.

## Screenshots (if applicable)
After
<img width="603" height="703" alt="스크린샷 2026-06-13 오후 1 00 02"
src="https://github.com/user-attachments/assets/065d7c19-9748-4e99-9b43-675a31e92949"
/>
<img width="601" height="197" alt="스크린샷 2026-06-13 오후 1 01 07"
src="https://github.com/user-attachments/assets/0fa1fbda-9091-4a45-9eca-e99c43089c0e"
/>
<img width="617" height="612" alt="스크린샷 2026-06-13 오후 1 02 43"
src="https://github.com/user-attachments/assets/3d67a057-bf65-4e51-bcf5-70ecce851826"
/>
<img width="495" height="723" alt="스크린샷 2026-06-13 오후 1 03 28"
src="https://github.com/user-attachments/assets/6dcc4021-0fc3-4955-a68b-c0533c66a3cf"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-16 13:32:10 +00:00

176 lines
5.1 KiB
Vue

<template>
<div class="px-3">
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden"
>
<MissingModelRow
v-for="row in importableModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="true"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="unsupportedModelRows.length > 0"
data-testid="missing-model-import-not-supported-section"
class="flex flex-col gap-1 border-t border-secondary-background pt-3"
>
<div class="mb-1">
<p class="m-0 text-sm font-semibold text-warning-background">
{{ t('rightSidePanel.missingModels.importNotSupported') }}
</p>
<p class="m-0 mt-1 text-xs/relaxed text-muted-foreground">
{{ t('rightSidePanel.missingModels.customNodeDownloadDisabled') }}
</p>
</div>
<MissingModelRow
v-for="row in unsupportedModelRows"
:key="row.key"
:model="row.model"
:directory="row.directory"
:is-asset-supported="row.isAssetSupported"
:can-cloud-import="false"
@locate-model="emit('locateModel', $event)"
/>
</div>
<div
v-if="downloadableModels.length > 0"
data-testid="missing-model-actions"
class="flex items-center pt-2"
>
<Button
data-testid="missing-model-download-all"
variant="secondary"
size="sm"
class="h-8 min-w-0 flex-1 rounded-md text-xs"
@click="downloadAllModels"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />
<span class="truncate">{{ downloadAllLabel }}</span>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import { isCloud } from '@/platform/distribution/types'
import MissingModelRow from '@/platform/missingModel/components/MissingModelRow.vue'
import Button from '@/components/ui/button/Button.vue'
import { downloadModel } from '@/platform/missingModel/missingModelDownload'
import { getDownloadableModels } from '@/platform/missingModel/missingModelViewUtils'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { formatSize } from '@/utils/formatUtil'
interface MissingModelRowEntry {
key: string
model: MissingModelGroup['models'][number]
directory: string | null
isAssetSupported: boolean
}
const MODEL_TYPE_SORT_ORDER = [
'checkpoints',
'loras',
'vae',
'text_encoders',
'diffusion_models'
] as const
const { missingModelGroups } = defineProps<{
missingModelGroups: MissingModelGroup[]
}>()
const emit = defineEmits<{
locateModel: [nodeId: string]
}>()
const { t } = useI18n()
const missingModelStore = useMissingModelStore()
const sortedModelRows = computed(() =>
missingModelGroups
.flatMap((group) =>
group.models.map((model, index) => ({
key: getModelRowKey(group, model, index),
model,
directory: group.directory,
isAssetSupported: group.isAssetSupported
}))
)
.sort((a, b) => compareModelRows(a, b))
)
const importableModelRows = computed(() =>
sortedModelRows.value.filter((row) => !isCloud || canCloudImport(row))
)
const unsupportedModelRows = computed(() =>
isCloud ? sortedModelRows.value.filter((row) => !canCloudImport(row)) : []
)
const downloadableModels = computed(() => {
if (isCloud) return []
return getDownloadableModels(missingModelGroups)
})
const downloadAllLabel = computed(() => {
const base = t('rightSidePanel.missingModels.downloadAll')
const total = downloadableModels.value.reduce(
(sum, model) => sum + (missingModelStore.fileSizes[model.url] ?? 0),
0
)
return total > 0 ? `${base} (${formatSize(total)})` : base
})
function downloadAllModels() {
for (const model of downloadableModels.value) {
downloadModel(model, missingModelStore.folderPaths)
}
}
function getModelRowKey(
group: MissingModelGroup,
model: MissingModelGroup['models'][number],
index: number
) {
const supportKey = group.isAssetSupported ? 'supported' : 'unsupported'
return [
supportKey,
group.directory ?? '__unknown__',
model.name,
String(index)
].join('::')
}
function compareModelRows(a: MissingModelRowEntry, b: MissingModelRowEntry) {
return (
getModelTypeSortIndex(a.directory) - getModelTypeSortIndex(b.directory) ||
(a.directory ?? '').localeCompare(b.directory ?? '') ||
a.model.name.localeCompare(b.model.name)
)
}
function getModelTypeSortIndex(directory: string | null) {
if (directory === null) return Number.MAX_SAFE_INTEGER
const index = MODEL_TYPE_SORT_ORDER.indexOf(
directory as (typeof MODEL_TYPE_SORT_ORDER)[number]
)
return index === -1 ? MODEL_TYPE_SORT_ORDER.length : index
}
function canCloudImport(row: MissingModelRowEntry) {
return row.isAssetSupported && row.directory !== null
}
</script>