e49cea3eba
Cluster B frontend, milestone #99. #7c: AllowlistTable gains Applied + Covers columns and a live 'covers ~N at T' projection as the operator drags a row's threshold (debounced coverage call, then commits the threshold). allowlist store gains coverage(tagId, threshold) and refreshes coverage_count after a save. #7d: suggestions store surfaces a non-blocking toast when accept/alias newly allowlists a tag — '<verb>: <tag> — allowlisted, auto-applying to ~N images' (N is the projection; apply runs async). Falls back to the plain toast when the tag was already allowlisted. #8b: TagsView merge picker now previews the merge via usePreviewCommit before committing — shows images moving / already-on-target / series pages / alias-or- delete / a thumbnail sample, blocks the Merge button on an incompatible kind/fandom. adminStore.mergeTags gains a dryRun option. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
121 lines
4.6 KiB
Vue
121 lines
4.6 KiB
Vue
<template>
|
|
<MaintenanceTile
|
|
icon="mdi-playlist-check"
|
|
:title="`Allowlisted tags (${store.rows.length})`"
|
|
blurb="Tags auto-applied to images that score above their threshold. Tune the
|
|
threshold and see how many images it would cover."
|
|
>
|
|
<v-data-table-virtual
|
|
:headers="headers" :items="store.rows" :loading="store.loading"
|
|
height="360" density="compact"
|
|
no-data-text="No tags on the allowlist yet — accept a suggestion to add one."
|
|
>
|
|
<template #item.applied_count="{ item }">
|
|
<span class="fc-num">{{ item.applied_count ?? '—' }}</span>
|
|
</template>
|
|
|
|
<template #item.min_confidence="{ item }">
|
|
<div class="fc-thr">
|
|
<v-text-field
|
|
:model-value="item.min_confidence" type="number"
|
|
density="compact" hide-details style="max-width: 100px;"
|
|
:min="floor" max="1" step="0.05"
|
|
:aria-label="`Auto-apply threshold for ${item.tag_name}`"
|
|
@update:model-value="(v) => onThreshold(item, v)"
|
|
/>
|
|
<span
|
|
v-if="proj[item.tag_id]"
|
|
class="fc-thr__proj"
|
|
:class="{ 'fc-thr__proj--loading': proj[item.tag_id].loading }"
|
|
:title="`At ${proj[item.tag_id].threshold}, a sweep would cover this many images`"
|
|
>≈ {{ proj[item.tag_id].count }} at {{ proj[item.tag_id].threshold }}</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #item.coverage_count="{ item }">
|
|
<span class="fc-num" :title="`Images a sweep covers at ${item.min_confidence}`">
|
|
{{ item.coverage_count ?? '—' }}
|
|
</span>
|
|
</template>
|
|
|
|
<template #item.actions="{ item }">
|
|
<v-btn
|
|
icon="mdi-delete" size="x-small" variant="text" color="error"
|
|
:aria-label="`Remove ${item.tag_name} from the allowlist`"
|
|
@click="store.remove(item.tag_id)"
|
|
/>
|
|
</template>
|
|
</v-data-table-virtual>
|
|
<p class="fc-muted text-caption mt-2">
|
|
<strong>Applied</strong> = images currently carrying the tag.
|
|
<strong>Covers</strong> = images a sweep would auto-apply it to at the
|
|
current threshold. Lower the threshold to cover more (less certain) images.
|
|
</p>
|
|
</MaintenanceTile>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, reactive } from 'vue'
|
|
import { useAllowlistStore } from '../../stores/allowlist.js'
|
|
import { useMLStore } from '../../stores/ml.js'
|
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
|
|
|
const store = useAllowlistStore()
|
|
const ml = useMLStore()
|
|
// min_confidence can't be set below the tagger store floor — predictions
|
|
// below it aren't stored, so a lower threshold would behave identically to
|
|
// the floor. The backend clamps too (#764).
|
|
const floor = computed(() => ml.settings?.tagger_store_floor ?? 0.70)
|
|
const headers = [
|
|
{ title: 'Tag', key: 'tag_name', sortable: true },
|
|
{ title: 'Kind', key: 'tag_kind', sortable: true, width: 100 },
|
|
{ title: 'Applied', key: 'applied_count', sortable: true, width: 90 },
|
|
{ title: 'Min confidence', key: 'min_confidence', sortable: false, width: 220 },
|
|
{ title: 'Covers', key: 'coverage_count', sortable: true, width: 90 },
|
|
{ title: '', key: 'actions', sortable: false, width: 56 }
|
|
]
|
|
|
|
// Per-row live projection while the operator drags a threshold:
|
|
// proj[tagId] = { threshold, count, loading }
|
|
const proj = reactive({})
|
|
|
|
onMounted(() => {
|
|
store.load()
|
|
if (!ml.settings) ml.loadSettings()
|
|
})
|
|
|
|
const debounces = {}
|
|
function onThreshold(item, value) {
|
|
const tagId = item.tag_id
|
|
const v = Math.max(parseFloat(value), floor.value)
|
|
if (!(v > 0 && v <= 1)) return
|
|
const shown = Number(v.toFixed(2))
|
|
// Optimistic live projection box (loading until the count returns).
|
|
proj[tagId] = { threshold: shown, count: proj[tagId]?.count ?? '…', loading: true }
|
|
if (debounces[tagId]) clearTimeout(debounces[tagId])
|
|
debounces[tagId] = setTimeout(async () => {
|
|
try {
|
|
const { count } = await store.coverage(tagId, v)
|
|
proj[tagId] = { threshold: shown, count, loading: false }
|
|
} catch {
|
|
delete proj[tagId] // drop the projection rather than show a wrong number
|
|
}
|
|
// Commit the new threshold (also refreshes the row's stored coverage_count).
|
|
store.updateThreshold(tagId, v)
|
|
}, 500)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-num { font-variant-numeric: tabular-nums; }
|
|
.fc-thr { display: flex; align-items: center; gap: 10px; }
|
|
.fc-thr__proj {
|
|
font-size: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
color: rgb(var(--v-theme-accent));
|
|
white-space: nowrap;
|
|
}
|
|
.fc-thr__proj--loading { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
|
</style>
|