Импорт и экспорт
Первый экран, где действие занимает время, и единственный, где задание вправе получиться наполовину.
Что он доказывает
Все остальные примеры на сайте отвечают мгновенно. Этот — нет, и всё трудное в нём растёт отсюда: экран обязан сказать, что происходит, что после этого останется и что делать, когда ответ — не «получилось» и не «не получилось».
Нажмите Импортировать CSV и посмотрите. Задание заканчивается так, как импорт заканчивается на самом деле.
У «готово» четыре состояния, а не два
Задание завершается с ошибками: 797 строк записано, 3 отклонено. Это обычный исход импорта настоящего файла, и почти всякий экран прячет его за зелёной галочкой — потому что у крутящегося кружка два выхода, а третий никто не нарисовал.
Честных состояний здесь четыре: идёт, завершено, завершено с ошибками, остановлено. Последним двум нужны слова, а не цвет.
Остановить — не значит отменить
Об этом написано в подтверждении до нажатия: уже записанные строки останутся записанными. Остановка на середине оставляет данные в состоянии, которое ничем на экране нельзя назвать «как было», — поэтому экран называет его тем, что оно есть.
Именно этой фразы нет в большинстве окон отмены, и правду читатель узнаёт потом, от поддержки.
Отказ — не сообщение, а задача
«7 строк не прошли» говорит, что где-то есть проблема. Этот экран показывает, какие строки, какое поле и почему — два адреса в одной ячейке, неизвестный код «UK» — ожидался «GB» — и предлагает повторить только их или забрать файлом.
Отклонённая строка всё ещё ждёт. Пока экран не сказал, что с ней делать, импорт не закончен — он брошен.
Задание переживает вкладку
Написано под полосой хода: задание идёт на сервере, и закрытие вкладки его не останавливает. Правда ли это — дело бэкенда, но сказать об этом обязан экран: иначе читатель гадает и ошибается в обе стороны.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrCardДанныеGrDataTableДанныеGrBadgeОбратная связьGrButtonДействияGrProgressBarОбратная связьGrStepsНавигацияGrAlertОбратная связьGrDescriptionListДанныеGrConfirmDialogСлои
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — examples/data-management/App.vue. Отдельной копии кода не существует.
<script setup lang="ts">
/**
* `/examples/data-management` — импорт и экспорт, работа C2.
*
* Первый экран раздела, где действие не мгновенно. Всё трудное здесь — про
* **время** и про то, что операция после себя оставляет.
*
* Три утверждения, вокруг которых он собран:
*
* 1. «Готово» — не два состояния, а четыре. Частичный успех бывает чаще
* полного, и почти всякий интерфейс импорта его прячет за зелёной галочкой.
* 2. Остановка не отменяет. Записанные строки остаются записанными, и это
* обязано быть написано **до** нажатия, а не после.
* 3. Отказ обязан быть действием. «7 строк не прошли» — это не сообщение, пока
* рядом нет ответа на вопрос «какие и что теперь».
*/
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity/components/GrDataTable'
import { GrDataTable } from '@feugene/granularity/components/GrDataTable'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrProgressBar } from '@feugene/granularity/components/GrProgressBar'
import { GrSteps } from '@feugene/granularity/components/GrSteps'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrDescriptionList } from '@feugene/granularity/components/GrDescriptionList'
import { GrConfirmDialog } from '@feugene/granularity/components/GrConfirmDialog'
import type { DataJob, DataManagementData } from './data'
const props = defineProps<{ data: DataManagementData }>()
const jobs = reactive<DataJob[]>(props.data.seed.map(job => ({ ...job, failures: [...job.failures] })))
const selectedId = ref<number | null>(props.data.seed[0]?.id ?? null)
const stopping = ref(false)
/** Попытка запустить второе задание, пока идёт первое. Отвечает словами. */
const busy = ref(false)
let ticker: ReturnType<typeof setInterval> | undefined
let nextId = props.data.seed.length + 1
onBeforeUnmount(() => clearInterval(ticker))
const selected = computed(() => jobs.find(job => job.id === selectedId.value))
const running = computed(() => jobs.find(job => job.state === 'running'))
/** Форма слова по числу: правило здесь, слова — в содержимом. */
function plural(n: number, forms: [string, string, string]): string {
const [one, few, many] = forms
const mod100 = n % 100
const mod10 = n % 10
if (mod100 >= 11 && mod100 <= 14)
return many
if (mod10 === 1)
return one
if (mod10 >= 2 && mod10 <= 4)
return few
return many
}
const STATE_TONE = { running: 'info', partial: 'warning', done: 'success', cancelled: 'neutral' } as const
function percent(job: DataJob) {
return job.total === 0 ? 0 : Math.round((job.written / job.total) * 100)
}
/** Этап определяется ходом, а не отдельным полем: два источника разошлись бы. */
function stageOf(job: DataJob) {
const done = percent(job)
if (done < 15)
return 'validate'
return done < 90 ? 'write' : 'index'
}
function start(kind: DataJob['kind']) {
if (running.value) {
busy.value = true
window.setTimeout(() => (busy.value = false), 4000)
return
}
busy.value = false
const job: DataJob = {
id: nextId++,
kind,
subject: kind === 'import' ? 'orders-2026-09.csv' : props.data.seed[1].subject,
state: 'running',
total: kind === 'import' ? 800 : 24,
written: 0,
startedAt: '—',
failures: [],
}
jobs.unshift(job)
selectedId.value = job.id
/**
* Дальше меняется `live`, а не `job`.
*
* `reactive` отдаёт **прокси**, и шаблон читает именно его. Правка исходного
* объекта доходит до данных, но не до отрисовки: задание шло, полоса стояла
* на нуле, экран молчал. Ошибка тихая — ни исключения, ни предупреждения, —
* и нашёл её прогон, а не глаз.
*/
const live = jobs[0]
clearInterval(ticker)
ticker = setInterval(() => {
const step = Math.max(1, Math.round(live.total / 12))
live.written = Math.min(live.total, live.written + step)
if (live.written >= live.total) {
clearInterval(ticker)
// Импорт заканчивается частичным успехом намеренно: это и есть обычный
// исход, а не редкая беда.
if (kind === 'import') {
live.state = 'partial'
live.written = live.total - 3
live.failures = props.data.seed[0].failures.slice(0, 3)
}
else {
live.state = 'done'
}
}
}, 260)
}
function confirmStop() {
const job = running.value
clearInterval(ticker)
if (job)
job.state = 'cancelled'
stopping.value = false
}
const columns = computed<GrDataColumn[]>(() => {
const label = props.data.columns
return [
{ key: 'subject', label: label.subject },
{ key: 'state', label: label.state },
{ key: 'progress', label: label.progress },
{ key: 'startedAt', label: label.started },
]
})
const failureColumns = computed<GrDataColumn[]>(() => {
const label = props.data.failureColumns
return [
{ key: 'row', label: label.row, align: 'right' },
{ key: 'field', label: label.field },
{ key: 'reason', label: label.reason },
]
})
const facts = computed(() => {
const job = selected.value
if (!job)
return []
const label = props.data.details.facts
return [
{ label: label.kind, value: props.data.kinds[job.kind] },
{ label: label.subject, value: job.subject },
{ label: label.total, value: String(job.total) },
{ label: label.written, value: String(job.written) },
{ label: label.failed, value: String(job.failures.length) },
{ label: label.started, value: job.startedAt },
]
})
</script>
<template>
<div class="screen">
<header>
<p class="screen__brand">{{ data.brand }}</p>
<h1 class="screen__title">{{ data.title }}</h1>
<p class="screen__subtitle">{{ data.subtitle }}</p>
</header>
<div class="screen__actions">
<!--
Кнопки не гаснут, пока идёт задание, — то же правило, что на экране
входа: погашенная кнопка прячет причину, по которой она погашена.
Нажатие отвечает словами.
-->
<GrButton @click="start('import')">{{ data.startImport }}</GrButton>
<GrButton variant="outline" @click="start('export')">{{ data.startExport }}</GrButton>
<p class="screen__hint">{{ data.importHint }}</p>
</div>
<GrAlert v-if="busy" tone="info" live="polite" :title="data.oneAtATime" />
<div class="screen__split">
<GrCard class="screen__panel">
<div class="screen__scroll">
<GrDataTable :rows="jobs" :columns="columns" row-key="id" @row-click="({ row }) => selectedId = Number(row.id)">
<template #cell-subject="{ row }">
<button type="button" class="screen__link" @click="selectedId = Number(row.id)">
{{ row.subject }}
</button>
</template>
<template #cell-state="{ row }">
<GrBadge size="sm" :tone="STATE_TONE[row.state as DataJob['state']]">
{{ data.states[row.state as DataJob['state']] }}
</GrBadge>
</template>
<!--
Ход показывается числом и полосой. Полоса без числа отвечает
«примерно столько», а на вопрос «сколько ещё ждать» отвечает
только число.
-->
<template #cell-progress="{ row }">
<span class="screen__progress">
<GrProgressBar
:value="percent(row as DataJob)"
:tone="row.state === 'partial' ? 'warning' : 'primary'"
:aria-label="`${row.subject}`"
size="sm"
/>
<span class="screen__percent">{{ percent(row as DataJob) }}%</span>
</span>
</template>
</GrDataTable>
</div>
</GrCard>
<GrCard class="screen__panel">
<p v-if="!selected" class="screen__hint">{{ data.details.empty }}</p>
<template v-else>
<template v-if="selected.state === 'running'">
<GrSteps :model-value="stageOf(selected)" :steps="data.details.stages" variant="compact" />
<GrProgressBar
:value="percent(selected)"
:aria-label="selected.subject"
/>
<!-- То, что обязано быть написано до нажатия «Остановить». -->
<p class="screen__hint">{{ data.survives }}</p>
<GrButton variant="outline" tone="danger" @click="stopping = true">
{{ data.details.cancel }}
</GrButton>
</template>
<GrAlert
v-else-if="selected.state === 'partial'"
tone="warning"
:title="data.partialTitle"
:description="data.partialLead"
/>
<GrDescriptionList :items="facts" label-width="11rem" divided />
<!--
Отказ становится действием: видно, какие строки, почему, и что с
ними делать. «7 строк не прошли» без этого — не сообщение, а
уведомление о том, что где-то есть проблема.
-->
<template v-if="selected.failures.length">
<p class="screen__failuresTitle">
{{ selected.failures.length }} {{ plural(selected.failures.length, data.rowForms) }}
</p>
<div class="screen__scroll">
<GrDataTable :rows="selected.failures" :columns="failureColumns" row-key="row" />
</div>
<div class="screen__failureActions">
<GrButton size="sm">{{ data.details.retry }}</GrButton>
<GrButton size="sm" variant="outline">{{ data.details.download }}</GrButton>
</div>
</template>
</template>
</GrCard>
</div>
<GrConfirmDialog
v-model="stopping"
:title="data.details.cancelTitle"
:description="data.details.cancelDescription"
:confirm-text="data.details.cancelConfirm"
:cancel-text="data.details.cancelKeep"
confirm-tone="danger"
@confirm="confirmStop"
/>
</div>
</template>
<style scoped>
.screen {
display: grid;
align-content: start;
gap: var(--gr-space-4);
min-height: 100dvh;
padding: var(--gr-space-4);
background: var(--gr-bg);
color: var(--gr-fg);
}
.screen__brand {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__title {
margin: var(--gr-space-1) 0 0;
font-size: var(--gr-text-xl);
font-weight: 600;
}
.screen__subtitle {
margin: var(--gr-space-1) 0 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gr-space-3);
}
.screen__hint {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-xs);
}
.screen__split {
display: grid;
gap: var(--gr-space-3);
}
.screen__panel {
display: grid;
align-content: start;
gap: var(--gr-space-4);
padding: var(--gr-space-4);
}
.screen__scroll {
overflow-x: auto;
}
.screen__link {
background: none;
border: 0;
padding: 0;
color: var(--gr-primary-text);
font: inherit;
text-align: start;
text-decoration: underline;
text-decoration-thickness: from-font;
cursor: pointer;
}
.screen__progress {
display: flex;
align-items: center;
gap: var(--gr-space-2);
min-width: 8rem;
}
.screen__percent {
font-variant-numeric: tabular-nums;
font-size: var(--gr-text-xs);
color: var(--gr-muted-fg);
}
.screen__failuresTitle {
margin: 0;
font-weight: 600;
font-size: var(--gr-text-sm);
}
.screen__failureActions {
display: flex;
flex-wrap: wrap;
gap: var(--gr-space-2);
}
@media (min-width: 768px) {
.screen {
padding: var(--gr-space-5);
}
}
@media (min-width: 1200px) {
.screen__split {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
align-items: start;
}
}
</style>