Импорт и экспорт

  • Рабочие экраны
  • компонентов: 9
  • пакетов: 1

Первый экран, где действие занимает время, и единственный, где задание вправе получиться наполовину.

Ревизия 2026-09-06

На этой странице
Во всю ширину

Что он доказывает

Все остальные примеры на сайте отвечают мгновенно. Этот — нет, и всё трудное в нём растёт отсюда: экран обязан сказать, что происходит, что после этого останется и что делать, когда ответ — не «получилось» и не «не получилось».

Нажмите Импортировать CSV и посмотрите. Задание заканчивается так, как импорт заканчивается на самом деле.

У «готово» четыре состояния, а не два

Задание завершается с ошибками: 797 строк записано, 3 отклонено. Это обычный исход импорта настоящего файла, и почти всякий экран прячет его за зелёной галочкой — потому что у крутящегося кружка два выхода, а третий никто не нарисовал.

Честных состояний здесь четыре: идёт, завершено, завершено с ошибками, остановлено. Последним двум нужны слова, а не цвет.

Остановить — не значит отменить

Об этом написано в подтверждении до нажатия: уже записанные строки останутся записанными. Остановка на середине оставляет данные в состоянии, которое ничем на экране нельзя назвать «как было», — поэтому экран называет его тем, что оно есть.

Именно этой фразы нет в большинстве окон отмены, и правду читатель узнаёт потом, от поддержки.

Отказ — не сообщение, а задача

«7 строк не прошли» говорит, что где-то есть проблема. Этот экран показывает, какие строки, какое поле и почему — два адреса в одной ячейке, неизвестный код «UK» — ожидался «GB» — и предлагает повторить только их или забрать файлом.

Отклонённая строка всё ещё ждёт. Пока экран не сказал, что с ней делать, импорт не закончен — он брошен.

Задание переживает вкладку

Написано под полосой хода: задание идёт на сервере, и закрытие вкладки его не останавливает. Правда ли это — дело бэкенда, но сказать об этом обязан экран: иначе читатель гадает и ошибается в обе стороны.

Использованные компоненты

Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.

Пакеты:

  • @feugene/granularity

Точка входа

Тот же файл, который здесь исполняется, — examples/data-management/App.vue. Отдельной копии кода не существует.

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>

Собрать такое же