feat(fc2a): implement Settings Import tab components

ImportTriggerPanel shows an active-batch progress indicator when scan_directory
is running, or a "Quick scan" button otherwise. Deep scan deferred to FC-2d
is called out in the panel text so it's not a surprise.

ImportFiltersForm autosaves on blur/change so there's no Submit button. The
transparency and single-color sliders gate on their respective switches.

ImportTaskList uses v-data-table-virtual for performance on long histories,
with status filter, retry-failed, and a clear-completed dialog with age
buckets. Status chips are color-coded via FabledDesignSystem semantic
tokens (success/warning/error/accent/info).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 12:31:22 -04:00
parent 3bcff142f5
commit 968a9e14e3
3 changed files with 257 additions and 3 deletions
@@ -1 +1,141 @@
<template><div class="text-caption">ImportTaskList implemented in Task 15</div></template>
<template>
<v-card>
<v-card-title class="d-flex align-center" style="gap: 12px;">
<span>Recent import tasks</span>
<v-spacer />
<v-select
v-model="statusFilter" :items="statusOptions" density="compact"
hide-details style="max-width: 180px;" @update:model-value="onFilterChange"
/>
<v-btn variant="text" rounded="pill" size="small" @click="onRefresh">
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="warning"
:disabled="!hasFailed" @click="onRetryFailed"
>
Retry failed
</v-btn>
<v-btn
variant="text" rounded="pill" size="small" color="error"
@click="onClearOpen"
>
Clear completed
</v-btn>
</v-card-title>
<v-data-table-virtual
:headers="headers" :items="store.tasks" :loading="store.tasksLoading"
height="480" density="compact" no-data-text="No tasks yet trigger a scan above."
>
<template #item.status="{ item }">
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
{{ item.status }}
</v-chip>
</template>
<template #item.source_path="{ item }">
<span :title="item.source_path">{{ shorten(item.source_path) }}</span>
</template>
<template #item.size_bytes="{ item }">{{ formatBytes(item.size_bytes) }}</template>
<template #item.created_at="{ item }">{{ formatDate(item.created_at) }}</template>
<template #item.error="{ item }">
<span v-if="item.error" :title="item.error" class="text-caption">
{{ shorten(item.error, 60) }}
</span>
</template>
</v-data-table-virtual>
<div v-if="store.hasMore" class="d-flex justify-center py-3">
<v-btn variant="text" size="small" @click="onLoadMore">Load more</v-btn>
</div>
<v-dialog v-model="clearDialog" max-width="400">
<v-card>
<v-card-title>Clear completed tasks</v-card-title>
<v-card-text>
<v-select
v-model="clearAgeDays" label="Older than"
:items="[
{ title: 'All finished', value: 0 },
{ title: '1 day', value: 1 },
{ title: '7 days', value: 7 },
{ title: '30 days', value: 30 }
]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="clearDialog = false">Cancel</v-btn>
<v-btn color="error" rounded="pill" @click="onClearConfirm">Clear</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useImportStore } from '../../stores/import.js'
const store = useImportStore()
const statusFilter = ref(null)
const clearDialog = ref(false)
const clearAgeDays = ref(7)
const statusOptions = [
{ title: 'All', value: null },
{ title: 'Pending', value: 'pending' },
{ title: 'Queued', value: 'queued' },
{ title: 'Processing', value: 'processing' },
{ title: 'Complete', value: 'complete' },
{ title: 'Skipped', value: 'skipped' },
{ title: 'Failed', value: 'failed' }
]
const headers = [
{ title: 'Status', key: 'status', sortable: false, width: 120 },
{ title: 'Source', key: 'source_path', sortable: false },
{ title: 'Size', key: 'size_bytes', sortable: false, width: 90 },
{ title: 'Created', key: 'created_at', sortable: false, width: 150 },
{ title: 'Note', key: 'error', sortable: false }
]
const hasFailed = computed(() => store.tasks.some(t => t.status === 'failed'))
function statusColor(s) {
return {
complete: 'success',
skipped: 'warning',
failed: 'error',
processing: 'accent',
queued: 'info',
pending: 'info'
}[s] || 'default'
}
function shorten(s, max = 90) {
if (!s) return ''
if (s.length <= max) return s
const head = Math.floor((max - 3) * 0.6)
const tail = max - 3 - head
return s.slice(0, head) + '...' + s.slice(-tail)
}
function formatBytes(b) {
if (!b) return ''
const units = ['B', 'KiB', 'MiB', 'GiB']
let i = 0; let v = b
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function formatDate(s) {
try { return new Date(s).toLocaleString() } catch { return s }
}
async function onRefresh() { await store.loadTasks(true) }
function onFilterChange() { store.setStatusFilter(statusFilter.value); store.loadTasks(true) }
async function onLoadMore() { await store.loadTasks(false) }
async function onRetryFailed() { await store.retryFailed() }
function onClearOpen() { clearDialog.value = true }
async function onClearConfirm() {
await store.clearCompleted(clearAgeDays.value)
clearDialog.value = false
}
</script>