release: v1.4.0 — 标签系统、操作日志扩展与文档截图更新
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
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
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
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
- 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标 - 操作日志记录登录、删除、设置与标签操作 - API 标签/打标接口;README 与文档站截图同步
This commit is contained in:
1 parent
f17d79b236
commit
737235acff
110 files changed
+5881
-778
No files matched your search
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageTag } from '~/types/image'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
tags: ImageTag[]
|
||||
selectedCount: number
|
||||
mode?: 'add' | 'remove'
|
||||
/** 仅添加标签,隐藏添加/移除切换(首页批量打标) */
|
||||
addOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'confirm': [tagIds: number[], action: 'add' | 'remove']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const action = ref<'add' | 'remove'>(props.mode ?? 'add')
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
const { loadRecentIds } = useRecentTags()
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
action.value = props.mode ?? 'add'
|
||||
selectedTagIds.value = []
|
||||
loadRecentIds()
|
||||
}
|
||||
})
|
||||
|
||||
function submit() {
|
||||
if (!selectedTagIds.value.length) return
|
||||
emit('confirm', selectedTagIds.value, action.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="addOnly ? t('tags.batchAddTitle') : t('tags.batchTitle')"
|
||||
:description="addOnly
|
||||
? t('tags.batchAddSubtitle', { n: selectedCount })
|
||||
: t('tags.batchSubtitle', { n: selectedCount })"
|
||||
:ui="{ content: 'max-w-md' }"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="!addOnly"
|
||||
class="flex gap-1 rounded-lg border border-default p-0.5"
|
||||
>
|
||||
<UButton
|
||||
type="button"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="action === 'add' ? 'solid' : 'ghost'"
|
||||
:color="action === 'add' ? 'primary' : 'neutral'"
|
||||
:label="t('tags.batchAdd')"
|
||||
@click="() => { action = 'add' }"
|
||||
/>
|
||||
<UButton
|
||||
type="button"
|
||||
size="xs"
|
||||
class="flex-1 justify-center"
|
||||
:variant="action === 'remove' ? 'solid' : 'ghost'"
|
||||
:color="action === 'remove' ? 'primary' : 'neutral'"
|
||||
:label="t('tags.batchRemove')"
|
||||
@click="() => { action = 'remove' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TagAssignPanel
|
||||
v-model="selectedTagIds"
|
||||
:tags="tags"
|
||||
:allow-create="action === 'add'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p
|
||||
v-if="addOnly"
|
||||
class="flex items-center gap-1.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-info"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{ t('tags.classifyHint') }}
|
||||
</p>
|
||||
<div class="flex flex-1 justify-end gap-2">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
:disabled="!selectedTagIds.length"
|
||||
@click="submit"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
+150
-10
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
import type { CopyFormat } from '~/composables/useUploadPreferences'
|
||||
import { buildImageBbcode } from '~/utils/image-display'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
image: ImageItem
|
||||
@@ -11,19 +12,26 @@ const props = withDefaults(defineProps<{
|
||||
compact?: boolean
|
||||
gallery?: boolean
|
||||
allowDelete?: boolean
|
||||
showTags?: boolean
|
||||
showTagAction?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
showStorage: false,
|
||||
compact: false,
|
||||
gallery: false,
|
||||
allowDelete: true
|
||||
allowDelete: true,
|
||||
showTags: false,
|
||||
showTagAction: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: boolean]
|
||||
'preview': []
|
||||
'delete': []
|
||||
'tag-click': [tagId: number]
|
||||
'untagged-click': []
|
||||
'edit-tags': []
|
||||
}>()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
@@ -79,7 +87,8 @@ onUnmounted(clearRetryTimer)
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const }
|
||||
{ label: t('copy.html'), value: 'html' as const },
|
||||
{ label: t('copy.bbcode'), value: 'bbcode' as const }
|
||||
])
|
||||
|
||||
const uploadedLabel = computed(() => {
|
||||
@@ -96,6 +105,8 @@ const previewValue = computed(() => {
|
||||
return props.image.markdown
|
||||
case 'html':
|
||||
return props.image.html
|
||||
case 'bbcode':
|
||||
return buildImageBbcode(props.image.url)
|
||||
default:
|
||||
return props.image.url
|
||||
}
|
||||
@@ -107,6 +118,8 @@ const copySuccessTitle = computed(() => {
|
||||
return t('copy.copiedMarkdown')
|
||||
case 'html':
|
||||
return t('copy.copiedHtml')
|
||||
case 'bbcode':
|
||||
return t('copy.copiedBbcode')
|
||||
default:
|
||||
return t('copy.copiedUrl')
|
||||
}
|
||||
@@ -127,6 +140,34 @@ const uploadSourceLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const uploadSourceIcon = computed(() => {
|
||||
switch (props.image.uploadSource) {
|
||||
case 'web':
|
||||
return 'i-lucide-globe'
|
||||
case 'api':
|
||||
return 'i-lucide-code-xml'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const GALLERY_INLINE_TAG_LIMIT = 3
|
||||
|
||||
const galleryTagDisplay = computed(() => {
|
||||
const tags = props.image.tags ?? []
|
||||
if (tags.length <= GALLERY_INLINE_TAG_LIMIT) {
|
||||
return { visible: tags, hidden: [] as typeof tags }
|
||||
}
|
||||
return {
|
||||
visible: tags.slice(0, GALLERY_INLINE_TAG_LIMIT),
|
||||
hidden: tags.slice(GALLERY_INLINE_TAG_LIMIT)
|
||||
}
|
||||
})
|
||||
const sessionVisibleTags = computed(() => props.image.tags ?? [])
|
||||
const sessionTagActionLabel = computed(() =>
|
||||
props.image.tags?.length ? t('tags.addTag') : t('tags.untagged')
|
||||
)
|
||||
|
||||
function toggleSelected(value: boolean | 'indeterminate') {
|
||||
emit('update:selected', value === true)
|
||||
}
|
||||
@@ -210,16 +251,61 @@ async function copyGalleryUrl() {
|
||||
<p class="truncate text-xs text-muted">
|
||||
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="showTags"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<template v-if="image.tags?.length">
|
||||
<TagBadge
|
||||
v-for="tag in galleryTagDisplay.visible"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
clickable
|
||||
@click="emit('tag-click', tag.id)"
|
||||
/>
|
||||
<UPopover
|
||||
v-if="galleryTagDisplay.hidden.length"
|
||||
:content="{ side: 'bottom', align: 'start' }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md border border-default px-2 py-0.5 text-xs text-muted transition-colors hover:border-primary/40 hover:text-primary"
|
||||
:aria-label="t('tags.moreTags', { n: galleryTagDisplay.hidden.length })"
|
||||
@click.stop
|
||||
>
|
||||
+{{ galleryTagDisplay.hidden.length }}
|
||||
</button>
|
||||
<template #content>
|
||||
<div
|
||||
class="flex max-w-56 flex-wrap gap-1.5 p-2"
|
||||
@click.stop
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in galleryTagDisplay.hidden"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
clickable
|
||||
@click="emit('tag-click', tag.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted transition-colors hover:border-primary/40 hover:text-primary"
|
||||
@click.stop="emit('untagged-click')"
|
||||
>
|
||||
{{ t('tags.unlabeled') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="uploadSourceLabel || (showStorage && image.storage)"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-if="uploadSourceLabel"
|
||||
class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ uploadSourceLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="showStorage && image.storage"
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
|
||||
@@ -231,6 +317,16 @@ async function copyGalleryUrl() {
|
||||
/>
|
||||
<span class="truncate">{{ image.storage.name }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="uploadSourceLabel && uploadSourceIcon"
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="uploadSourceIcon"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ uploadSourceLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,6 +398,21 @@ async function copyGalleryUrl() {
|
||||
@update:model-value="toggleSelected"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="allowDelete && showTagAction"
|
||||
class="absolute top-2 right-2"
|
||||
@click.stop
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
variant="solid"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
class="bg-black/55 text-white hover:bg-black/70"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="image.owner"
|
||||
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
|
||||
@@ -371,6 +482,20 @@ async function copyGalleryUrl() {
|
||||
>
|
||||
{{ image.originalName }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="showTagAction && image.tags?.length"
|
||||
type="button"
|
||||
class="flex flex-wrap items-center gap-1.5 text-left"
|
||||
@click.stop="emit('edit-tags')"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in sessionVisibleTags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-muted">
|
||||
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
|
||||
</p>
|
||||
@@ -396,13 +521,28 @@ async function copyGalleryUrl() {
|
||||
class="min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
:label="t('common.copy')"
|
||||
:variant="showTagAction ? 'solid' : 'soft'"
|
||||
:color="showTagAction ? 'primary' : undefined"
|
||||
:icon="showTagAction ? undefined : 'i-lucide-copy'"
|
||||
:value="previewValue"
|
||||
:success-title="copySuccessTitle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="showTagAction"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 self-start text-xs text-muted transition-colors hover:text-primary"
|
||||
@click.stop="emit('edit-tags')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-plus"
|
||||
class="size-3"
|
||||
/>
|
||||
{{ sessionTagActionLabel }}
|
||||
</button>
|
||||
|
||||
<p
|
||||
v-if="showKey"
|
||||
class="truncate text-xs text-dimmed"
|
||||
|
||||
@@ -13,6 +13,8 @@ const props = withDefaults(defineProps<{
|
||||
/** 图库:5 列 × 2 行,缩略图略扁 */
|
||||
gallery?: boolean
|
||||
allowDelete?: boolean
|
||||
showTags?: boolean
|
||||
showTagAction?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
@@ -20,7 +22,9 @@ const props = withDefaults(defineProps<{
|
||||
dense: false,
|
||||
compact: false,
|
||||
gallery: false,
|
||||
allowDelete: true
|
||||
allowDelete: true,
|
||||
showTags: false,
|
||||
showTagAction: false
|
||||
})
|
||||
|
||||
const gridClass = computed(() => {
|
||||
@@ -37,6 +41,9 @@ const emit = defineEmits<{
|
||||
'update:selectedKeys': [value: Set<string>]
|
||||
'preview': [image: ImageItem]
|
||||
'delete': [image: ImageItem]
|
||||
'tag-click': [tagId: number]
|
||||
'untagged-click': []
|
||||
'edit-tags': [image: ImageItem]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -68,9 +75,14 @@ function updateSelection(key: string, selected: boolean) {
|
||||
:compact="compact"
|
||||
:gallery="gallery"
|
||||
:allow-delete="allowDelete"
|
||||
:show-tags="showTags"
|
||||
:show-tag-action="showTagAction"
|
||||
@update:selected="updateSelection(image.key, $event)"
|
||||
@preview="emit('preview', image)"
|
||||
@delete="emit('delete', image)"
|
||||
@tag-click="emit('tag-click', $event)"
|
||||
@untagged-click="emit('untagged-click')"
|
||||
@edit-tags="emit('edit-tags', image)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
import type { CopyFormat } from '~/composables/useUploadPreferences'
|
||||
import { formatImageDimensions } from '~/utils/image-display'
|
||||
|
||||
type EmbedFormat = CopyFormat | 'bbcode'
|
||||
import { buildImageBbcode, formatImageDimensions } from '~/utils/image-display'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
image: ImageItem | null
|
||||
showStorage?: boolean
|
||||
showTags?: boolean
|
||||
deleting?: boolean
|
||||
allowDelete?: boolean
|
||||
}>(), {
|
||||
showStorage: false,
|
||||
showTags: false,
|
||||
allowDelete: true
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
const { t, locale } = useI18n()
|
||||
const { copyFormat } = useUploadPreferences()
|
||||
@@ -30,7 +31,7 @@ const naturalWidth = ref<number | null>(null)
|
||||
const naturalHeight = ref<number | null>(null)
|
||||
const dimensionsLoaded = ref(false)
|
||||
const copyingUrl = ref(false)
|
||||
const embedFormat = ref<EmbedFormat>('url')
|
||||
const embedFormat = ref<CopyFormat>('url')
|
||||
|
||||
const embedFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
@@ -65,7 +66,7 @@ const sizeLabel = computed(() => {
|
||||
|
||||
const bbcodeValue = computed(() => {
|
||||
if (!props.image) return ''
|
||||
return `[img]${props.image.url}[/img]`
|
||||
return buildImageBbcode(props.image.url)
|
||||
})
|
||||
|
||||
const previewValue = computed(() => {
|
||||
@@ -110,6 +111,17 @@ const uploadSourceTag = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const uploadSourceIcon = computed(() => {
|
||||
switch (props.image?.uploadSource) {
|
||||
case 'web':
|
||||
return 'i-lucide-globe'
|
||||
case 'api':
|
||||
return 'i-lucide-code-xml'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const dimensionsLabel = computed(() => {
|
||||
const formatted = formatImageDimensions(naturalWidth.value, naturalHeight.value)
|
||||
if (formatted) return formatted
|
||||
@@ -128,11 +140,9 @@ watch(() => props.open, (open) => {
|
||||
}
|
||||
})
|
||||
|
||||
function selectEmbedFormat(value: EmbedFormat) {
|
||||
function selectEmbedFormat(value: CopyFormat) {
|
||||
embedFormat.value = value
|
||||
if (value !== 'bbcode') {
|
||||
copyFormat.value = value
|
||||
}
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
async function copyText(text: string, successTitle: string) {
|
||||
@@ -288,6 +298,32 @@ function onImageLoad(event: Event) {
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="showTags"
|
||||
class="flex gap-4 py-2.5"
|
||||
>
|
||||
<dt class="w-20 shrink-0 text-muted">
|
||||
{{ t('tags.column') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
<div
|
||||
v-if="image.tags?.length"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in image.tags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex rounded-md border border-dashed border-default px-2 py-0.5 text-xs text-muted"
|
||||
>
|
||||
{{ t('tags.unlabeled') }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="uploadSourceTag"
|
||||
class="flex gap-4 py-2.5"
|
||||
@@ -296,7 +332,14 @@ function onImageLoad(event: Event) {
|
||||
{{ t('image.previewUploadSource') }}
|
||||
</dt>
|
||||
<dd class="min-w-0 flex-1">
|
||||
<span class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<span
|
||||
v-if="uploadSourceIcon"
|
||||
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-0.5 text-xs text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="uploadSourceIcon"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ uploadSourceTag }}
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem, ImageTag } from '~/types/image'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
image: ImageItem | null
|
||||
tags: ImageTag[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'saved': [tags: ImageTag[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { setImageTags, fetchTags } = useTags()
|
||||
const { recordRecent, loadRecentIds } = useRecentTags()
|
||||
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) {
|
||||
selectedTagIds.value = props.image?.tags?.map(tag => tag.id) ?? []
|
||||
loadRecentIds()
|
||||
void fetchTags(true)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!props.image || saving.value) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const tags = await setImageTags(props.image.key, selectedTagIds.value)
|
||||
recordRecent(selectedTagIds.value)
|
||||
emit('saved', tags)
|
||||
emit('update:open', false)
|
||||
toast.add({ title: t('tags.saved'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('tags.saveFailed')
|
||||
toast.add({ title: message, color: 'error' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTagCreated(tag: ImageTag) {
|
||||
if (!selectedTagIds.value.includes(tag.id)) {
|
||||
selectedTagIds.value = [...selectedTagIds.value, tag.id]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="t('tags.manageImageTitle')"
|
||||
:description="image?.originalName"
|
||||
:ui="{ content: 'max-w-md' }"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template #body>
|
||||
<TagAssignPanel
|
||||
v-model="selectedTagIds"
|
||||
:tags="tags"
|
||||
:disabled="saving"
|
||||
@created="onTagCreated"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="flex items-center gap-1.5 text-xs text-muted">
|
||||
<UIcon
|
||||
name="i-lucide-info"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{ t('tags.classifyHint') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
:disabled="saving"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageTag } from '~/types/image'
|
||||
import { pickRandomUnusedTagColor, tagPillStyle } from '~/utils/tag-colors'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: number[]
|
||||
tags: ImageTag[]
|
||||
disabled?: boolean
|
||||
allowCreate?: boolean
|
||||
}>(), {
|
||||
disabled: false,
|
||||
allowCreate: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]]
|
||||
'created': [tag: ImageTag]
|
||||
}>()
|
||||
|
||||
const COMMON_LIMIT = 12
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { createTag } = useTags()
|
||||
const { recentIds, loadRecentIds } = useRecentTags()
|
||||
|
||||
const query = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadRecentIds()
|
||||
})
|
||||
|
||||
const isSearching = computed(() => query.value.trim().length > 0)
|
||||
|
||||
const selectedTags = computed(() =>
|
||||
props.tags.filter(tag => props.modelValue.includes(tag.id))
|
||||
)
|
||||
|
||||
const commonTags = computed(() =>
|
||||
[...props.tags]
|
||||
.sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0))
|
||||
.slice(0, COMMON_LIMIT)
|
||||
)
|
||||
|
||||
const recentTags = computed(() =>
|
||||
recentIds.value
|
||||
.map(id => props.tags.find(tag => tag.id === id))
|
||||
.filter((tag): tag is ImageTag => Boolean(tag))
|
||||
)
|
||||
|
||||
const existingTags = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return props.tags
|
||||
return props.tags.filter(tag => tag.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const showCreateOption = computed(() => {
|
||||
if (!props.allowCreate) return false
|
||||
const q = query.value.trim()
|
||||
if (!q) return false
|
||||
return !props.tags.some(tag => tag.name === q)
|
||||
})
|
||||
|
||||
const allExistingSelected = computed(() =>
|
||||
existingTags.value.length > 0
|
||||
&& existingTags.value.every(tag => props.modelValue.includes(tag.id))
|
||||
)
|
||||
|
||||
function isSelected(tagId: number) {
|
||||
return props.modelValue.includes(tagId)
|
||||
}
|
||||
|
||||
function toggleTag(tagId: number) {
|
||||
if (props.disabled) return
|
||||
const next = new Set(props.modelValue)
|
||||
if (next.has(tagId)) next.delete(tagId)
|
||||
else next.add(tagId)
|
||||
emit('update:modelValue', [...next])
|
||||
}
|
||||
|
||||
function removeTag(tagId: number) {
|
||||
if (props.disabled) return
|
||||
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
if (props.disabled) return
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
|
||||
function toggleSelectAllExisting() {
|
||||
if (props.disabled) return
|
||||
const visibleIds = existingTags.value.map(tag => tag.id)
|
||||
if (allExistingSelected.value) {
|
||||
emit('update:modelValue', props.modelValue.filter(id => !visibleIds.includes(id)))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', [...new Set([...props.modelValue, ...visibleIds])])
|
||||
}
|
||||
|
||||
async function createFromQuery() {
|
||||
const name = query.value.trim()
|
||||
if (!name || creating.value) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const color = pickRandomUnusedTagColor(props.tags.map(tag => tag.color))
|
||||
const tag = await createTag(name, color)
|
||||
emit('created', tag)
|
||||
emit('update:modelValue', [...new Set([...props.modelValue, tag.id])])
|
||||
query.value = ''
|
||||
toast.add({ title: t('tags.created', { name: tag.name }), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('tags.createFailed')
|
||||
toast.add({ title: message, color: 'error' })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reloadRecent: loadRecentIds })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<UInput
|
||||
v-model="query"
|
||||
size="md"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('tags.searchOrCreate')"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="showCreateOption"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1.5 rounded-lg px-1 py-1 text-left text-sm text-primary hover:bg-primary/5"
|
||||
:disabled="disabled || creating"
|
||||
@click="createFromQuery"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-plus"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
{{ t('tags.createNamed', { name: query.trim() }) }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="isSearching && existingTags.length"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('tags.existingLabel', { n: existingTags.length }) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-muted transition-colors hover:text-highlighted"
|
||||
:disabled="disabled"
|
||||
@click="toggleSelectAllExisting"
|
||||
>
|
||||
{{ allExistingSelected ? t('tags.deselectAll') : t('tags.selectAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="tag in existingTags"
|
||||
:key="`existing-${tag.id}`"
|
||||
type="button"
|
||||
class="inline-flex min-w-0 items-center justify-center gap-1 rounded-full border px-2.5 py-1.5 text-xs font-medium transition-opacity hover:opacity-90"
|
||||
:style="tagPillStyle(tag.color, isSelected(tag.id))"
|
||||
:disabled="disabled"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
<UIcon
|
||||
v-if="isSelected(tag.id)"
|
||||
name="i-lucide-check"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
<UIcon
|
||||
v-if="isSelected(tag.id)"
|
||||
name="i-lucide-x"
|
||||
class="size-3 shrink-0 opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="selectedTags.length"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('tags.selectedLabel', { n: selectedTags.length }) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-muted transition-colors hover:text-highlighted"
|
||||
:disabled="disabled"
|
||||
@click="clearSelected"
|
||||
>
|
||||
{{ t('tags.clearSelected') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in selectedTags"
|
||||
:key="tag.id"
|
||||
type="button"
|
||||
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
|
||||
:style="tagPillStyle(tag.color, true)"
|
||||
:disabled="disabled"
|
||||
@click="removeTag(tag.id)"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-check"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
<UIcon
|
||||
name="i-lucide-x"
|
||||
class="size-3 shrink-0 opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="commonTags.length"
|
||||
class="space-y-2"
|
||||
>
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('tags.commonTags') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in commonTags"
|
||||
:key="tag.id"
|
||||
type="button"
|
||||
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
|
||||
:style="tagPillStyle(tag.color, isSelected(tag.id))"
|
||||
:disabled="disabled"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
<UIcon
|
||||
v-if="isSelected(tag.id)"
|
||||
name="i-lucide-check"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recentTags.length"
|
||||
class="space-y-2"
|
||||
>
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('tags.recentTags') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in recentTags"
|
||||
:key="`recent-${tag.id}`"
|
||||
type="button"
|
||||
class="inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-90"
|
||||
:style="tagPillStyle(tag.color, isSelected(tag.id))"
|
||||
:disabled="disabled"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
<UIcon
|
||||
v-if="isSelected(tag.id)"
|
||||
name="i-lucide-check"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p
|
||||
v-if="!tags.length && !showCreateOption"
|
||||
class="py-6 text-center text-sm text-muted"
|
||||
>
|
||||
{{ t('tags.emptyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageTag } from '~/types/image'
|
||||
import { defaultTagColor, resolveTagHexColor, tagPillStyle } from '~/utils/tag-colors'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tag: ImageTag
|
||||
removable?: boolean
|
||||
clickable?: boolean
|
||||
size?: 'xs' | 'sm'
|
||||
}>(), {
|
||||
removable: false,
|
||||
clickable: false,
|
||||
size: 'xs'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: []
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const displayColor = computed(() =>
|
||||
resolveTagHexColor(props.tag.color, defaultTagColor(props.tag.id))
|
||||
)
|
||||
|
||||
const pillStyle = computed(() => tagPillStyle(displayColor.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-0.5 font-medium"
|
||||
:class="[
|
||||
size === 'sm' ? 'text-sm' : 'text-xs',
|
||||
clickable ? 'cursor-pointer hover:opacity-90' : ''
|
||||
]"
|
||||
:style="pillStyle"
|
||||
:role="clickable ? 'button' : undefined"
|
||||
:tabindex="clickable ? 0 : undefined"
|
||||
@click="clickable ? emit('click') : undefined"
|
||||
@keydown.enter.prevent="clickable ? emit('click') : undefined"
|
||||
@keydown.space.prevent="clickable ? emit('click') : undefined"
|
||||
>
|
||||
<span
|
||||
class="size-1.5 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: displayColor }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
class="inline-flex shrink-0 rounded p-0.5 opacity-70 hover:opacity-100"
|
||||
:aria-label="`Remove ${tag.name}`"
|
||||
@click.stop="emit('remove')"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-x"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { TAG_COLOR_PRESETS, visibleTagColorPresets } from '~/utils/tag-colors'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
/** 已被其他标签占用的预设色,选择器中优先展示未占用颜色 */
|
||||
usedColors?: (string | null | undefined)[]
|
||||
/** 固定展示的备选色(新增 8 个 / 编辑 3 个) */
|
||||
candidates?: string[]
|
||||
/** 紧凑模式:不显示标题 */
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
compact: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const colorInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const normalizedValue = computed(() => normalizeHex(props.modelValue))
|
||||
|
||||
const presetColors = computed(() => {
|
||||
if (props.candidates?.length) {
|
||||
const seen = new Set<string>()
|
||||
const items: string[] = []
|
||||
for (const color of props.candidates) {
|
||||
const lower = color.toLowerCase()
|
||||
if (!seen.has(lower)) {
|
||||
seen.add(lower)
|
||||
items.push(lower)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
if (!props.usedColors?.length) return TAG_COLOR_PRESETS
|
||||
return visibleTagColorPresets(props.usedColors, props.modelValue)
|
||||
})
|
||||
|
||||
const isPreset = computed(() =>
|
||||
TAG_COLOR_PRESETS.some(color => color.toLowerCase() === normalizedValue.value.toLowerCase())
|
||||
)
|
||||
|
||||
const isCustomSelected = computed(() => {
|
||||
if (props.candidates?.length) {
|
||||
return !presetColors.value.includes(normalizedValue.value)
|
||||
}
|
||||
return !isPreset.value
|
||||
})
|
||||
|
||||
function normalizeHex(value: string) {
|
||||
const trimmed = value.trim()
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(trimmed)) return trimmed.toLowerCase()
|
||||
return '#22c55e'
|
||||
}
|
||||
|
||||
function selectColor(color: string) {
|
||||
emit('update:modelValue', color.toLowerCase())
|
||||
}
|
||||
|
||||
function onNativeColorInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
if (value) emit('update:modelValue', value.toLowerCase())
|
||||
}
|
||||
|
||||
function openNativePicker() {
|
||||
colorInput.value?.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="compact ? '' : 'space-y-2'">
|
||||
<p
|
||||
v-if="!compact"
|
||||
class="text-xs font-medium text-muted"
|
||||
>
|
||||
{{ t('tags.pickColor') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-for="color in presetColors"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="size-7 shrink-0 rounded-full border-2 transition-all hover:scale-110"
|
||||
:class="normalizedValue === color
|
||||
? 'border-highlighted ring-2 ring-primary/25 scale-110'
|
||||
: 'border-transparent'"
|
||||
:style="{ backgroundColor: color }"
|
||||
:aria-label="color"
|
||||
:title="color"
|
||||
@click="selectColor(color)"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-7 shrink-0 overflow-hidden rounded-full border-2 transition-all hover:scale-110"
|
||||
:class="isCustomSelected
|
||||
? 'border-highlighted ring-2 ring-primary/25 scale-110'
|
||||
: 'border-dashed border-default'"
|
||||
:style="isCustomSelected ? { backgroundColor: normalizedValue } : undefined"
|
||||
:aria-label="t('tags.customColor')"
|
||||
:title="t('tags.customColor')"
|
||||
@click="openNativePicker"
|
||||
>
|
||||
<span
|
||||
v-if="!isCustomSelected"
|
||||
class="flex size-full items-center justify-center bg-muted/40"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-pipette"
|
||||
class="size-3.5 text-muted"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
ref="colorInput"
|
||||
type="color"
|
||||
class="pointer-events-none absolute inset-0 size-full opacity-0"
|
||||
:value="normalizedValue"
|
||||
@input="onNativeColorInput"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageTag } from '~/types/image'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: number[]
|
||||
tagMode: 'or' | 'and'
|
||||
untaggedOnly: boolean
|
||||
tags: ImageTag[]
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]]
|
||||
'update:tagMode': [value: 'or' | 'and']
|
||||
'update:untaggedOnly': [value: boolean]
|
||||
'manage': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedTags = computed(() =>
|
||||
props.tags.filter(tag => props.modelValue.includes(tag.id))
|
||||
)
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (props.untaggedOnly) return t('tags.untagged')
|
||||
if (!selectedTags.value.length) return t('tags.filterAll')
|
||||
if (selectedTags.value.length === 1) return selectedTags.value[0]!.name
|
||||
return t('tags.filterSelected', { n: selectedTags.value.length })
|
||||
})
|
||||
|
||||
const tagItems = computed(() =>
|
||||
props.tags.map(tag => ({
|
||||
label: tag.name,
|
||||
value: tag.id
|
||||
}))
|
||||
)
|
||||
|
||||
const selectValue = computed({
|
||||
get: () => (props.untaggedOnly ? [] : props.modelValue),
|
||||
set: (value: number[]) => {
|
||||
emit('update:untaggedOnly', false)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
})
|
||||
|
||||
function findTag(tagId: number) {
|
||||
return props.tags.find(tag => tag.id === tagId)
|
||||
}
|
||||
|
||||
function selectUntagged() {
|
||||
emit('update:modelValue', [])
|
||||
emit('update:untaggedOnly', true)
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
emit('update:modelValue', [])
|
||||
emit('update:untaggedOnly', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelect
|
||||
v-model="selectValue"
|
||||
multiple
|
||||
:items="tagItems"
|
||||
class="w-28 shrink-0"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
value-key="value"
|
||||
:content="{ align: 'start', sideOffset: 4 }"
|
||||
:ui="{
|
||||
content: 'w-72 max-w-[calc(100vw-2rem)]',
|
||||
viewport: 'max-h-44 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden',
|
||||
item: 'text-xs',
|
||||
itemLabel: 'text-xs',
|
||||
itemTrailingIcon: 'size-3.5 text-primary',
|
||||
placeholder: 'truncate pointer-events-none text-highlighted',
|
||||
value: 'truncate pointer-events-none text-highlighted'
|
||||
}"
|
||||
>
|
||||
<template #default>
|
||||
{{ buttonLabel }}
|
||||
</template>
|
||||
|
||||
<template #content-top>
|
||||
<div class="space-y-1.5 border-b border-default p-2 text-xs">
|
||||
<div class="flex items-center justify-between gap-2 px-0.5">
|
||||
<span class="font-medium text-highlighted">{{ t('tags.filterTitle') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary hover:underline"
|
||||
@click="emit('manage')"
|
||||
>
|
||||
{{ t('tags.manage') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="modelValue.length > 1"
|
||||
class="flex gap-1 rounded-md border border-default p-0.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded px-2 py-1 text-center transition-colors"
|
||||
:class="tagMode === 'or' ? 'bg-primary text-white' : 'text-muted hover:bg-elevated'"
|
||||
@click="emit('update:tagMode', 'or')"
|
||||
>
|
||||
{{ t('tags.modeOr') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded px-2 py-1 text-center transition-colors"
|
||||
:class="tagMode === 'and' ? 'bg-primary text-white' : 'text-muted hover:bg-elevated'"
|
||||
@click="emit('update:tagMode', 'and')"
|
||||
>
|
||||
{{ t('tags.modeAnd') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded-md px-2 py-1 text-left hover:bg-elevated"
|
||||
:class="!untaggedOnly && !modelValue.length ? 'bg-primary/10 text-primary' : ''"
|
||||
@click="clearFilter"
|
||||
>
|
||||
{{ t('tags.filterAll') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded-md px-2 py-1 text-left hover:bg-elevated"
|
||||
:class="untaggedOnly ? 'bg-primary/10 text-primary' : ''"
|
||||
@click="selectUntagged"
|
||||
>
|
||||
{{ t('tags.untagged') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item-leading="{ item }">
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: findTag(item.value)?.color }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-trailing="{ item }">
|
||||
<span class="text-[11px] tabular-nums text-muted">
|
||||
{{ findTag(item.value)?.imageCount ?? 0 }}
|
||||
</span>
|
||||
</template>
|
||||
</USelect>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageTag } from '~/types/image'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
tags: ImageTag[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'changed': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { fetchTags } = useTags()
|
||||
|
||||
watch(() => props.open, (open) => {
|
||||
if (open) void fetchTags(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="t('tags.manageTitle')"
|
||||
:description="t('tags.manageSubtitle')"
|
||||
:ui="{ content: 'max-w-5xl' }"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<template #body>
|
||||
<TagManagePanel
|
||||
:tags="tags"
|
||||
@changed="emit('changed')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full justify-end">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="emit('update:open', false)"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,843 @@
|
||||
<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)
|
||||
|
||||
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()
|
||||
return 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
|
||||
}).sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0) || 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' }
|
||||
])
|
||||
|
||||
const statValueClass = computed(() =>
|
||||
'mt-2 text-2xl font-semibold tabular-nums text-highlighted'
|
||||
)
|
||||
|
||||
const tableTextClass = computed(() => 'text-sm')
|
||||
|
||||
function formatLastUsed(tag: ImageTag) {
|
||||
if (!tag.lastUsedAt || (tag.imageCount ?? 0) === 0) {
|
||||
return t('tags.neverUsed')
|
||||
}
|
||||
const date = new Date(tag.lastUsedAt)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return t('tags.neverUsed')
|
||||
}
|
||||
return date.toLocaleString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
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 class="overflow-x-auto">
|
||||
<table
|
||||
class="min-w-full"
|
||||
:class="tableTextClass"
|
||||
>
|
||||
<thead class="border-b border-default bg-elevated/60 text-xs text-muted">
|
||||
<tr>
|
||||
<th class="w-10 px-3 py-3">
|
||||
<UCheckbox
|
||||
:model-value="allPageSelected"
|
||||
:disabled="!paginatedTags.length"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('tags.colName') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('tags.colImageCount') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-left font-medium">
|
||||
{{ t('tags.colLastUsed') }}
|
||||
</th>
|
||||
<th class="px-3 py-3 text-right font-medium">
|
||||
{{ t('tags.colActions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="tag in paginatedTags"
|
||||
:key="tag.id"
|
||||
class="border-b border-default last:border-b-0"
|
||||
:class="selectedIds.has(tag.id) ? 'bg-primary/5' : 'hover:bg-elevated/40'"
|
||||
>
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<UCheckbox
|
||||
:model-value="selectedIds.has(tag.id)"
|
||||
@update:model-value="toggleSelect(tag.id)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<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"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle tabular-nums text-muted">
|
||||
{{ tag.imageCount ?? 0 }}
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle text-muted">
|
||||
{{ formatLastUsed(tag) }}
|
||||
</td>
|
||||
<td class="px-3 py-3 align-middle">
|
||||
<div class="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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!paginatedTags.length">
|
||||
<td
|
||||
colspan="5"
|
||||
class="px-3 py-12 text-center text-sm text-muted"
|
||||
>
|
||||
{{ tags.length ? t('tags.noMatch') : t('tags.emptyHint') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageTag } from '~/types/image'
|
||||
import { pickRandomUnusedTagColor } from '~/utils/tag-colors'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: number[]
|
||||
tags: ImageTag[]
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
allowCreate?: boolean
|
||||
/** 在 Modal 内使用内联列表,避免 Popover 被遮罩挡住 */
|
||||
inline?: boolean
|
||||
/** 内联列表使用复选框样式 */
|
||||
checkbox?: boolean
|
||||
/** 隐藏已选标签徽章(弹窗内用复选框列表即可) */
|
||||
hideSelectedChips?: boolean
|
||||
}>(), {
|
||||
disabled: false,
|
||||
allowCreate: true,
|
||||
inline: false,
|
||||
checkbox: false,
|
||||
hideSelectedChips: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]]
|
||||
'created': [tag: ImageTag]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const { createTag } = useTags()
|
||||
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
const selectedTags = computed(() =>
|
||||
props.tags.filter(tag => props.modelValue.includes(tag.id))
|
||||
)
|
||||
|
||||
const filteredTags = computed(() => {
|
||||
const q = query.value.trim().toLowerCase()
|
||||
if (!q) return props.tags
|
||||
return props.tags.filter(tag => tag.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const showCreateOption = computed(() => {
|
||||
if (!props.allowCreate) return false
|
||||
const q = query.value.trim()
|
||||
if (!q) return false
|
||||
return !props.tags.some(tag => tag.name === q)
|
||||
})
|
||||
|
||||
function toggleTag(tagId: number) {
|
||||
const next = new Set(props.modelValue)
|
||||
if (next.has(tagId)) {
|
||||
next.delete(tagId)
|
||||
} else {
|
||||
next.add(tagId)
|
||||
}
|
||||
emit('update:modelValue', [...next])
|
||||
}
|
||||
|
||||
function removeTag(tagId: number) {
|
||||
emit('update:modelValue', props.modelValue.filter(id => id !== tagId))
|
||||
}
|
||||
|
||||
async function createFromQuery() {
|
||||
const name = query.value.trim()
|
||||
if (!name || creating.value) return
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const color = pickRandomUnusedTagColor(props.tags.map(tag => tag.color))
|
||||
const tag = await createTag(name, color)
|
||||
emit('created', tag)
|
||||
emit('update:modelValue', [...new Set([...props.modelValue, tag.id])])
|
||||
query.value = ''
|
||||
toast.add({ title: t('tags.created', { name: tag.name }), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : t('tags.createFailed')
|
||||
toast.add({ title: message, color: 'error' })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-if="selectedTags.length && !hideSelectedChips"
|
||||
class="flex flex-wrap gap-1.5"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in selectedTags"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
removable
|
||||
@remove="removeTag(tag.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="inline"
|
||||
class="space-y-2 rounded-lg border border-default p-2"
|
||||
>
|
||||
<UInput
|
||||
v-model="query"
|
||||
size="sm"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="checkbox ? t('tags.search') : t('tags.searchOrCreate')"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
|
||||
/>
|
||||
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated"
|
||||
:class="modelValue.includes(tag.id) ? 'bg-primary/10' : ''"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
<UCheckbox
|
||||
v-if="checkbox"
|
||||
:model-value="modelValue.includes(tag.id)"
|
||||
class="pointer-events-none"
|
||||
@click.stop
|
||||
/>
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ tag.name }}</span>
|
||||
<UIcon
|
||||
v-if="!checkbox && modelValue.includes(tag.id)"
|
||||
name="i-lucide-check"
|
||||
class="size-4 text-primary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<p
|
||||
v-if="!filteredTags.length && !showCreateOption"
|
||||
class="px-2 py-3 text-center text-xs text-muted"
|
||||
>
|
||||
{{ tags.length ? t('tags.noMatch') : t('tags.emptyHint') }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="showCreateOption"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-primary hover:bg-elevated"
|
||||
:disabled="creating"
|
||||
@click="createFromQuery"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-plus"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ t('tags.createNamed', { name: query.trim() }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UPopover
|
||||
v-else
|
||||
v-model:open="open"
|
||||
>
|
||||
<UButton
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
icon="i-lucide-plus"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ placeholder ?? t('tags.addTag') }}
|
||||
</UButton>
|
||||
|
||||
<template #content>
|
||||
<div class="w-64 space-y-2 p-2">
|
||||
<UInput
|
||||
v-model="query"
|
||||
size="sm"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="checkbox ? t('tags.search') : t('tags.searchOrCreate')"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="showCreateOption ? createFromQuery() : undefined"
|
||||
/>
|
||||
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
v-for="tag in filteredTags"
|
||||
:key="tag.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-elevated"
|
||||
:class="modelValue.includes(tag.id) ? 'bg-primary/10' : ''"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate">{{ tag.name }}</span>
|
||||
<UIcon
|
||||
v-if="modelValue.includes(tag.id)"
|
||||
name="i-lucide-check"
|
||||
class="size-4 text-primary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showCreateOption"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-primary hover:bg-elevated"
|
||||
:disabled="creating"
|
||||
@click="createFromQuery"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-plus"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ t('tags.createNamed', { name: query.trim() }) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,7 +23,8 @@ const {
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const }
|
||||
{ label: t('copy.html'), value: 'html' as const },
|
||||
{ label: t('copy.bbcode'), value: 'bbcode' as const }
|
||||
])
|
||||
|
||||
const clientQualityLevel = computed(() => {
|
||||
@@ -32,7 +33,7 @@ const clientQualityLevel = computed(() => {
|
||||
return t('preferences.qualityLow')
|
||||
})
|
||||
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
|
||||
@@ -5,24 +5,39 @@ const props = withDefaults(defineProps<{
|
||||
storageBackendItems: Array<{ label: string, value: string }>
|
||||
uploadSource: string
|
||||
uploadSourceItems: Array<{ label: string, value: string }>
|
||||
tagIds?: number[]
|
||||
tagMode?: 'or' | 'and'
|
||||
untaggedOnly?: boolean
|
||||
tags?: Array<{ id: number, name: string, color: string, imageCount?: number }>
|
||||
viewMode: 'grid' | 'list'
|
||||
selectedCount?: number
|
||||
loading?: boolean
|
||||
showBatchDelete?: boolean
|
||||
showBatchTags?: boolean
|
||||
}>(), {
|
||||
selectedCount: 0,
|
||||
loading: false,
|
||||
showBatchDelete: true
|
||||
showBatchDelete: true,
|
||||
showBatchTags: false,
|
||||
tagIds: () => [],
|
||||
tags: () => [],
|
||||
tagMode: 'or',
|
||||
untaggedOnly: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:storageBackend': [value: string]
|
||||
'update:uploadSource': [value: string]
|
||||
'update:tagIds': [value: number[]]
|
||||
'update:tagMode': [value: 'or' | 'and']
|
||||
'update:untaggedOnly': [value: boolean]
|
||||
'update:viewMode': [mode: 'grid' | 'list']
|
||||
'search': []
|
||||
'reset': []
|
||||
'batch-delete': []
|
||||
'batch-tags': []
|
||||
'manage-tags': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -74,6 +89,18 @@ function viewButtonClass(active: boolean) {
|
||||
@update:model-value="emit('update:uploadSource', $event)"
|
||||
/>
|
||||
|
||||
<TagFilterSelect
|
||||
:model-value="tagIds"
|
||||
:tag-mode="tagMode"
|
||||
:untagged-only="untaggedOnly"
|
||||
:tags="tags"
|
||||
:disabled="loading"
|
||||
@update:model-value="emit('update:tagIds', $event)"
|
||||
@update:tag-mode="emit('update:tagMode', $event)"
|
||||
@update:untagged-only="emit('update:untaggedOnly', $event)"
|
||||
@manage="emit('manage-tags')"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
icon="i-lucide-search"
|
||||
@@ -100,6 +127,20 @@ function viewButtonClass(active: boolean) {
|
||||
{{ t('stats.resetFilters') }}
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="showBatchTags"
|
||||
type="button"
|
||||
icon="i-lucide-tags"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
class="shrink-0"
|
||||
:disabled="!selectedCount || loading"
|
||||
@click="emit('batch-tags')"
|
||||
>
|
||||
{{ t('tags.batchAdd') }}
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="showBatchDelete"
|
||||
type="button"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageItem } from '~/types/image'
|
||||
import type { ImageItem, ImageTag } from '~/types/image'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
items: ImageItem[]
|
||||
selectedKeys: Set<string>
|
||||
selectable?: boolean
|
||||
showStorage?: boolean
|
||||
showTags?: boolean
|
||||
emptyText?: string
|
||||
showDelete?: boolean
|
||||
}>(), {
|
||||
selectable: false,
|
||||
showStorage: false,
|
||||
showTags: false,
|
||||
showDelete: true
|
||||
})
|
||||
|
||||
@@ -18,11 +20,15 @@ const emit = defineEmits<{
|
||||
'update:selectedKeys': [value: Set<string>]
|
||||
'preview': [image: ImageItem]
|
||||
'delete': [image: ImageItem]
|
||||
'tag-click': [tagId: number]
|
||||
'untagged-click': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatFileSize } = useFileSize()
|
||||
|
||||
const INLINE_TAG_LIMIT = 3
|
||||
|
||||
const displayEmptyText = computed(() => props.emptyText ?? t('image.empty'))
|
||||
|
||||
const allSelected = computed(() =>
|
||||
@@ -76,10 +82,33 @@ function sourceLabel(image: ImageItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(image: ImageItem) {
|
||||
if (props.showStorage && image.storage) return image.storage.name
|
||||
return '—'
|
||||
function sourceIcon(image: ImageItem) {
|
||||
switch (image.uploadSource) {
|
||||
case 'web':
|
||||
return 'i-lucide-globe'
|
||||
case 'api':
|
||||
return 'i-lucide-code-xml'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function storageIcon(image: ImageItem) {
|
||||
return image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
}
|
||||
|
||||
function tagDisplay(tags: ImageTag[] | undefined) {
|
||||
const list = tags ?? []
|
||||
if (list.length <= INLINE_TAG_LIMIT) {
|
||||
return { visible: list, hidden: [] as ImageTag[] }
|
||||
}
|
||||
return {
|
||||
visible: list.slice(0, INLINE_TAG_LIMIT),
|
||||
hidden: list.slice(INLINE_TAG_LIMIT)
|
||||
}
|
||||
}
|
||||
|
||||
const metaBadgeClass = 'inline-flex max-w-full items-center gap-1 rounded-md border border-default px-1.5 py-0.5 text-[11px] leading-4 text-muted'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,12 +117,12 @@ function typeLabel(image: ImageItem) {
|
||||
class="overflow-hidden rounded-xl border border-default bg-default"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="border-b border-default text-xs text-muted">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="border-b border-default bg-elevated/40 text-muted">
|
||||
<tr>
|
||||
<th
|
||||
v-if="selectable"
|
||||
class="w-10 px-4 py-3 text-left font-medium"
|
||||
class="w-10 px-3 py-2.5 text-left font-medium"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="allSelected ? true : headerIndeterminate ? 'indeterminate' : false"
|
||||
@@ -101,22 +130,31 @@ function typeLabel(image: ImageItem) {
|
||||
@update:model-value="toggleAll"
|
||||
/>
|
||||
</th>
|
||||
<th class="min-w-[16rem] px-4 py-3 text-left font-medium">
|
||||
<th class="min-w-[14rem] px-3 py-2.5 text-left font-medium">
|
||||
{{ t('stats.colFilename') }}
|
||||
</th>
|
||||
<th class="hidden w-28 px-4 py-3 text-left font-medium sm:table-cell">
|
||||
<th class="hidden w-24 px-3 py-2.5 text-left font-medium sm:table-cell">
|
||||
{{ t('stats.colSource') }}
|
||||
</th>
|
||||
<th class="w-24 px-4 py-3 text-left font-medium">
|
||||
<th class="w-20 px-3 py-2.5 text-left font-medium">
|
||||
{{ t('stats.colSize') }}
|
||||
</th>
|
||||
<th class="hidden w-32 px-4 py-3 text-left font-medium md:table-cell">
|
||||
<th
|
||||
v-if="showStorage"
|
||||
class="hidden w-28 px-3 py-2.5 text-left font-medium md:table-cell"
|
||||
>
|
||||
{{ t('stats.colType') }}
|
||||
</th>
|
||||
<th class="hidden w-44 px-4 py-3 text-left font-medium lg:table-cell">
|
||||
<th class="hidden w-40 px-3 py-2.5 text-left font-medium lg:table-cell">
|
||||
{{ t('stats.colUploadedAt') }}
|
||||
</th>
|
||||
<th class="w-28 px-4 py-3 text-right font-medium">
|
||||
<th
|
||||
v-if="showTags"
|
||||
class="min-w-[9rem] px-3 py-2.5 text-left font-medium md:table-cell"
|
||||
>
|
||||
{{ t('tags.column') }}
|
||||
</th>
|
||||
<th class="w-24 px-3 py-2.5 text-right font-medium">
|
||||
{{ t('stats.colActions') }}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -125,22 +163,23 @@ function typeLabel(image: ImageItem) {
|
||||
<tr
|
||||
v-for="image in items"
|
||||
:key="image.key"
|
||||
class="transition-colors hover:bg-muted/20"
|
||||
class="transition-colors hover:bg-elevated/50"
|
||||
:class="selectedKeys.has(image.key) ? 'bg-primary/5' : ''"
|
||||
>
|
||||
<td
|
||||
v-if="selectable"
|
||||
class="px-4 py-3"
|
||||
class="px-3 py-2"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="selectedKeys.has(image.key)"
|
||||
@update:model-value="updateSelection(image.key, $event === true)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-3 py-2.5">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="relative size-10 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
|
||||
class="relative size-12 shrink-0 overflow-hidden rounded-md border border-default bg-muted"
|
||||
:aria-label="t('image.openPreview')"
|
||||
@click="emit('preview', image)"
|
||||
>
|
||||
@@ -152,35 +191,108 @@ function typeLabel(image: ImageItem) {
|
||||
>
|
||||
</button>
|
||||
<p
|
||||
class="min-w-0 flex-1 truncate font-medium"
|
||||
class="min-w-0 flex-1 truncate text-highlighted"
|
||||
:title="image.originalName"
|
||||
>
|
||||
{{ image.originalName }}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 sm:table-cell">
|
||||
<td class="hidden px-3 py-2 sm:table-cell">
|
||||
<span
|
||||
v-if="sourceLabel(image)"
|
||||
class="inline-flex rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
v-if="sourceLabel(image) && sourceIcon(image)"
|
||||
:class="metaBadgeClass"
|
||||
>
|
||||
{{ sourceLabel(image) }}
|
||||
<UIcon
|
||||
:name="sourceIcon(image)!"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ sourceLabel(image) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted">
|
||||
<td class="px-3 py-2 tabular-nums text-muted">
|
||||
{{ formatFileSize(image.size) }}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-muted md:table-cell">
|
||||
<span class="truncate">{{ typeLabel(image) }}</span>
|
||||
<td
|
||||
v-if="showStorage"
|
||||
class="hidden px-3 py-2 md:table-cell"
|
||||
>
|
||||
<span
|
||||
v-if="image.storage"
|
||||
:class="metaBadgeClass"
|
||||
:title="image.storage.name"
|
||||
>
|
||||
<UIcon
|
||||
:name="storageIcon(image)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ image.storage.name }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 text-muted lg:table-cell">
|
||||
<td class="hidden px-3 py-2 tabular-nums text-muted lg:table-cell">
|
||||
{{ formatUploadedAt(image.uploadedAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<td
|
||||
v-if="showTags"
|
||||
class="px-3 py-2 md:table-cell"
|
||||
>
|
||||
<div
|
||||
v-if="image.tags?.length"
|
||||
class="flex flex-wrap items-center gap-1"
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in tagDisplay(image.tags).visible"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
clickable
|
||||
@click="emit('tag-click', tag.id)"
|
||||
/>
|
||||
<UPopover
|
||||
v-if="tagDisplay(image.tags).hidden.length"
|
||||
:content="{ side: 'bottom', align: 'start' }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex rounded-md border border-default px-1.5 py-0.5 text-[11px] text-muted transition-colors hover:border-primary/40 hover:text-primary"
|
||||
:aria-label="t('tags.moreTags', { n: tagDisplay(image.tags).hidden.length })"
|
||||
@click.stop
|
||||
>
|
||||
+{{ tagDisplay(image.tags).hidden.length }}
|
||||
</button>
|
||||
<template #content>
|
||||
<div
|
||||
class="flex max-w-56 flex-wrap gap-1 p-2"
|
||||
@click.stop
|
||||
>
|
||||
<TagBadge
|
||||
v-for="tag in tagDisplay(image.tags).hidden"
|
||||
:key="tag.id"
|
||||
:tag="tag"
|
||||
clickable
|
||||
@click="emit('tag-click', tag.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="rounded-md border border-dashed border-default px-1.5 py-0.5 text-[11px] text-muted transition-colors hover:border-primary/40 hover:text-primary"
|
||||
@click.stop="emit('untagged-click')"
|
||||
>
|
||||
{{ t('tags.unlabeled') }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center justify-end gap-0.5 text-muted">
|
||||
<CopyButton
|
||||
icon="i-lucide-link"
|
||||
@@ -217,7 +329,7 @@ function typeLabel(image: ImageItem) {
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
|
||||
class="rounded-xl border border-dashed border-default py-16 text-center text-xs text-muted"
|
||||
>
|
||||
{{ displayEmptyText }}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="divide-y divide-default overflow-hidden rounded-lg border border-default">
|
||||
<div class="divide-y divide-default">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs'
|
||||
import type { ActivityLogListResponse, LogAction, LogSource, LogStatus } from '~/types/logs'
|
||||
|
||||
const { isAuthenticated, handleAuthError, isAdmin } = useAuth()
|
||||
const toast = useToast()
|
||||
@@ -8,36 +8,30 @@ const { formatFileSize } = useFileSize()
|
||||
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const actionFilter = ref<'all' | LogAction>('all')
|
||||
const sourceFilter = ref<'all' | LogSource>('all')
|
||||
const userFilter = ref<'all' | number>('all')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const listData = ref<ActivityLogListResponse | null>(null)
|
||||
const imageLinkBase = ref('')
|
||||
const hideFolderInUrl = ref(false)
|
||||
|
||||
const pageSize = 10
|
||||
|
||||
const summaryLabel = computed(() => {
|
||||
const summary = listData.value?.summary
|
||||
if (!summary) return ''
|
||||
return t('logs.summaryAll', {
|
||||
total: summary.total,
|
||||
upload: summary.upload,
|
||||
delete: summary.delete
|
||||
})
|
||||
})
|
||||
|
||||
const actionItems = computed(() => [
|
||||
{ label: t('logs.filterActionAll'), value: 'all' as const },
|
||||
{ label: t('logs.actionUpload'), value: 'upload' as const },
|
||||
{ label: t('logs.actionDelete'), value: 'delete' as const }
|
||||
{ label: t('logs.actionDelete'), value: 'delete' as const },
|
||||
{ label: t('logs.actionLogin'), value: 'login' as const },
|
||||
{ label: t('logs.actionEdit'), value: 'edit' as const },
|
||||
{ label: t('logs.actionSettings'), value: 'settings' as const },
|
||||
{ label: t('logs.actionTags'), value: 'tags' as const }
|
||||
])
|
||||
|
||||
const sourceItems = computed(() => [
|
||||
{ label: t('logs.filterSourceAll'), value: 'all' as const },
|
||||
{ label: t('logs.sourceWeb'), value: 'web' as const },
|
||||
{ label: t('logs.sourceAdmin'), value: 'admin' as const },
|
||||
{ label: t('logs.sourceApi'), value: 'api' as const }
|
||||
])
|
||||
|
||||
@@ -73,66 +67,104 @@ function formatTimeShort(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(action: LogAction) {
|
||||
const labels: Record<LogAction, string> = {
|
||||
upload: t('logs.actionUpload'),
|
||||
delete: t('logs.actionDelete'),
|
||||
login: t('logs.actionLogin'),
|
||||
edit: t('logs.actionEdit'),
|
||||
settings: t('logs.actionSettings'),
|
||||
tags: t('logs.actionTags')
|
||||
}
|
||||
return labels[action]
|
||||
}
|
||||
|
||||
function actionBadgeClass(action: LogAction) {
|
||||
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
|
||||
return action === 'upload'
|
||||
? `${base} bg-success/10 text-success`
|
||||
: `${base} bg-error/10 text-error`
|
||||
switch (action) {
|
||||
case 'upload':
|
||||
return `${base} bg-success/10 text-success`
|
||||
case 'delete':
|
||||
return `${base} bg-error/10 text-error`
|
||||
case 'login':
|
||||
return `${base} bg-sky-500/10 text-sky-600 dark:text-sky-400`
|
||||
case 'edit':
|
||||
return `${base} bg-blue-500/10 text-blue-600 dark:text-blue-400`
|
||||
case 'settings':
|
||||
return `${base} bg-slate-500/10 text-slate-600 dark:text-slate-400`
|
||||
case 'tags':
|
||||
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
|
||||
}
|
||||
}
|
||||
|
||||
function actionIcon(action: LogAction) {
|
||||
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
|
||||
switch (action) {
|
||||
case 'upload':
|
||||
return 'i-lucide-upload'
|
||||
case 'delete':
|
||||
return 'i-lucide-trash-2'
|
||||
case 'login':
|
||||
return 'i-lucide-log-in'
|
||||
case 'edit':
|
||||
return 'i-lucide-pencil'
|
||||
case 'settings':
|
||||
return 'i-lucide-settings'
|
||||
case 'tags':
|
||||
return 'i-lucide-tag'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceIcon(source: LogSource) {
|
||||
switch (source) {
|
||||
case 'web':
|
||||
return 'i-lucide-monitor'
|
||||
case 'admin':
|
||||
return 'i-lucide-layout-dashboard'
|
||||
case 'api':
|
||||
return 'i-lucide-code-2'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: LogSource) {
|
||||
switch (source) {
|
||||
case 'web':
|
||||
return t('logs.sourceWeb')
|
||||
case 'admin':
|
||||
return t('logs.sourceAdmin')
|
||||
case 'api':
|
||||
return t('logs.sourceApi')
|
||||
}
|
||||
}
|
||||
|
||||
function storageIcon(storage: ActivityLogStorage | null) {
|
||||
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
}
|
||||
|
||||
function publicPathFromKey(key: string) {
|
||||
if (hideFolderInUrl.value) {
|
||||
const slash = key.lastIndexOf('/')
|
||||
return slash >= 0 ? key.slice(slash + 1) : key
|
||||
function sourceBadgeClass(source: LogSource) {
|
||||
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
|
||||
switch (source) {
|
||||
case 'web':
|
||||
return `${base} bg-primary/10 text-primary`
|
||||
case 'admin':
|
||||
return `${base} bg-orange-500/10 text-orange-600 dark:text-orange-400`
|
||||
case 'api':
|
||||
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
|
||||
}
|
||||
const prefix = 'images/'
|
||||
return key.startsWith(prefix) ? key.slice(prefix.length) : key
|
||||
}
|
||||
|
||||
function imageUrl(key: string) {
|
||||
const base = imageLinkBase.value.replace(/\/$/, '')
|
||||
const path = publicPathFromKey(key)
|
||||
if (!base) return `/${path}`
|
||||
return `${base}/${path}`
|
||||
function statusLabel(status: LogStatus) {
|
||||
return status === 'failure' ? t('logs.resultFailure') : t('logs.resultSuccess')
|
||||
}
|
||||
|
||||
async function loadImageLinkBase() {
|
||||
try {
|
||||
if (isAdmin.value) {
|
||||
const data = await $fetch<{ effectiveImageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', {
|
||||
credentials: 'include'
|
||||
})
|
||||
imageLinkBase.value = data.effectiveImageBaseUrl
|
||||
hideFolderInUrl.value = data.hideFolderInUrl
|
||||
return
|
||||
}
|
||||
const data = await $fetch<{ env: { imageBaseUrl: string, hideFolderInUrl: boolean } }>('/api/user/api-token', {
|
||||
credentials: 'include'
|
||||
})
|
||||
imageLinkBase.value = data.env.imageBaseUrl
|
||||
hideFolderInUrl.value = data.env.hideFolderInUrl
|
||||
} catch {
|
||||
if (import.meta.client) {
|
||||
imageLinkBase.value = window.location.origin
|
||||
}
|
||||
}
|
||||
function statusDotClass(status: LogStatus) {
|
||||
return status === 'failure' ? 'bg-error' : 'bg-success'
|
||||
}
|
||||
|
||||
function formatIp(value: string | null) {
|
||||
if (!value || value === '-') return '—'
|
||||
return value
|
||||
}
|
||||
|
||||
function formatSize(action: LogAction, size: number) {
|
||||
if (action !== 'upload' && action !== 'delete') return '—'
|
||||
if (action === 'delete' && size <= 0) return '—'
|
||||
return formatFileSize(size)
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
@@ -140,7 +172,7 @@ async function loadLogs() {
|
||||
try {
|
||||
const query: Record<string, string | number> = {
|
||||
page: page.value,
|
||||
limit: pageSize
|
||||
limit: pageSize.value
|
||||
}
|
||||
if (actionFilter.value !== 'all') {
|
||||
query.action = actionFilter.value
|
||||
@@ -154,6 +186,12 @@ async function loadLogs() {
|
||||
if (activeSearch.value) {
|
||||
query.q = activeSearch.value
|
||||
}
|
||||
if (dateFrom.value) {
|
||||
query.from = dateFrom.value
|
||||
}
|
||||
if (dateTo.value) {
|
||||
query.to = dateTo.value
|
||||
}
|
||||
|
||||
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
|
||||
credentials: 'include',
|
||||
@@ -191,21 +229,32 @@ function handlePageChange(target: number) {
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadImageLinkBase()
|
||||
await loadLogs()
|
||||
function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('logs.pageTitle') }}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-4 w-1 shrink-0 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 class="text-sm font-semibold text-highlighted">
|
||||
{{ t('logs.pageTitle') }}
|
||||
</h2>
|
||||
</div>
|
||||
<p class="mt-1.5 pl-3 text-xs leading-relaxed text-muted">
|
||||
{{ isAdmin ? t('settings.pageSubtitleLogs') : t('logs.pageSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
@@ -215,35 +264,45 @@ onMounted(async () => {
|
||||
size="sm"
|
||||
:label="t('common.refresh')"
|
||||
:loading="loading"
|
||||
class="shrink-0"
|
||||
@click="loadLogs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="border-b border-default px-5 py-3 sm:px-6"
|
||||
class="mb-4 flex flex-col gap-3"
|
||||
@submit.prevent="submitSearch"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p
|
||||
v-if="summaryLabel"
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
{{ summaryLabel }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
{{ t('stats.counting') }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<UInput
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
size="sm"
|
||||
class="min-w-0 w-full sm:w-36"
|
||||
:aria-label="t('logs.dateFrom')"
|
||||
:disabled="loading"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
<span class="hidden text-xs text-muted sm:inline">{{ t('logs.dateRangeSeparator') }}</span>
|
||||
<UInput
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
size="sm"
|
||||
class="min-w-0 w-full sm:w-36"
|
||||
:aria-label="t('logs.dateTo')"
|
||||
:disabled="loading"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<USelect
|
||||
v-if="isAdmin"
|
||||
v-model="userFilter"
|
||||
:items="userItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-full sm:w-36"
|
||||
class="min-w-0 w-full sm:w-32"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
@@ -251,7 +310,7 @@ onMounted(async () => {
|
||||
v-model="actionFilter"
|
||||
:items="actionItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
@@ -259,7 +318,7 @@ onMounted(async () => {
|
||||
v-model="sourceFilter"
|
||||
:items="sourceItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
@@ -268,19 +327,17 @@ onMounted(async () => {
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('logs.searchPlaceholder')"
|
||||
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
|
||||
class="min-w-0 flex-1 sm:w-44 sm:flex-none"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<UButton
|
||||
type="submit"
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:aria-label="t('common.search')"
|
||||
:loading="loading"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('common.search') }}</span>
|
||||
{{ t('common.search') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="activeSearch"
|
||||
@@ -292,263 +349,209 @@ onMounted(async () => {
|
||||
class="shrink-0"
|
||||
:aria-label="t('stats.clear')"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
|
||||
</UButton>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-4 sm:p-6"
|
||||
>
|
||||
<div class="space-y-3 sm:hidden">
|
||||
<USkeleton
|
||||
v-for="n in 6"
|
||||
:key="`mobile-${n}`"
|
||||
class="h-24 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden space-y-2 sm:block">
|
||||
<USkeleton
|
||||
v-for="n in 8"
|
||||
:key="`desktop-${n}`"
|
||||
class="h-12 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="listData && listData.items.length">
|
||||
<div class="min-w-0 sm:hidden">
|
||||
<div class="divide-y divide-default">
|
||||
<article
|
||||
v-for="row in listData.items"
|
||||
:key="row.id"
|
||||
class="min-w-0 px-4 py-3.5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<time class="text-xs text-muted">
|
||||
{{ formatTimeShort(row.createdAt) }}
|
||||
</time>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted">
|
||||
{{ formatFileSize(row.size) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary whitespace-nowrap">
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin"
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
{{ row.username ?? '—' }}
|
||||
</span>
|
||||
<UBadge
|
||||
v-if="row.storage"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="max-w-full truncate"
|
||||
>
|
||||
<UIcon
|
||||
:name="storageIcon(row.storage)"
|
||||
class="mr-1 size-3"
|
||||
/>
|
||||
{{ row.storage.name }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mt-2 truncate text-sm"
|
||||
:title="row.originalName"
|
||||
>
|
||||
{{ row.originalName }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex min-w-0 items-center gap-1">
|
||||
<a
|
||||
v-if="row.action === 'upload'"
|
||||
:href="imageUrl(row.key)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs text-primary hover:underline"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 flex-1 truncate font-mono text-xs text-dimmed"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</span>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
icon-only
|
||||
class="shrink-0"
|
||||
:label="t('common.copy')"
|
||||
:value="row.key"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
<div class="overflow-hidden rounded-xl border border-default bg-default">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-4 sm:p-6"
|
||||
>
|
||||
<div class="space-y-3 sm:hidden">
|
||||
<USkeleton
|
||||
v-for="n in 6"
|
||||
:key="`mobile-${n}`"
|
||||
class="h-24 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div class="hidden space-y-2 sm:block">
|
||||
<USkeleton
|
||||
v-for="n in 8"
|
||||
:key="`desktop-${n}`"
|
||||
class="h-12 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden min-w-0 sm:block">
|
||||
<table class="w-full table-fixed text-left text-sm">
|
||||
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
||||
<tr>
|
||||
<th class="w-36 px-4 py-3 font-medium sm:px-6">
|
||||
{{ t('logs.colTime') }}
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="w-20 px-4 py-3 font-medium"
|
||||
>
|
||||
{{ t('logs.colUser') }}
|
||||
</th>
|
||||
<th class="w-20 whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colAction') }}
|
||||
</th>
|
||||
<th class="w-16 whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colSource') }}
|
||||
</th>
|
||||
<th class="w-24 px-4 py-3 font-medium">
|
||||
{{ t('logs.colStorage') }}
|
||||
</th>
|
||||
<th class="hidden px-4 py-3 font-medium md:table-cell">
|
||||
{{ t('logs.colFile') }}
|
||||
</th>
|
||||
<th class="w-20 px-4 py-3 font-medium">
|
||||
{{ t('logs.colSize') }}
|
||||
</th>
|
||||
<th class="hidden px-4 py-3 font-medium xl:table-cell">
|
||||
{{ t('logs.colKey') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr
|
||||
<template v-else-if="listData && listData.items.length">
|
||||
<div class="min-w-0 sm:hidden">
|
||||
<div class="divide-y divide-default">
|
||||
<article
|
||||
v-for="row in listData.items"
|
||||
:key="row.id"
|
||||
class="hover:bg-muted/20"
|
||||
class="min-w-0 px-4 py-3.5"
|
||||
>
|
||||
<td class="px-4 py-3 text-xs text-muted sm:px-6">
|
||||
<span class="block truncate">{{ formatTime(row.createdAt) }}</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-3 text-xs"
|
||||
>
|
||||
<span class="block truncate">{{ row.username ?? '—' }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<time class="text-xs text-muted">
|
||||
{{ formatTimeShort(row.createdAt) }}
|
||||
</time>
|
||||
<span class="shrink-0 text-xs tabular-nums text-muted">
|
||||
{{ formatSize(row.action, row.size) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
{{ actionLabel(row.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<span :class="sourceBadgeClass(row.source)">
|
||||
<UIcon
|
||||
:name="sourceIcon(row.source)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
v-if="row.storage"
|
||||
class="flex min-w-0 items-center gap-1"
|
||||
:title="row.storage.name"
|
||||
v-if="isAdmin"
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="storageIcon(row.storage)"
|
||||
class="size-3.5 shrink-0 opacity-70"
|
||||
/>
|
||||
<span class="truncate">{{ row.storage.name }}</span>
|
||||
{{ row.username ?? '—' }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td
|
||||
class="hidden max-w-0 truncate px-4 py-3 md:table-cell"
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mt-2 truncate text-sm"
|
||||
:title="row.originalName"
|
||||
>
|
||||
{{ row.originalName }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
||||
{{ formatFileSize(row.size) }}
|
||||
</td>
|
||||
<td class="hidden max-w-0 px-4 py-3 xl:table-cell">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<a
|
||||
v-if="row.action === 'upload'"
|
||||
:href="imageUrl(row.key)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="min-w-0 truncate font-mono text-xs text-primary hover:underline"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>{{ formatIp(row.ipAddress) }}</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 truncate font-mono text-xs text-dimmed"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</span>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
icon-only
|
||||
class="shrink-0"
|
||||
:label="t('common.copy')"
|
||||
:value="row.key"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
class="size-1.5 rounded-full"
|
||||
:class="statusDotClass(row.status)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden min-w-0 overflow-x-auto sm:block">
|
||||
<table class="w-full min-w-[56rem] text-left text-sm">
|
||||
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
||||
<tr>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('logs.colTime') }}
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="whitespace-nowrap px-4 py-3 font-medium"
|
||||
>
|
||||
{{ t('logs.colUser') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colAction') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colSource') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
{{ t('logs.colFile') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colSize') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colIp') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('logs.colResult') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr
|
||||
v-for="row in listData.items"
|
||||
:key="row.id"
|
||||
class="hover:bg-muted/20"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-5">
|
||||
{{ formatTime(row.createdAt) }}
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="whitespace-nowrap px-4 py-3 text-xs"
|
||||
>
|
||||
{{ row.username ?? '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ actionLabel(row.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span :class="sourceBadgeClass(row.source)">
|
||||
<UIcon
|
||||
:name="sourceIcon(row.source)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="max-w-[12rem] truncate px-4 py-3"
|
||||
:title="row.originalName"
|
||||
>
|
||||
{{ row.originalName }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
||||
{{ formatSize(row.action, row.size) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs text-muted">
|
||||
{{ formatIp(row.ipAddress) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 sm:px-5">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
class="size-1.5 rounded-full"
|
||||
:class="statusDotClass(row.status)"
|
||||
/>
|
||||
{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
|
||||
>
|
||||
{{ t('logs.empty') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
|
||||
>
|
||||
{{ t('logs.empty') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="listData && listData.total > 0"
|
||||
class="border-t border-default px-4 py-4 sm:px-6"
|
||||
>
|
||||
<PaginationBar
|
||||
:page="listData.page"
|
||||
:total-pages="listData.totalPages"
|
||||
:total="listData.total"
|
||||
:unit="t('logs.entriesUnit')"
|
||||
:loading="loading"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
<div
|
||||
v-if="listData && listData.total > 0"
|
||||
class="border-t border-default px-4 py-4 sm:px-5"
|
||||
>
|
||||
<PaginationBar
|
||||
:page="listData.page"
|
||||
:total-pages="listData.totalPages"
|
||||
:total="listData.total"
|
||||
:unit="t('logs.entriesUnit')"
|
||||
:page-size="pageSize"
|
||||
:loading="loading"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="mb-0 flex items-start gap-3 border-b border-default pb-6">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<UIcon
|
||||
:name="icon"
|
||||
class="size-5 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 pt-0.5">
|
||||
<h1 class="text-lg font-semibold tracking-tight text-highlighted">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-1 text-xs leading-relaxed text-muted"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-xl border border-default bg-default p-5 sm:p-6">
|
||||
<div class="space-y-8">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="mt-6 space-y-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
hint?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden rounded-xl border border-default bg-default">
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-sm font-semibold text-highlighted">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<slot name="badge" />
|
||||
</div>
|
||||
<p
|
||||
v-if="hint"
|
||||
class="mt-1 text-xs leading-relaxed text-muted"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-default px-4 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
export type SettingsRadioOption = {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
items: SettingsRadioOption[]
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const radioUi = {
|
||||
fieldset: 'flex flex-col divide-y divide-default',
|
||||
item: 'flex items-start gap-4 py-4',
|
||||
wrapper: 'min-w-0 flex-1 pr-2',
|
||||
base: 'mt-0.5 shrink-0',
|
||||
label: 'block text-sm font-medium text-highlighted',
|
||||
description: 'mt-1 block text-xs leading-relaxed text-muted'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<URadioGroup
|
||||
v-model="model"
|
||||
:items="items"
|
||||
variant="list"
|
||||
:ui="radioUi"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<span class="text-sm font-medium text-highlighted">{{ item.label }}</span>
|
||||
</template>
|
||||
<template #description="{ item }">
|
||||
<span
|
||||
v-if="item.description"
|
||||
class="mt-1 block text-xs leading-relaxed text-muted"
|
||||
>{{ item.description }}</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
</template>
|
||||
@@ -6,8 +6,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<section class="overflow-hidden rounded-xl border border-default bg-default">
|
||||
<div class="flex items-start justify-between gap-4 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
@@ -32,6 +32,8 @@ defineProps<{
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
<div class="border-t border-default px-4">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
restore: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -13,13 +14,19 @@ const { t } = useI18n()
|
||||
|
||||
<template>
|
||||
<div class="border-t border-default bg-default px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||
<p
|
||||
v-if="hasChanges"
|
||||
class="mr-auto text-xs text-warning"
|
||||
>
|
||||
{{ t('settings.unsavedChanges') }}
|
||||
</p>
|
||||
<UButton
|
||||
:label="t('settings.restoreDefaults')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="emit('restore')"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('settings.saveSettings')"
|
||||
icon="i-lucide-save"
|
||||
|
||||
@@ -1,39 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsTab } from '~/types/settings'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: SettingsTab
|
||||
items: Array<{ id: SettingsTab, label: string, icon: string }>
|
||||
title: string
|
||||
subtitle: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [SettingsTab]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return props.items
|
||||
return props.items.filter(item => item.label.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function selectTab(id: SettingsTab) {
|
||||
emit('update:modelValue', id)
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-neutral-50 dark:bg-neutral-900/30 lg:w-56 lg:border-b-0 lg:border-r xl:w-60">
|
||||
<div class="border-b border-default px-4 py-5 sm:px-5">
|
||||
<h1 class="text-lg font-semibold tracking-tight text-highlighted">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div class="border-b border-default p-3">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
size="sm"
|
||||
:placeholder="t('settings.searchPlaceholder')"
|
||||
icon="i-lucide-search"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UKbd
|
||||
size="sm"
|
||||
class="hidden sm:inline-flex"
|
||||
>
|
||||
⌘K
|
||||
</UKbd>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 space-y-0.5 p-3">
|
||||
<p
|
||||
v-if="searchQuery.trim() && !filteredItems.length"
|
||||
class="px-3 py-6 text-center text-xs text-muted"
|
||||
>
|
||||
{{ t('settings.searchEmpty') }}
|
||||
</p>
|
||||
<button
|
||||
v-for="item in items"
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors"
|
||||
:class="modelValue === item.id
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted hover:bg-muted/50 hover:text-highlighted'"
|
||||
@click="emit('update:modelValue', item.id)"
|
||||
@click="selectTab(item.id)"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
|
||||
@@ -4,32 +4,64 @@ defineProps<{
|
||||
updateAvailable: boolean
|
||||
latestVersion: string | null
|
||||
releaseUrl: string | null
|
||||
checking: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
checkUpdate: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pl-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-highlighted">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.systemVersion') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.systemVersionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium tabular-nums text-highlighted">
|
||||
v{{ appVersion }}
|
||||
</span>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
v-if="updateAvailable && latestVersion"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
v{{ appVersion }}
|
||||
{{ t('settings.updateAvailable', { version: `v${latestVersion}` }) }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('settings.upToDate') }}
|
||||
</UBadge>
|
||||
<UButton
|
||||
:label="t('settings.checkUpdate')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:loading="checking"
|
||||
@click="emit('checkUpdate')"
|
||||
/>
|
||||
<a
|
||||
v-if="updateAvailable && latestVersion"
|
||||
:href="releaseUrl || 'https://github.com/O96u/PicHost/releases'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-warning hover:underline"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('settings.updateAvailable', { version: `v${latestVersion}` }) }}
|
||||
{{ t('settings.viewReleases') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const { items: tags, loading, fetchTags } = useTags()
|
||||
|
||||
onMounted(() => {
|
||||
void fetchTags()
|
||||
})
|
||||
|
||||
async function handleChanged() {
|
||||
await fetchTags(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPanel>
|
||||
<div
|
||||
v-if="loading && !tags.length"
|
||||
class="flex justify-center py-16"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-6 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
<TagManagePanel
|
||||
v-else
|
||||
:tags="tags"
|
||||
embedded
|
||||
@changed="handleChanged"
|
||||
/>
|
||||
</SettingsPanel>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@ const model = defineModel<boolean>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5">
|
||||
<div class="flex items-start justify-between gap-4 py-4">
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
<slot name="title">
|
||||
|
||||
@@ -19,16 +19,11 @@ const {
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const }
|
||||
{ label: t('copy.html'), value: 'html' as const },
|
||||
{ label: t('copy.bbcode'), value: 'bbcode' as const }
|
||||
])
|
||||
|
||||
const clientQualityLevel = computed(() => {
|
||||
if (clientWebpQuality.value >= 85) return t('preferences.qualityHigh')
|
||||
if (clientWebpQuality.value >= 60) return t('preferences.qualityMedium')
|
||||
return t('preferences.qualityLow')
|
||||
})
|
||||
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
@@ -37,6 +32,11 @@ function onClientQualityInput(event: Event) {
|
||||
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||
}
|
||||
|
||||
function onClientQualityNumberInput(value: string | number) {
|
||||
const num = Number(value)
|
||||
clientWebpQuality.value = Number.isFinite(num) ? Math.min(100, Math.max(1, Math.round(num))) : 80
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPreferences()
|
||||
void loadUserSettings()
|
||||
@@ -53,7 +53,7 @@ onMounted(() => {
|
||||
/>
|
||||
<div
|
||||
v-if="userSettings"
|
||||
class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5"
|
||||
class="flex items-start justify-between gap-4 py-4"
|
||||
>
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
|
||||
@@ -87,55 +87,61 @@ onMounted(() => {
|
||||
class="size-5 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 py-4 sm:px-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.webpQualityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right text-xs text-muted">
|
||||
<p>{{ t('preferences.current', { n: clientWebpQuality }) }}</p>
|
||||
<p class="mt-0.5">
|
||||
{{ clientQualityLevel }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.webpQualityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="clientWebpQuality"
|
||||
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||
@input="onClientQualityInput"
|
||||
>
|
||||
<UInput
|
||||
:model-value="clientWebpQuality"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
size="xs"
|
||||
class="w-14 shrink-0 tabular-nums"
|
||||
@update:model-value="onClientQualityNumberInput"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="clientWebpQuality"
|
||||
class="mt-3 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||
@input="onClientQualityInput"
|
||||
>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection :title="t('settings.uploadConvenience')">
|
||||
<SettingsSection :title="t('preferences.title')">
|
||||
<SettingsGroup>
|
||||
<SettingsToggleRow
|
||||
v-model="autoCopyMarkdown"
|
||||
:title="t('preferences.autoCopy')"
|
||||
:hint="t('preferences.autoCopyHint')"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div class="py-4">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.defaultCopyFormat') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.defaultCopyFormatHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex w-full max-w-xs gap-1 rounded-lg border border-default p-0.5 sm:w-auto"
|
||||
class="mt-3 flex w-full max-w-md gap-1 rounded-lg border border-default p-0.5"
|
||||
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
|
||||
>
|
||||
<UButton
|
||||
v-for="item in copyFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center sm:min-w-[4.5rem]"
|
||||
class="flex-1 justify-center"
|
||||
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
|
||||
Reference in new issue
Block a user