From 4ed4692f5eb0c4647aa5114d7e04afd6c2c5d322 Mon Sep 17 00:00:00 2001 From: O96u Date: Wed, 23 Sep 2026 15:38:46 +0800 Subject: [PATCH] release: v1.4.2 Fix batch tagging and upload token auth; single API token and tagNames-only upload; tag management sort and responsive layout. --- CHANGELOG.md | 14 + app/components/TagManagePanel.vue | 749 ++++++++++++++++------- app/components/api/ApiDocView.vue | 25 +- app/components/api/ApiTokenCard.vue | 9 - app/composables/useApiDocs.ts | 7 - app/composables/useImageUpload.ts | 8 +- app/pages/api.vue | 48 +- app/pages/gallery.vue | 7 +- docs-site/en/guide/api.md | 12 +- docs-site/en/guide/changelog.md | 10 + docs-site/guide/api.md | 12 +- docs-site/guide/changelog.md | 10 + docs-site/guide/users-and-permissions.md | 19 +- i18n/locales/en.json | 8 +- i18n/locales/zh-CN.json | 6 +- package-lock.json | 4 +- package.json | 2 +- server/api/images/batch-tags.post.ts | 9 +- server/api/images/upload.post.ts | 7 +- server/routes/api/index.php.post.ts | 13 +- server/utils/access.ts | 69 ++- server/utils/db.ts | 8 + server/utils/tags.test.ts | 9 +- server/utils/tags.ts | 133 +++- 24 files changed, 828 insertions(+), 370 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a30c72e..cd9fce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ ## [Unreleased] +## [1.4.2] - 2026-09-23 + +### 修复 + +- 图库批量打标:管理员/跨归属场景下标签解析与失败提示 +- 上传鉴权:`Auth-Token` 与表单 `token` 优先于会话;兼容全局 `API_UPLOAD_TOKEN`(上传归属首位管理员) + +### 改进 + +- **API 页**:每账号单一 API Token,界面与文档统一 +- **上传 API**:仅支持 `tagNames`(按名创建或复用标签);Twikoo 兼容路由同步 +- **标签管理**:按图片数/最近使用排序;桌面列布局;窄屏卡片列表 +- **API 文档**:参数表列宽,避免 form-data 列异常换行 + ## [1.4.1] - 2026-09-16 ### 新增 diff --git a/app/components/TagManagePanel.vue b/app/components/TagManagePanel.vue index 2480404..bb20470 100644 --- a/app/components/TagManagePanel.vue +++ b/app/components/TagManagePanel.vue @@ -8,12 +8,15 @@ import { TAG_COLOR_EDIT_CANDIDATE_COUNT } from '~/utils/tag-colors' -const props = withDefaults(defineProps<{ - tags: ImageTag[] - embedded?: boolean -}>(), { - embedded: false -}) +const props = withDefaults( + defineProps<{ + tags: ImageTag[] + embedded?: boolean + }>(), + { + embedded: false + } +) const emit = defineEmits<{ changed: [] @@ -27,7 +30,9 @@ const { createTag, updateTag, deleteTag, mergeTags } = useTags() const creating = ref(false) const newName = ref('') const newColorCandidates = ref(pickTagColorCandidates([])) -const newColor = ref(pickDefaultTagColorFromCandidates(newColorCandidates.value, [])) +const newColor = ref( + pickDefaultTagColorFromCandidates(newColorCandidates.value, []) +) const search = ref('') const statusFilter = ref<'all' | 'used' | 'unused'>('all') const selectedIds = ref>(new Set()) @@ -47,6 +52,10 @@ const saving = ref(false) const page = ref(1) const pageSize = ref(10) +type TagSortKey = 'imageCount' | 'lastUsedAt' +const sortKey = ref('imageCount') +const sortDir = ref<'asc' | 'desc'>('desc') + const stats = computed(() => { const now = Date.now() const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000 @@ -71,15 +80,31 @@ const stats = computed(() => { 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 + const list = props.tags.filter((tag) => { + if (statusFilter.value === 'used' && (tag.imageCount ?? 0) === 0) + return false + if (statusFilter.value === 'unused' && (tag.imageCount ?? 0) > 0) + return false if (q && !tag.name.toLowerCase().includes(q)) return false return true - }).sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0) || a.name.localeCompare(b.name)) + }) + + const dir = sortDir.value === 'asc' ? 1 : -1 + return list.sort((a, b) => { + let cmp = 0 + if (sortKey.value === 'imageCount') { + cmp = (a.imageCount ?? 0) - (b.imageCount ?? 0) + } else { + cmp = lastUsedSortTime(a) - lastUsedSortTime(b) + } + if (cmp !== 0) return cmp * dir + return a.name.localeCompare(b.name) + }) }) -const totalPages = computed(() => Math.max(1, Math.ceil(filteredTags.value.length / pageSize.value))) +const totalPages = computed(() => + Math.max(1, Math.ceil(filteredTags.value.length / pageSize.value)) +) const paginatedTags = computed(() => { const start = (page.value - 1) * pageSize.value @@ -96,30 +121,37 @@ watch(filteredTags, (list) => { } }) -const selectedList = computed(() => - [...selectedIds.value] - .map(id => props.tags.find(tag => tag.id === id)) - .filter(Boolean) as ImageTag[] +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 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 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)) + .sort( + (a, b) => + (b.imageCount ?? 0) - (a.imageCount ?? 0) + || a.name.localeCompare(b.name) + ) }) const mergeTagsToDelete = computed(() => { @@ -127,8 +159,8 @@ const mergeTagsToDelete = computed(() => { return mergeSourceTags.value.filter(tag => tag.id !== mergeTargetId.value) }) -const mergeKeepTag = computed(() => - props.tags.find(tag => tag.id === mergeTargetId.value) ?? null +const mergeKeepTag = computed( + () => props.tags.find(tag => tag.id === mergeTargetId.value) ?? null ) const statusItems = computed(() => [ @@ -137,28 +169,71 @@ const statusItems = computed(() => [ { label: t('tags.filterStatusUnused'), value: 'unused' } ]) -const statValueClass = computed(() => - 'mt-2 text-2xl font-semibold tabular-nums text-highlighted' +function tagSortTime(iso: string | null | undefined): number { + if (!iso) return 0 + const time = new Date(iso).getTime() + return Number.isNaN(time) ? 0 : time +} + +function lastUsedSortTime(tag: ImageTag): number { + if ((tag.imageCount ?? 0) === 0) return 0 + return tagSortTime(tag.lastUsedAt) +} + +function toggleSort(key: TagSortKey) { + if (sortKey.value === key) { + sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc' + } else { + sortKey.value = key + sortDir.value = 'desc' + } + page.value = 1 +} + +function sortIcon(key: TagSortKey): string { + if (sortKey.value !== key) return 'i-lucide-arrow-up-down' + return sortDir.value === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down' +} + +function sortIconClass(key: TagSortKey): string { + const base = 'size-3 shrink-0' + return sortKey.value === key + ? `${base} opacity-70` + : `${base} opacity-40` +} + +const statValueClass = computed( + () => 'mt-2 text-2xl font-semibold tabular-nums text-highlighted' ) const tableTextClass = computed(() => 'text-sm') +const tagRowClass = 'flex items-center gap-x-3 px-3' + +const tagNameColClass = 'min-w-0 flex-1 basis-0 pr-8 sm:pr-12' + +const tagRightGroupClass + = 'ml-auto flex shrink-0 items-center gap-x-8 sm:gap-x-10' + +const tagCountColClass + = 'w-44 shrink-0 text-left tabular-nums whitespace-nowrap sm:w-48' + +const tagLastColClass + = 'w-[10.5rem] shrink-0 whitespace-nowrap text-xs text-muted' + +const tagActionsColClass = 'w-[9.5rem] shrink-0' + function formatLastUsed(tag: ImageTag) { if (!tag.lastUsedAt || (tag.imageCount ?? 0) === 0) { return t('tags.neverUsed') } - const date = new Date(tag.lastUsedAt) - if (Number.isNaN(date.getTime())) { + try { + return new Date(tag.lastUsedAt).toLocaleString(locale.value, { + hour12: false + }) + } catch { 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) { @@ -191,7 +266,10 @@ function usedColorsExcept(tagId?: number) { function refreshNewTagColor() { const used = usedColorsExcept() newColorCandidates.value = pickTagColorCandidates(used) - newColor.value = pickDefaultTagColorFromCandidates(newColorCandidates.value, used) + newColor.value = pickDefaultTagColorFromCandidates( + newColorCandidates.value, + used + ) } function toggleCreating() { @@ -303,7 +381,10 @@ async function confirmDelete() { selectedIds.value.delete(tag.id) selectedIds.value = new Set(selectedIds.value) emit('changed') - toast.add({ title: t('tags.deleted', { name: tag.name }), color: 'success' }) + toast.add({ + title: t('tags.deleted', { name: tag.name }), + color: 'success' + }) } catch { toast.add({ title: t('tags.deleteFailed'), color: 'error' }) } finally { @@ -318,7 +399,9 @@ function openBatchMerge() { } mergeMode.value = 'batch' mergeSourceIds.value = [...selectedIds.value] - const sorted = selectedList.value.sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0)) + const sorted = selectedList.value.sort( + (a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0) + ) mergeTargetId.value = sorted[0]?.id ?? null mergeOpen.value = true } @@ -335,14 +418,18 @@ function openSingleMerge(tag: ImageTag) { function requestMergeConfirm() { if (!mergeTargetId.value) return - const sourceIds = mergeSourceIds.value.filter(id => id !== mergeTargetId.value) + 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) + const sourceIds = mergeSourceIds.value.filter( + id => id !== mergeTargetId.value + ) if (!sourceIds.length) return saving.value = true try { @@ -365,13 +452,15 @@ async function confirmMerge() {
- + - {{ t('tags.statTotal') }} + {{ t("tags.statTotal") }}

