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:
@@ -1 +1,71 @@
|
||||
<template><div class="text-caption">ImportFiltersForm — implemented in Task 15</div></template>
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Import filters</v-card-title>
|
||||
<v-card-text v-if="store.settings">
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.min_width" label="Min width (px)" type="number"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-text-field
|
||||
v-model.number="local.min_height" label="Min height (px)" type="number"
|
||||
density="compact" hide-details @blur="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch
|
||||
v-model="local.skip_transparent" label="Skip mostly-transparent images"
|
||||
density="compact" hide-details color="primary" @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-slider
|
||||
v-model="local.transparency_threshold" label="Transparency threshold"
|
||||
min="0" max="1" step="0.05" thumb-label hide-details color="accent"
|
||||
:disabled="!local.skip_transparent" @end="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-switch
|
||||
v-model="local.skip_single_color" label="Skip near-single-color images"
|
||||
density="compact" hide-details color="primary" @change="save"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6">
|
||||
<v-slider
|
||||
v-model="local.single_color_threshold" label="Single-color threshold"
|
||||
min="0.5" max="1" step="0.05" thumb-label hide-details color="accent"
|
||||
:disabled="!local.skip_single_color" @end="save"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-alert v-if="store.settingsError" type="error" variant="tonal" class="mt-2" closable>
|
||||
{{ store.settingsError }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-text v-else>
|
||||
<v-skeleton-loader type="paragraph" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const store = useImportStore()
|
||||
const local = reactive({
|
||||
min_width: 0, min_height: 0,
|
||||
skip_transparent: false, transparency_threshold: 0.9,
|
||||
skip_single_color: false, single_color_threshold: 0.95
|
||||
})
|
||||
|
||||
watch(() => store.settings, (s) => { if (s) Object.assign(local, s) }, { immediate: true })
|
||||
|
||||
async function save() {
|
||||
await store.patchSettings({ ...local })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +1,45 @@
|
||||
<template><div class="text-caption">ImportTriggerPanel — implemented in Task 15</div></template>
|
||||
<template>
|
||||
<v-card>
|
||||
<v-card-title>Trigger scan</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="store.activeBatch" class="d-flex align-center" style="gap: 12px;">
|
||||
<v-progress-circular
|
||||
indeterminate color="accent" size="20"
|
||||
/>
|
||||
<span>
|
||||
Scanning {{ store.activeBatch.source_path }} —
|
||||
imported {{ store.activeBatch.imported }},
|
||||
skipped {{ store.activeBatch.skipped }},
|
||||
failed {{ store.activeBatch.failed }} /
|
||||
{{ store.activeBatch.total_files }} files
|
||||
</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="text-body-2 mb-3">
|
||||
Run a quick scan of the import directory. Deep scan (pHash dedup,
|
||||
archives) lands in FC-2d.
|
||||
</p>
|
||||
<v-btn color="primary" rounded="pill" @click="trigger" :loading="busy">
|
||||
<v-icon start>mdi-magnify-scan</v-icon>
|
||||
Quick scan
|
||||
</v-btn>
|
||||
<v-alert v-if="store.triggerError" type="error" variant="tonal" class="mt-3" closable>
|
||||
{{ store.triggerError }}
|
||||
</v-alert>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useImportStore } from '../../stores/import.js'
|
||||
|
||||
const store = useImportStore()
|
||||
const busy = ref(false)
|
||||
|
||||
async function trigger() {
|
||||
busy.value = true
|
||||
try { await store.triggerScan() } catch {} finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user