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,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>
|
||||
Reference in new issue
Block a user