{{ stats.total }} @@ -379,13 +468,15 @@ async function confirmMerge() {

- + - {{ t('tags.statUsed') }} + {{ t("tags.statUsed") }}

{{ stats.used }} @@ -393,13 +484,15 @@ async function confirmMerge() {

- + - {{ t('tags.statUnused') }} + {{ t("tags.statUnused") }}

{{ stats.unused }} @@ -407,13 +500,15 @@ async function confirmMerge() {

- + - {{ t('tags.statNew7d') }} + {{ t("tags.statNew7d") }}

{{ stats.newIn7Days }} @@ -443,7 +538,7 @@ async function confirmMerge() { icon="i-lucide-plus" @click="toggleCreating" > - {{ t('tags.addTag') }} + {{ t("tags.addTag") }} - {{ t('tags.mergeTags') }} + {{ t("tags.mergeTags") }}

@@ -464,7 +559,7 @@ async function confirmMerge() { class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2" > - {{ t('tags.mergeSelected', { n: selectedCount }) }} + {{ t("tags.mergeSelected", { n: selectedCount }) }} - {{ t('tags.mergeClear') }} + {{ t("tags.mergeClear") }} @@ -498,11 +593,9 @@ async function confirmMerge() { class="shrink-0 pt-1" >

- {{ t('tags.preview') }} + {{ t("tags.preview") }}

- +
@@ -510,9 +603,13 @@ async function confirmMerge() { size="sm" variant="ghost" color="neutral" - @click="() => { creating = false }" + @click=" + () => { + creating = false; + } + " > - {{ t('common.cancel') }} + {{ t("common.cancel") }} - {{ t('common.save') }} + {{ t("common.save") }}
-
- - - - - - - - - - - - + {{ tags.length ? t("tags.noMatch") : t("tags.emptyHint") }} + + + +
- - - {{ t('tags.colName') }} - - {{ t('tags.colImageCount') }} - - {{ t('tags.colLastUsed') }} - - {{ t('tags.colActions') }} -