fix: update error\warning flow

This commit is contained in:
2026-10-11 10:58:54 +05:00
parent f67ddb3b0a
commit 3c9175f8a3
9 changed files with 138 additions and 41 deletions
+10
View File
@@ -114,6 +114,16 @@
## UI и качество ## UI и качество
- [ ] **обдумать агрегацию предупреждений на тайле** — сейчас разрешено два
бейджа рядом (derived `resultNote` + runtime-warnings); после
использования решить, нужен ли один сводный бейдж со счётчиком и списком
- [ ] **лимит КБ/качество как авто-шаг пайплайна** — вынести size limit/quality
из меню Download в автоматический инструмент цепочки, который применяет
выбранные настройки (сжатие видно в превью и в цепочке, ошибки
недостижимости ложатся на тайл шага). Нужен флоу передачи индикатора из
такого шага в кнопку Download (ошибка/предупреждение/итоговый размер).
Связано с пунктами про индикацию активного сжатия и превью итогового
результата
- [ ] кнопку редактирования названия пайплайна на этой же странице - [ ] кнопку редактирования названия пайплайна на этой же странице
- [ ] при загрузке нескольких файлов, показывает source не той же высоты, как - [ ] при загрузке нескольких файлов, показывает source не той же высоты, как
result result
@@ -18,6 +18,9 @@
// Runtime warnings (chain/download events): shown as badges next to the // Runtime warnings (chain/download events): shown as badges next to the
// derived result note, but from a different source. // derived result note, but from a different source.
warnings?: string[]; warnings?: string[];
// Runtime errors: danger badges; detail goes to the native tooltip until
// a popover with full text exists.
errors?: { label: string; detail?: string }[];
actions?: Snippet; actions?: Snippet;
} }
let { let {
@@ -31,6 +34,7 @@
note = undefined, note = undefined,
noteTone = "warning", noteTone = "warning",
warnings = [], warnings = [],
errors = [],
actions = undefined, actions = undefined,
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -56,6 +60,14 @@
{#if note} {#if note}
<Badge tone={noteTone} label={note} /> <Badge tone={noteTone} label={note} />
{/if} {/if}
{#each errors as error (error.label + (error.detail ?? ""))}
<Badge
tone="danger"
label={error.label}
title={error.detail}
role="alert"
/>
{/each}
{#each warnings as warning (warning)} {#each warnings as warning (warning)}
<Badge tone="warning" label={warning} role="status" /> <Badge tone="warning" label={warning} role="status" />
{/each} {/each}
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import DownloadButton from "$lib/components/ui/DownloadButton.svelte"; import DownloadButton from "$lib/components/ui/DownloadButton.svelte";
import UploadButton from "$lib/components/ui/UploadButton.svelte"; import UploadButton from "$lib/components/ui/UploadButton.svelte";
import Badge from "$lib/components/ui/Badge.svelte";
import type { OutputMime } from "$lib/core/io"; import type { OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/output-formats"; import { outputFormatByMime } from "$lib/output-formats";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
@@ -16,6 +17,9 @@
quality?: number; quality?: number;
limitKb?: number; limitKb?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
// Download-zone feedback: size-limit misses and action failures.
warnings?: string[];
error?: { label: string; detail?: string } | null;
onupload: (file: File) => void; onupload: (file: File) => void;
ondownload: () => void; ondownload: () => void;
onformat?: (mime: OutputMime) => void; onformat?: (mime: OutputMime) => void;
@@ -31,6 +35,8 @@
quality = undefined, quality = undefined,
limitKb = undefined, limitKb = undefined,
alphaLoss = false, alphaLoss = false,
warnings = [],
error = null,
onupload, onupload,
ondownload, ondownload,
onformat, onformat,
@@ -61,6 +67,17 @@
disabled={running} disabled={running}
/> />
{/if} {/if}
{#each warnings as warning (warning)}
<Badge tone="warning" label={warning} role="status" />
{/each}
{#if error}
<Badge
tone="danger"
label={error.label}
title={error.detail}
role="alert"
/>
{/if}
</div> </div>
{#if canDownload && alphaLoss} {#if canDownload && alphaLoss}
<p class="alpha-warning" role="status"> <p class="alpha-warning" role="status">
@@ -14,6 +14,7 @@
result: PixelImage | null; result: PixelImage | null;
resultNote?: ResultNote | null; resultNote?: ResultNote | null;
warnings?: string[]; warnings?: string[];
errors?: { label: string; detail?: string }[];
fileResult?: FileResult | null; fileResult?: FileResult | null;
textResult?: string | null; textResult?: string | null;
textVars?: Record<string, string | number>; textVars?: Record<string, string | number>;
@@ -30,6 +31,7 @@
result, result,
resultNote = null, resultNote = null,
warnings = [], warnings = [],
errors = [],
fileResult = null, fileResult = null,
textResult = null, textResult = null,
textVars = undefined, textVars = undefined,
@@ -71,6 +73,7 @@
note={resultNote ? t(resultNote.key) : undefined} note={resultNote ? t(resultNote.key) : undefined}
noteTone={resultNote?.tone ?? "warning"} noteTone={resultNote?.tone ?? "warning"}
{warnings} {warnings}
{errors}
> >
{#snippet actions()} {#snippet actions()}
{#if ontogglemask} {#if ontogglemask}
@@ -15,6 +15,8 @@
textSource?: string; textSource?: string;
running?: boolean; running?: boolean;
fileCount?: number; fileCount?: number;
warnings?: string[];
errors?: { label: string; detail?: string }[];
ontextinput?: (text: string) => void; ontextinput?: (text: string) => void;
onrendertext?: () => void; onrendertext?: () => void;
onupload?: (file: File) => void; onupload?: (file: File) => void;
@@ -27,6 +29,8 @@
textSource = "", textSource = "",
running = false, running = false,
fileCount = 0, fileCount = 0,
warnings = [],
errors = [],
ontextinput, ontextinput,
onrendertext, onrendertext,
onupload, onupload,
@@ -83,7 +87,13 @@
} }
</script> </script>
<PreviewTile label={t("sourceCard.source")} viewMode={mode} dims={sourceDims}> <PreviewTile
label={t("sourceCard.source")}
viewMode={mode}
dims={sourceDims}
{warnings}
{errors}
>
{#if mode === "text"} {#if mode === "text"}
<div class="text-source-wrap"> <div class="text-source-wrap">
<SchemaTextSource <SchemaTextSource
@@ -32,6 +32,7 @@
import { createOutputControls } from "./output-controls.svelte"; import { createOutputControls } from "./output-controls.svelte";
import { hasPreviewResult } from "./schema-preview-model"; import { hasPreviewResult } from "./schema-preview-model";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte"; import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import type { DisplayError } from "./schema-tool-runner.svelte";
import { import {
createSchemaToolState, createSchemaToolState,
toolSchemaOf, toolSchemaOf,
@@ -63,7 +64,7 @@
setSteps: (v) => (stepState.steps = v), setSteps: (v) => (stepState.steps = v),
touchedFor: (key) => stepState.touchedFor(key), touchedFor: (key) => stepState.touchedFor(key),
reportError: (e) => runner.reportError(e), reportError: (e) => runner.reportError(e),
clearError: () => (runner.displayError = null), clearError: () => runner.clearSourceError(),
}); });
const stepState = createSchemaToolState({ const stepState = createSchemaToolState({
@@ -122,12 +123,36 @@
const stepResults = $derived(runner.stepResults); const stepResults = $derived(runner.stepResults);
const stepFileSets = $derived(runner.stepFileSets); const stepFileSets = $derived(runner.stepFileSets);
const runWarnings = $derived(runner.runWarnings); const runWarnings = $derived(runner.runWarnings);
const displayError = $derived(runner.displayError); // Feedback zones: source tile / step tiles / download button area.
const warningTexts = $derived( const sourceWarningTexts = $derived(
[...upload.sourceWarnings, ...runWarnings, ...runner.downloadWarnings].map( upload.sourceWarnings.map(chainWarningText),
chainWarningText,
),
); );
const warningsByStep = $derived.by(() => {
const byStep: string[][] = steps.map(() => []);
for (const w of runWarnings) {
if ("step" in w && w.step >= 1 && w.step <= byStep.length) {
byStep[w.step - 1].push(chainWarningText(w));
}
}
return byStep;
});
const downloadWarningTexts = $derived(
runner.downloadWarnings.map(chainWarningText),
);
const stepError = $derived(runner.stepError);
const sourceError = $derived(runner.sourceError);
const actionError = $derived(runner.actionError);
function displayText(e: DisplayError | null): string {
if (!e) return "";
return e.kind === "i18n" ? t(e.key, e.vars) : e.text;
}
function errorBadge(e: DisplayError | null) {
return e
? [{ label: t("actions.actionFailed"), detail: displayText(e) }]
: [];
}
const masks = createStepMasks({ const masks = createStepMasks({
steps: () => steps, steps: () => steps,
@@ -176,14 +201,6 @@
: null, : null,
); );
const errorText = $derived.by(() => {
const current = displayError;
if (!current) return "";
return current.kind === "i18n"
? t(current.key, current.vars)
: current.text;
});
const debouncedRun = debounce(() => run(), 200); const debouncedRun = debounce(() => run(), 200);
$effect(() => { $effect(() => {
@@ -288,6 +305,8 @@
textSource={upload.textSource} textSource={upload.textSource}
running={busy} running={busy}
fileCount={upload.sourceFiles.length} fileCount={upload.sourceFiles.length}
warnings={sourceWarningTexts}
errors={errorBadge(sourceError)}
ontextinput={(v) => (upload.textSource = v)} ontextinput={(v) => (upload.textSource = v)}
onrendertext={run} onrendertext={run}
onupload={upload.handleFile} onupload={upload.handleFile}
@@ -320,7 +339,10 @@
resultKind={displayResultKind} resultKind={displayResultKind}
result={out} result={out}
{resultNote} {resultNote}
warnings={warningTexts} warnings={warningsByStep[i] ?? []}
errors={stepError && stepError.step === i
? [{ label: stepError.label, detail: stepError.detail }]
: []}
{fileResult} {fileResult}
{textResult} {textResult}
textVars={verdictVars} textVars={verdictVars}
@@ -341,6 +363,10 @@
viewMode="image" viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined} dims={out ? `${out.width} × ${out.height}` : undefined}
parts={outSet.length > 1 ? outSet.length : undefined} parts={outSet.length > 1 ? outSet.length : undefined}
warnings={warningsByStep[i] ?? []}
errors={stepError && stepError.step === i
? [{ label: stepError.label, detail: stepError.detail }]
: []}
> >
{#snippet actions()} {#snippet actions()}
{@render maskChip(i)} {@render maskChip(i)}
@@ -381,6 +407,13 @@
quality={output.quality} quality={output.quality}
limitKb={output.limitKb} limitKb={output.limitKb}
alphaLoss={output.alphaLoss} alphaLoss={output.alphaLoss}
warnings={downloadWarningTexts}
error={actionError
? {
label: t("actions.actionFailed"),
detail: displayText(actionError),
}
: null}
onupload={upload.handleFile} onupload={upload.handleFile}
ondownload={download} ondownload={download}
onformat={(v) => (output.format = v)} onformat={(v) => (output.format = v)}
@@ -442,9 +475,6 @@
</section> </section>
</div> </div>
{/if} {/if}
{#if errorText}
<p class="error" role="alert">{errorText}</p>
{/if}
<StepReorderOverlay {steps} dnd={stepDnd} /> <StepReorderOverlay {steps} dnd={stepDnd} />
</DragDropProvider> </DragDropProvider>
</div> </div>
@@ -611,11 +641,6 @@
border-radius: 0 0 var(--radius-m) var(--radius-m); border-radius: 0 0 var(--radius-m) var(--radius-m);
background: var(--color-panel); background: var(--color-panel);
} }
.error {
margin: var(--space-l) 0 0;
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.empty { .empty {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
@@ -58,7 +58,12 @@ interface RunnerState {
runWarnings: ChainWarning[]; runWarnings: ChainWarning[];
// Size-limit feedback from the last download (limit unreachable etc.). // Size-limit feedback from the last download (limit unreachable etc.).
downloadWarnings: ChainWarning[]; downloadWarnings: ChainWarning[];
displayError: DisplayError | null; // Chain step failure: badge lands on that step's tile (0-based index).
stepError: { step: number; label: string; detail: string } | null;
// Source decode/upload failures (via reportError): badge on the source tile.
sourceError: DisplayError | null;
// Download/copy and non-chain run failures: badge near the download button.
actionError: DisplayError | null;
} }
function toDisplayError(e: unknown): DisplayError { function toDisplayError(e: unknown): DisplayError {
@@ -101,7 +106,8 @@ async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
const steps = ctx.steps(); const steps = ctx.steps();
if (!schema || steps.length === 0) return; if (!schema || steps.length === 0) return;
const gen = ++s.runGen; const gen = ++s.runGen;
s.displayError = null; s.stepError = null;
s.actionError = null;
s.running = true; s.running = true;
try { try {
const source = ctx.source(); const source = ctx.source();
@@ -125,16 +131,17 @@ async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
if (e instanceof ChainStepError) { if (e instanceof ChainStepError) {
const base = toDisplayError(e.cause); const base = toDisplayError(e.cause);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text; const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
s.displayError = { s.stepError = {
kind: "plain", step: e.stepIndex,
text: t("toolPage.stepError", { label: t("chain.stepFailed", { n: e.stepIndex + 1 }),
detail: t("toolPage.stepError", {
n: e.stepIndex + 1, n: e.stepIndex + 1,
title: chainStepTitle(e.toolId), title: chainStepTitle(e.toolId),
msg, msg,
}), }),
}; };
} else { } else {
s.displayError = toDisplayError(e); s.actionError = toDisplayError(e);
} }
} finally { } finally {
if (gen === s.runGen) s.running = false; if (gen === s.runGen) s.running = false;
@@ -144,7 +151,7 @@ async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) { async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
if (!ctx.schema()) return; if (!ctx.schema()) return;
s.downloading = true; s.downloading = true;
s.displayError = null; s.actionError = null;
try { try {
if (s.fileResult) { if (s.fileResult) {
await downloadZip(s.fileResult.files, `${ctx.pageSlug()}.zip`); await downloadZip(s.fileResult.files, `${ctx.pageSlug()}.zip`);
@@ -179,7 +186,7 @@ async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`); downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`);
} }
} catch (e) { } catch (e) {
s.displayError = toDisplayError(e); s.actionError = toDisplayError(e);
} finally { } finally {
s.downloading = false; s.downloading = false;
} }
@@ -195,7 +202,7 @@ async function copyText(s: RunnerState, ctx: SchemaToolRunnerCtx) {
try { try {
await navigator.clipboard.writeText(copyValue); await navigator.clipboard.writeText(copyValue);
} catch (e) { } catch (e) {
s.displayError = toDisplayError(e); s.actionError = toDisplayError(e);
} }
} }
@@ -205,7 +212,7 @@ async function downloadText(s: RunnerState, ctx: SchemaToolRunnerCtx) {
const blob = new Blob([s.textResult], { type: "text/plain" }); const blob = new Blob([s.textResult], { type: "text/plain" });
downloadBlob(blob, `${ctx.pageSlug()}.txt`); downloadBlob(blob, `${ctx.pageSlug()}.txt`);
} catch (e) { } catch (e) {
s.displayError = toDisplayError(e); s.actionError = toDisplayError(e);
} }
} }
@@ -215,7 +222,8 @@ function clearResults(s: RunnerState) {
s.textResult = null; s.textResult = null;
s.verdictVars = undefined; s.verdictVars = undefined;
s.downloadWarnings = []; s.downloadWarnings = [];
s.displayError = null; s.stepError = null;
s.actionError = null;
} }
// Execution state and actions of SchemaToolView: run/download/clipboard and // Execution state and actions of SchemaToolView: run/download/clipboard and
@@ -234,7 +242,9 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
stepFileSets: [], stepFileSets: [],
runWarnings: [], runWarnings: [],
downloadWarnings: [], downloadWarnings: [],
displayError: null, stepError: null,
sourceError: null,
actionError: null,
}); });
return { return {
get running() { get running() {
@@ -270,11 +280,17 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
get downloadWarnings() { get downloadWarnings() {
return s.downloadWarnings; return s.downloadWarnings;
}, },
get displayError() { get stepError() {
return s.displayError; return s.stepError;
}, },
set displayError(v: DisplayError | null) { get sourceError() {
s.displayError = v; return s.sourceError;
},
get actionError() {
return s.actionError;
},
clearSourceError: () => {
s.sourceError = null;
}, },
run: () => run(s, ctx), run: () => run(s, ctx),
download: () => download(s, ctx), download: () => download(s, ctx),
@@ -290,7 +306,7 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
}, },
clearResults: () => clearResults(s), clearResults: () => clearResults(s),
reportError: (e: unknown) => { reportError: (e: unknown) => {
s.displayError = toDisplayError(e); s.sourceError = toDisplayError(e);
}, },
}; };
} }
+2
View File
@@ -69,6 +69,7 @@ export const en: Dict = {
warnSourceSkip: "{skipped} of {total} files could not be read", warnSourceSkip: "{skipped} of {total} files could not be read",
warnSizeLimitMiss: warnSizeLimitMiss:
"Could not fit into {limit} KB — downloaded file is {actual} KB", "Could not fit into {limit} KB — downloaded file is {actual} KB",
stepFailed: "Step {n} failed",
}, },
savedChains: { savedChains: {
pageTitle: "Saved pipelines — easy-png-tools", pageTitle: "Saved pipelines — easy-png-tools",
@@ -141,6 +142,7 @@ export const en: Dict = {
sizeLimitKb: "Max size, KB", sizeLimitKb: "Max size, KB",
sizeLimitBmp: sizeLimitBmp:
"Size limit does not apply to BMP — it is always uncompressed", "Size limit does not apply to BMP — it is always uncompressed",
actionFailed: "Failed",
}, },
textSource: { textSource: {
placeholder: "Paste base64 / hex / bytes…", placeholder: "Paste base64 / hex / bytes…",
+2
View File
@@ -71,6 +71,7 @@ export const ru: Dict = {
warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать", warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать",
warnSizeLimitMiss: warnSizeLimitMiss:
"Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ", "Не удалось ужаться до {limit} КБ — скачан файл на {actual} КБ",
stepFailed: "Шаг {n}: ошибка",
}, },
savedChains: { savedChains: {
pageTitle: "Сохранённые пайплайны — easy-png-tools", pageTitle: "Сохранённые пайплайны — easy-png-tools",
@@ -142,6 +143,7 @@ export const ru: Dict = {
alphaLoss: "В результате есть прозрачность — {format} её уберёт", alphaLoss: "В результате есть прозрачность — {format} её уберёт",
sizeLimitKb: "Лимит, КБ", sizeLimitKb: "Лимит, КБ",
sizeLimitBmp: "Лимит размера не действует на BMP — он всегда несжатый", sizeLimitBmp: "Лимит размера не действует на BMP — он всегда несжатый",
actionFailed: "Ошибка",
}, },
textSource: { textSource: {
placeholder: "Вставьте base64 / hex / байты…", placeholder: "Вставьте base64 / hex / байты…",