Files
FileHost/app/components/TagManagePanel.vue
T
O96u 4ed4692f5e
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
release: v1.4.2
Fix batch tagging and upload token auth; single API token and tagNames-only upload; tag management sort and responsive layout.
2026-09-23 15:38:46 +08:00

1163 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { ImageTag } from '~/types/image'
import {
defaultTagColor,
pickDefaultTagColorFromCandidates,
pickTagColorCandidates,
resolveTagHexColor,
TAG_COLOR_EDIT_CANDIDATE_COUNT
} from '~/utils/tag-colors'
const props = withDefaults(
defineProps<{
tags: ImageTag[]
embedded?: boolean
}>(),
{
embedded: false
}
)
const emit = defineEmits<{
changed: []
close: []
}>()
const { t, locale } = useI18n()
const toast = useToast()
const { createTag, updateTag, deleteTag, mergeTags } = useTags()
const creating = ref(false)
const newName = ref('')
const newColorCandidates = ref<string[]>(pickTagColorCandidates([]))
const newColor = ref(
pickDefaultTagColorFromCandidates(newColorCandidates.value, [])
)
const search = ref('')
const statusFilter = ref<'all' | 'used' | 'unused'>('all')
const selectedIds = ref<Set<number>>(new Set())
const renamingId = ref<number | null>(null)
const renameValue = ref('')
const colorPickerId = ref<number | null>(null)
const colorValue = ref('#22c55e')
const editColorCandidates = ref<string[]>([])
const mergeOpen = ref(false)
const mergeConfirmOpen = ref(false)
const mergeMode = ref<'batch' | 'single'>('batch')
const mergeSourceIds = ref<number[]>([])
const mergeTargetId = ref<number | null>(null)
const deleteOpen = ref(false)
const deleteTarget = ref<ImageTag | null>(null)
const saving = ref(false)
const page = ref(1)
const pageSize = ref(10)
type TagSortKey = 'imageCount' | 'lastUsedAt'
const sortKey = ref<TagSortKey>('imageCount')
const sortDir = ref<'asc' | 'desc'>('desc')
const stats = computed(() => {
const now = Date.now()
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000
let used = 0
let unused = 0
let newIn7Days = 0
for (const tag of props.tags) {
const count = tag.imageCount ?? 0
if (count > 0) used++
else unused++
if (tag.createdAt && new Date(tag.createdAt).getTime() >= sevenDaysAgo) {
newIn7Days++
}
}
return {
total: props.tags.length,
used,
unused,
newIn7Days
}
})
const filteredTags = computed(() => {
const q = search.value.trim().toLowerCase()
const list = props.tags.filter((tag) => {
if (statusFilter.value === 'used' && (tag.imageCount ?? 0) === 0)
return false
if (statusFilter.value === 'unused' && (tag.imageCount ?? 0) > 0)
return false
if (q && !tag.name.toLowerCase().includes(q)) return false
return true
})
const dir = sortDir.value === 'asc' ? 1 : -1
return list.sort((a, b) => {
let cmp = 0
if (sortKey.value === 'imageCount') {
cmp = (a.imageCount ?? 0) - (b.imageCount ?? 0)
} else {
cmp = lastUsedSortTime(a) - lastUsedSortTime(b)
}
if (cmp !== 0) return cmp * dir
return a.name.localeCompare(b.name)
})
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredTags.value.length / pageSize.value))
)
const paginatedTags = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredTags.value.slice(start, start + pageSize.value)
})
watch([search, statusFilter, pageSize], () => {
page.value = 1
})
watch(filteredTags, (list) => {
if (page.value > Math.max(1, Math.ceil(list.length / pageSize.value))) {
page.value = 1
}
})
const selectedList = computed(
() =>
[...selectedIds.value]
.map(id => props.tags.find(tag => tag.id === id))
.filter(Boolean) as ImageTag[]
)
const selectedCount = computed(() => selectedIds.value.size)
const allPageSelected = computed(
() =>
paginatedTags.value.length > 0
&& paginatedTags.value.every(tag => selectedIds.value.has(tag.id))
)
const mergeSourceTags = computed(
() =>
mergeSourceIds.value
.map(id => props.tags.find(tag => tag.id === id))
.filter(Boolean) as ImageTag[]
)
const mergeTargetCandidates = computed(() => {
const sourceSet = new Set(mergeSourceIds.value)
return props.tags
.filter(tag => !sourceSet.has(tag.id))
.sort(
(a, b) =>
(b.imageCount ?? 0) - (a.imageCount ?? 0)
|| a.name.localeCompare(b.name)
)
})
const mergeTagsToDelete = computed(() => {
if (!mergeTargetId.value) return []
return mergeSourceTags.value.filter(tag => tag.id !== mergeTargetId.value)
})
const mergeKeepTag = computed(
() => props.tags.find(tag => tag.id === mergeTargetId.value) ?? null
)
const statusItems = computed(() => [
{ label: t('tags.filterStatusAll'), value: 'all' },
{ label: t('tags.filterStatusUsed'), value: 'used' },
{ label: t('tags.filterStatusUnused'), value: 'unused' }
])
function tagSortTime(iso: string | null | undefined): number {
if (!iso) return 0
const time = new Date(iso).getTime()
return Number.isNaN(time) ? 0 : time
}
function lastUsedSortTime(tag: ImageTag): number {
if ((tag.imageCount ?? 0) === 0) return 0
return tagSortTime(tag.lastUsedAt)
}
function toggleSort(key: TagSortKey) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortDir.value = 'desc'
}
page.value = 1
}
function sortIcon(key: TagSortKey): string {
if (sortKey.value !== key) return 'i-lucide-arrow-up-down'
return sortDir.value === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
}
function sortIconClass(key: TagSortKey): string {
const base = 'size-3 shrink-0'
return sortKey.value === key
? `${base} opacity-70`
: `${base} opacity-40`
}
const statValueClass = computed(
() => 'mt-2 text-2xl font-semibold tabular-nums text-highlighted'
)
const tableTextClass = computed(() => 'text-sm')
const tagRowClass = 'flex items-center gap-x-3 px-3'
const tagNameColClass = 'min-w-0 flex-1 basis-0 pr-8 sm:pr-12'
const tagRightGroupClass
= 'ml-auto flex shrink-0 items-center gap-x-8 sm:gap-x-10'
const tagCountColClass
= 'w-44 shrink-0 text-left tabular-nums whitespace-nowrap sm:w-48'
const tagLastColClass
= 'w-[10.5rem] shrink-0 whitespace-nowrap text-xs text-muted'
const tagActionsColClass = 'w-[9.5rem] shrink-0'
function formatLastUsed(tag: ImageTag) {
if (!tag.lastUsedAt || (tag.imageCount ?? 0) === 0) {
return t('tags.neverUsed')
}
try {
return new Date(tag.lastUsedAt).toLocaleString(locale.value, {
hour12: false
})
} catch {
return t('tags.neverUsed')
}
}
function toggleSelect(id: number) {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function toggleSelectAll() {
const next = new Set(selectedIds.value)
if (allPageSelected.value) {
for (const tag of paginatedTags.value) next.delete(tag.id)
} else {
for (const tag of paginatedTags.value) next.add(tag.id)
}
selectedIds.value = next
}
function clearSelection() {
selectedIds.value = new Set()
}
function usedColorsExcept(tagId?: number) {
return props.tags
.filter(tag => tagId === undefined || tag.id !== tagId)
.map(tag => tag.color)
}
function refreshNewTagColor() {
const used = usedColorsExcept()
newColorCandidates.value = pickTagColorCandidates(used)
newColor.value = pickDefaultTagColorFromCandidates(
newColorCandidates.value,
used
)
}
function toggleCreating() {
if (creating.value) {
creating.value = false
return
}
newName.value = ''
refreshNewTagColor()
creating.value = true
}
async function handleCreate() {
const name = newName.value.trim()
if (!name || saving.value) return
saving.value = true
try {
await createTag(name, newColor.value)
newName.value = ''
creating.value = false
refreshNewTagColor()
emit('changed')
toast.add({ title: t('tags.created', { name }), color: 'success' })
} catch {
toast.add({ title: t('tags.createFailed'), color: 'error' })
} finally {
saving.value = false
}
}
function startRename(tag: ImageTag) {
if (renamingId.value !== null && renamingId.value !== tag.id) {
cancelRename()
}
renamingId.value = tag.id
renameValue.value = tag.name
}
function cancelRename() {
renamingId.value = null
renameValue.value = ''
}
function onRenameBlur(tagId: number) {
window.setTimeout(() => {
if (renamingId.value === tagId) {
void finishRename(tagId)
}
}, 0)
}
async function finishRename(tagId: number) {
if (renamingId.value !== tagId) return
const name = renameValue.value.trim()
const tag = props.tags.find(item => item.id === tagId)
renamingId.value = null
renameValue.value = ''
if (!name || !tag || tag.name === name) return
saving.value = true
try {
await updateTag(tagId, { name })
emit('changed')
toast.add({ title: t('tags.renamed'), color: 'success' })
} catch {
toast.add({ title: t('tags.renameFailed'), color: 'error' })
} finally {
saving.value = false
}
}
function openColorPicker(tag: ImageTag) {
colorPickerId.value = tag.id
colorValue.value = resolveTagHexColor(tag.color, defaultTagColor(tag.id))
editColorCandidates.value = pickTagColorCandidates(
usedColorsExcept(tag.id),
TAG_COLOR_EDIT_CANDIDATE_COUNT
)
}
async function saveColor(tagId: number) {
saving.value = true
try {
await updateTag(tagId, { color: colorValue.value })
colorPickerId.value = null
emit('changed')
} catch {
toast.add({ title: t('tags.colorFailed'), color: 'error' })
} finally {
saving.value = false
}
}
function openDeleteConfirm(tag: ImageTag) {
deleteTarget.value = tag
deleteOpen.value = true
}
async function confirmDelete() {
const tag = deleteTarget.value
if (!tag || saving.value) return
saving.value = true
try {
await deleteTag(tag.id)
deleteOpen.value = false
deleteTarget.value = null
selectedIds.value.delete(tag.id)
selectedIds.value = new Set(selectedIds.value)
emit('changed')
toast.add({
title: t('tags.deleted', { name: tag.name }),
color: 'success'
})
} catch {
toast.add({ title: t('tags.deleteFailed'), color: 'error' })
} finally {
saving.value = false
}
}
function openBatchMerge() {
if (selectedIds.value.size < 2) {
toast.add({ title: t('tags.mergeSelectAtLeast'), color: 'warning' })
return
}
mergeMode.value = 'batch'
mergeSourceIds.value = [...selectedIds.value]
const sorted = selectedList.value.sort(
(a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0)
)
mergeTargetId.value = sorted[0]?.id ?? null
mergeOpen.value = true
}
function openSingleMerge(tag: ImageTag) {
mergeMode.value = 'single'
mergeSourceIds.value = [tag.id]
const fallback = props.tags
.filter(item => item.id !== tag.id)
.sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0))[0]
mergeTargetId.value = fallback?.id ?? null
mergeOpen.value = true
}
function requestMergeConfirm() {
if (!mergeTargetId.value) return
const sourceIds = mergeSourceIds.value.filter(
id => id !== mergeTargetId.value
)
if (!sourceIds.length) return
mergeConfirmOpen.value = true
}
async function confirmMerge() {
if (!mergeTargetId.value || saving.value) return
const sourceIds = mergeSourceIds.value.filter(
id => id !== mergeTargetId.value
)
if (!sourceIds.length) return
saving.value = true
try {
await mergeTags(sourceIds, mergeTargetId.value)
selectedIds.value = new Set()
mergeConfirmOpen.value = false
mergeOpen.value = false
emit('changed')
toast.add({ title: t('tags.merged'), color: 'success' })
} catch {
toast.add({ title: t('tags.mergeFailed'), color: 'error' })
} finally {
saving.value = false
}
}
</script>
<template>
<div class="space-y-5">
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted">
<span
class="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<UIcon
name="i-lucide-tags"
class="size-4"
/>
</span>
{{ t("tags.statTotal") }}
</div>
<p :class="statValueClass">
{{ stats.total }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted">
<span
class="flex size-7 items-center justify-center rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400"
>
<UIcon
name="i-lucide-image"
class="size-4"
/>
</span>
{{ t("tags.statUsed") }}
</div>
<p :class="statValueClass">
{{ stats.used }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted">
<span
class="flex size-7 items-center justify-center rounded-lg bg-orange-500/10 text-orange-600 dark:text-orange-400"
>
<UIcon
name="i-lucide-tag"
class="size-4"
/>
</span>
{{ t("tags.statUnused") }}
</div>
<p :class="statValueClass">
{{ stats.unused }}
</p>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted">
<span
class="flex size-7 items-center justify-center rounded-lg bg-violet-500/10 text-violet-600 dark:text-violet-400"
>
<UIcon
name="i-lucide-trending-up"
class="size-4"
/>
</span>
{{ t("tags.statNew7d") }}
</div>
<p :class="statValueClass">
{{ stats.newIn7Days }}
</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<UInput
v-model="search"
size="sm"
icon="i-lucide-search"
:placeholder="t('tags.searchName')"
class="min-w-44 flex-1 sm:max-w-xs"
/>
<USelect
v-model="statusFilter"
size="sm"
:items="statusItems"
class="w-32"
/>
<div class="ml-auto flex flex-wrap gap-2">
<UButton
type="button"
size="sm"
color="primary"
icon="i-lucide-plus"
@click="toggleCreating"
>
{{ t("tags.addTag") }}
</UButton>
<UButton
type="button"
size="sm"
variant="outline"
color="neutral"
icon="i-lucide-merge"
:disabled="selectedCount < 2"
@click="openBatchMerge"
>
{{ t("tags.mergeTags") }}
</UButton>
</div>
</div>
<div
v-if="selectedCount > 0"
class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2"
>
<span class="text-xs font-medium text-highlighted">
{{ t("tags.mergeSelected", { n: selectedCount }) }}
</span>
<UButton
size="xs"
variant="ghost"
color="neutral"
@click="clearSelection"
>
{{ t("tags.mergeClear") }}
</UButton>
</div>
<div
v-if="creating"
class="space-y-4 rounded-xl border border-default bg-elevated/50 p-4"
>
<div class="flex flex-wrap items-start gap-3">
<div class="min-w-0 flex-1 space-y-3">
<UInput
v-model="newName"
size="md"
:placeholder="t('tags.namePlaceholder')"
@keydown.enter.prevent="handleCreate"
/>
<TagColorPicker
v-model="newColor"
:candidates="newColorCandidates"
/>
</div>
<div
v-if="newName.trim()"
class="shrink-0 pt-1"
>
<p class="mb-1.5 text-xs text-muted">
{{ t("tags.preview") }}
</p>
<TagBadge :tag="{ id: 0, name: newName.trim(), color: newColor }" />
</div>
</div>
<div class="flex justify-end gap-2 border-t border-default pt-3">
<UButton
size="sm"
variant="ghost"
color="neutral"
@click="
() => {
creating = false;
}
"
>
{{ t("common.cancel") }}
</UButton>
<UButton
size="sm"
color="primary"
:loading="saving"
:disabled="!newName.trim()"
@click="handleCreate"
>
{{ t("common.save") }}
</UButton>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-default">
<div
v-if="!paginatedTags.length"
class="px-3 py-12 text-center text-sm text-muted"
>
{{ tags.length ? t("tags.noMatch") : t("tags.emptyHint") }}
</div>
<!-- 手机:卡片列表 -->
<template v-else>
<div class="sm:hidden">
<div
class="flex items-center gap-2 border-b border-default bg-elevated/60 px-3 py-2.5 text-xs text-muted"
>
<UCheckbox
:model-value="allPageSelected"
:disabled="!paginatedTags.length"
@update:model-value="toggleSelectAll"
/>
<span class="min-w-0 flex-1 font-medium">{{ t("tags.colName") }}</span>
<button
type="button"
class="inline-flex shrink-0 items-center gap-0.5 font-medium text-inherit"
@click="toggleSort('imageCount')"
>
{{ t("tags.colImageCount") }}
<UIcon
:name="sortIcon('imageCount')"
:class="sortIconClass('imageCount')"
/>
</button>
<button
type="button"
class="inline-flex shrink-0 items-center gap-0.5 font-medium text-inherit"
@click="toggleSort('lastUsedAt')"
>
{{ t("tags.colLastUsed") }}
<UIcon
:name="sortIcon('lastUsedAt')"
:class="sortIconClass('lastUsedAt')"
/>
</button>
</div>
<article
v-for="tag in paginatedTags"
:key="`mobile-${tag.id}`"
class="border-b border-default px-3 py-3 last:border-b-0"
:class="selectedIds.has(tag.id) ? 'bg-primary/5' : ''"
>
<div class="flex items-start gap-2">
<UCheckbox
class="mt-0.5"
:model-value="selectedIds.has(tag.id)"
@update:model-value="toggleSelect(tag.id)"
/>
<div class="min-w-0 flex-1">
<UInput
v-if="renamingId === tag.id"
v-model="renameValue"
size="sm"
class="w-full max-w-none"
autofocus
@keydown.enter.prevent="finishRename(tag.id)"
@keydown.esc.prevent="cancelRename"
@blur="onRenameBlur(tag.id)"
/>
<TagBadge
v-else
:tag="tag"
size="sm"
class="max-w-full truncate"
/>
<div
class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"
>
<span class="tabular-nums">
{{ t("tags.colImageCount") }} {{ tag.imageCount ?? 0 }}
</span>
<span class="min-w-0 break-all">
{{ formatLastUsed(tag) }}
</span>
</div>
</div>
</div>
<div class="mt-2 flex flex-wrap justify-end gap-0.5">
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-pencil"
:aria-label="t('common.edit')"
@click="startRename(tag)"
/>
<UPopover
:open="colorPickerId === tag.id"
:content="{ side: 'bottom', align: 'end' }"
@update:open="
(open) => {
colorPickerId = open ? tag.id : null;
}
"
>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-palette"
:aria-label="t('tags.pickColor')"
@click="() => openColorPicker(tag)"
/>
<template #content>
<div
class="flex items-center gap-2 p-2"
@click.stop
>
<TagColorPicker
v-model="colorValue"
:candidates="editColorCandidates"
compact
/>
<UButton
icon="i-lucide-check"
size="sm"
color="primary"
class="shrink-0"
:aria-label="t('common.save')"
:loading="saving"
@click="saveColor(tag.id)"
/>
</div>
</template>
</UPopover>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-merge"
:aria-label="t('tags.mergeIntoAction')"
:disabled="tags.length < 2"
@click="openSingleMerge(tag)"
/>
<UButton
type="button"
size="xs"
variant="ghost"
color="error"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="openDeleteConfirm(tag)"
/>
</div>
</article>
</div>
<!-- 桌面:表格 -->
<div class="hidden sm:block">
<div
class="border-b border-default bg-elevated/60 text-xs text-muted"
:class="[tagRowClass, tableTextClass, 'py-2.5']"
>
<div class="flex w-10 shrink-0 justify-center">
<UCheckbox
:model-value="allPageSelected"
:disabled="!paginatedTags.length"
@update:model-value="toggleSelectAll"
/>
</div>
<div :class="[tagNameColClass, 'font-medium']">
{{ t("tags.colName") }}
</div>
<div :class="tagRightGroupClass">
<div :class="[tagCountColClass, 'font-medium']">
<button
type="button"
class="inline-flex items-center gap-0.5 font-medium text-inherit"
@click="toggleSort('imageCount')"
>
{{ t("tags.colImageCount") }}
<UIcon
:name="sortIcon('imageCount')"
:class="sortIconClass('imageCount')"
/>
</button>
</div>
<div :class="[tagLastColClass, 'font-medium text-muted']">
<button
type="button"
class="inline-flex items-center gap-0.5 font-medium text-inherit"
@click="toggleSort('lastUsedAt')"
>
{{ t("tags.colLastUsed") }}
<UIcon
:name="sortIcon('lastUsedAt')"
:class="sortIconClass('lastUsedAt')"
/>
</button>
</div>
<div :class="[tagActionsColClass, 'text-right font-medium']">
{{ t("tags.colActions") }}
</div>
</div>
</div>
<div
v-for="tag in paginatedTags"
:key="tag.id"
class="border-b border-default last:border-b-0"
:class="[
tagRowClass,
tableTextClass,
'py-0',
selectedIds.has(tag.id) ? 'bg-primary/5' : 'hover:bg-elevated/40'
]"
>
<div class="flex w-10 shrink-0 justify-center py-3">
<UCheckbox
:model-value="selectedIds.has(tag.id)"
@update:model-value="toggleSelect(tag.id)"
/>
</div>
<div :class="[tagNameColClass, 'py-3']">
<UInput
v-if="renamingId === tag.id"
v-model="renameValue"
size="sm"
class="max-w-xs"
autofocus
@keydown.enter.prevent="finishRename(tag.id)"
@keydown.esc.prevent="cancelRename"
@blur="onRenameBlur(tag.id)"
/>
<TagBadge
v-else
:tag="tag"
size="sm"
/>
</div>
<div :class="[tagRightGroupClass, 'py-3']">
<div :class="[tagCountColClass, 'text-muted']">
{{ tag.imageCount ?? 0 }}
</div>
<div :class="tagLastColClass">
{{ formatLastUsed(tag) }}
</div>
<div
:class="[
tagActionsColClass,
'flex items-center justify-end gap-0.5'
]"
>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-pencil"
:aria-label="t('common.edit')"
@click="startRename(tag)"
/>
<UPopover
:open="colorPickerId === tag.id"
:content="{ side: 'bottom', align: 'end' }"
@update:open="
(open) => {
colorPickerId = open ? tag.id : null;
}
"
>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-palette"
:aria-label="t('tags.pickColor')"
@click="() => openColorPicker(tag)"
/>
<template #content>
<div
class="flex items-center gap-2 p-2"
@click.stop
>
<TagColorPicker
v-model="colorValue"
:candidates="editColorCandidates"
compact
/>
<UButton
icon="i-lucide-check"
size="sm"
color="primary"
class="shrink-0"
:aria-label="t('common.save')"
:loading="saving"
@click="saveColor(tag.id)"
/>
</div>
</template>
</UPopover>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-merge"
:aria-label="t('tags.mergeIntoAction')"
:disabled="tags.length < 2"
@click="openSingleMerge(tag)"
/>
<UButton
type="button"
size="xs"
variant="ghost"
color="error"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="openDeleteConfirm(tag)"
/>
</div>
</div>
</div>
</div>
</template>
</div>
<PaginationBar
v-if="filteredTags.length > 0"
:page="page"
:total-pages="totalPages"
:total="filteredTags.length"
:page-size="pageSize"
:page-size-options="[10, 20, 30, 50]"
@update:page="
(value) => {
page = value;
}
"
@update:page-size="
(value) => {
pageSize = value;
}
"
/>
<UModal
v-model:open="mergeOpen"
:title="
mergeMode === 'single' ? t('tags.mergeIntoTitle') : t('tags.mergeTitle')
"
:description="
mergeMode === 'single'
? t('tags.mergeIntoSubtitle', {
name: mergeSourceTags[0]?.name ?? ''
})
: t('tags.mergeSubtitle')
"
>
<template #body>
<template v-if="mergeMode === 'batch'">
<p class="mb-2 text-sm text-muted">
{{ t("tags.mergeChooseTarget") }}
</p>
<div class="merge-tag-list max-h-[16.5rem] space-y-2 overflow-y-auto">
<label
v-for="tag in mergeSourceTags"
:key="tag.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border border-default px-3 py-2.5 transition-colors"
:class="
mergeTargetId === tag.id
? 'border-primary/40 bg-primary/5'
: 'hover:bg-elevated'
"
>
<input
v-model="mergeTargetId"
type="radio"
:value="tag.id"
class="accent-primary"
>
<TagBadge :tag="tag" />
<span class="ml-auto text-sm text-muted">
{{ t("common.countImages", { count: tag.imageCount ?? 0 }) }}
</span>
</label>
</div>
</template>
<template v-else>
<p class="mb-2 text-sm text-muted">
{{ t("tags.mergeIntoChoose") }}
</p>
<div class="merge-tag-list max-h-[16.5rem] space-y-2 overflow-y-auto">
<label
v-for="tag in mergeTargetCandidates"
:key="tag.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border border-default px-3 py-2.5 transition-colors"
:class="
mergeTargetId === tag.id
? 'border-primary/40 bg-primary/5'
: 'hover:bg-elevated'
"
>
<input
v-model="mergeTargetId"
type="radio"
:value="tag.id"
class="accent-primary"
>
<TagBadge :tag="tag" />
<span class="ml-auto text-sm text-muted">
{{ t("common.countImages", { count: tag.imageCount ?? 0 }) }}
</span>
</label>
</div>
</template>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
variant="outline"
color="neutral"
@click="
() => {
mergeOpen = false;
}
"
>
{{ t("common.cancel") }}
</UButton>
<UButton
color="primary"
icon="i-lucide-merge"
:disabled="!mergeTargetId"
@click="requestMergeConfirm"
>
{{ t("tags.mergeConfirm") }}
</UButton>
</div>
</template>
</UModal>
<UModal
v-model:open="mergeConfirmOpen"
:title="t('tags.mergeIrreversibleTitle')"
:description="t('tags.mergeIrreversible')"
>
<template #body>
<div class="space-y-3 text-sm">
<p v-if="mergeKeepTag">
{{ t("tags.mergeConfirmKeep", { name: mergeKeepTag.name }) }}
</p>
<p v-if="mergeTagsToDelete.length">
{{
t("tags.mergeWillDelete", {
names: mergeTagsToDelete.map((tag) => tag.name).join("、")
})
}}
</p>
</div>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
variant="outline"
color="neutral"
@click="
() => {
mergeConfirmOpen = false;
}
"
>
{{ t("common.cancel") }}
</UButton>
<UButton
color="primary"
icon="i-lucide-merge"
:loading="saving"
@click="confirmMerge"
>
{{ t("tags.mergeConfirm") }}
</UButton>
</div>
</template>
</UModal>
<UModal
v-model:open="deleteOpen"
:title="t('tags.deleteConfirmTitle')"
:description="
deleteTarget
? t('tags.deleteConfirmDesc', { name: deleteTarget.name })
: ''
"
>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton
variant="outline"
color="neutral"
@click="
() => {
deleteOpen = false;
}
"
>
{{ t("common.cancel") }}
</UButton>
<UButton
color="error"
icon="i-lucide-trash-2"
:loading="saving"
@click="confirmDelete"
>
{{ t("common.delete") }}
</UButton>
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.merge-tag-list {
scrollbar-width: none;
-ms-overflow-style: none;
}
.merge-tag-list::-webkit-scrollbar {
display: none;
}
</style>