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
Fix batch tagging and upload token auth; single API token and tagNames-only upload; tag management sort and responsive layout.
1163 lines
33 KiB
Vue
1163 lines
33 KiB
Vue
<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>
|