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

- 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标
- 操作日志记录登录、删除、设置与标签操作
- API 标签/打标接口;README 与文档站截图同步
This commit is contained in:
O96u committed 2026-09-09 14:46:52 +08:00
1 parent f17d79b236
commit 737235acff
110 files changed
+5881 -778

No files matched your search

+61 -1
View File
@@ -20,6 +20,9 @@ export function useImageList() {
const activeStorageBackend = ref('all')
const activeContentType = ref('all')
const activeUploadSource = ref('all')
const activeTagIds = ref<number[]>([])
const activeTagMode = ref<'or' | 'and'>('or')
const activeUntaggedOnly = ref(false)
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function storageQueryParams(): Record<string, string> {
@@ -38,6 +41,14 @@ export function useImageList() {
if (uploadSource && uploadSource !== 'all') {
params.uploadSource = uploadSource
}
if (activeUntaggedOnly.value) {
params.untagged = '1'
} else if (activeTagIds.value.length) {
params.tagIds = activeTagIds.value.join(',')
if (activeTagMode.value === 'and' && activeTagIds.value.length > 1) {
params.tagMode = 'and'
}
}
return params
}
@@ -181,12 +192,48 @@ export function useImageList() {
}
}
async function setActiveTagFilter(options: {
tagIds?: number[]
tagMode?: 'or' | 'and'
untaggedOnly?: boolean
}) {
if (options.tagIds !== undefined) {
activeTagIds.value = [...options.tagIds]
}
if (options.tagMode !== undefined) {
activeTagMode.value = options.tagMode
}
if (options.untaggedOnly !== undefined) {
activeUntaggedOnly.value = options.untaggedOnly
}
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function toggleTagFilter(tagId: number) {
const next = new Set(activeTagIds.value)
activeUntaggedOnly.value = false
if (next.has(tagId)) {
next.delete(tagId)
} else {
next.add(tagId)
}
await setActiveTagFilter({ tagIds: [...next] })
}
async function resetFilters() {
searchQuery.value = ''
activeSearch.value = ''
activeStorageBackend.value = 'all'
activeContentType.value = 'all'
activeUploadSource.value = 'all'
activeTagIds.value = []
activeTagMode.value = 'or'
activeUntaggedOnly.value = false
page.value = 1
await Promise.all([fetchList(1), fetchTotal()])
}
@@ -278,6 +325,12 @@ export function useImageList() {
return t('stats.summaryTotal', { total: count })
})
function updateItemTags(key: string, tags: ImageItem['tags']) {
items.value = items.value.map(item =>
item.key === key ? { ...item, tags } : item
)
}
return {
items,
page,
@@ -288,6 +341,9 @@ export function useImageList() {
activeStorageBackend,
activeContentType,
activeUploadSource,
activeTagIds,
activeTagMode,
activeUntaggedOnly,
storageBackendOptions,
searchQuery,
activeSearch,
@@ -300,12 +356,16 @@ export function useImageList() {
setActiveStorageBackend,
setActiveContentType,
setActiveUploadSource,
setActiveTagFilter,
toggleTagFilter,
resetFilters,
loadStorageBackendOptions,
submitSearch,
goToPage,
setPageSize,
prependItems,
removeItems
removeItems,
updateItemTags,
listQueryParams
}
}
+8 -2
View File
@@ -10,14 +10,14 @@ const MAX_FILES = 10
export function useImageUpload() {
const toast = useToast()
const { t } = useI18n()
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles(
files: File[],
options?: { notify?: boolean }
options?: { notify?: boolean, tagIds?: number[] }
) {
if (uploading.value) return
@@ -58,6 +58,12 @@ export function useImageUpload() {
formData.append('files', file, file.name)
}
const tagIds = options?.tagIds ?? lastTagIds.value
if (tagIds.length) {
formData.append('tagIds', JSON.stringify(tagIds))
lastTagIds.value = tagIds
}
const response = await $fetch<UploadResponse>('/api/images/upload', {
method: 'POST',
body: formData
+48
View File
@@ -0,0 +1,48 @@
const STORAGE_KEY = 'pichost.recent-tag-ids'
const MAX_RECENT = 12
function readRecentIds(): number[] {
if (import.meta.server) return []
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((id: unknown): id is number => Number.isInteger(id) && (id as number) > 0)
} catch {
return []
}
}
function writeRecentIds(ids: number[]) {
if (import.meta.server) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
}
export function useRecentTags() {
const recentIds = useState<number[]>('recent-tag-ids', () => [])
function loadRecentIds() {
recentIds.value = readRecentIds()
}
if (import.meta.client && !recentIds.value.length) {
loadRecentIds()
}
function recordRecent(tagIds: number[]) {
if (!tagIds.length) return
const merged = [
...tagIds,
...recentIds.value.filter(id => !tagIds.includes(id))
].slice(0, MAX_RECENT)
recentIds.value = merged
writeRecentIds(merged)
}
return {
recentIds,
loadRecentIds,
recordRecent
}
}
+122
View File
@@ -0,0 +1,122 @@
import type { ImageTag } from '~/types/image'
export interface TagListResponse {
items: ImageTag[]
}
export interface TagResponse {
tag: ImageTag
}
export function useTags() {
const items = useState<ImageTag[]>('tags-list', () => [])
const loading = ref(false)
const loaded = ref(false)
async function fetchTags(force = false) {
if (loaded.value && !force) return items.value
loading.value = true
try {
const data = await $fetch<TagListResponse>('/api/tags', {
credentials: 'include'
})
items.value = data.items
loaded.value = true
return data.items
} finally {
loading.value = false
}
}
async function createTag(name: string, color?: string | null) {
const data = await $fetch<TagResponse>('/api/tags', {
method: 'POST',
credentials: 'include',
body: { name, color }
})
await fetchTags(true)
return data.tag
}
async function updateTag(id: number, patch: { name?: string, color?: string | null }) {
const data = await $fetch<TagResponse>(`/api/tags/${id}`, {
method: 'PATCH',
credentials: 'include',
body: patch
})
await fetchTags(true)
return data.tag
}
async function deleteTag(id: number) {
await $fetch(`/api/tags/${id}`, {
method: 'DELETE',
credentials: 'include'
})
await fetchTags(true)
}
async function mergeTags(sourceIds: number[], targetId: number) {
const data = await $fetch<TagResponse>('/api/tags/merge', {
method: 'POST',
credentials: 'include',
body: { sourceIds, targetId }
})
await fetchTags(true)
return data.tag
}
async function addTagsToImage(key: string, tagIds: number[]) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'POST',
credentials: 'include',
body: { key, tagIds }
})
return data.tags
}
async function setImageTags(key: string, tagIds: number[]) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'PATCH',
credentials: 'include',
body: { key, tagIds }
})
return data.tags
}
async function removeTagFromImage(key: string, tagId: number) {
const data = await $fetch<{ tags: ImageTag[] }>('/api/images/tags', {
method: 'DELETE',
credentials: 'include',
body: { key, tagId }
})
return data.tags
}
async function batchUpdateTags(
keys: string[],
tagIds: number[],
action: 'add' | 'remove' = 'add'
) {
return $fetch<{ success: boolean, updated: number }>('/api/images/batch-tags', {
method: 'POST',
credentials: 'include',
body: { keys, tagIds, action }
})
}
return {
items,
loading,
loaded,
fetchTags,
createTag,
updateTag,
deleteTag,
mergeTags,
addTagsToImage,
setImageTags,
removeTagFromImage,
batchUpdateTags
}
}
+25 -7
View File
@@ -1,19 +1,21 @@
const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html'
export type CopyFormat = 'url' | 'markdown' | 'html' | 'bbcode'
export interface UploadPreferences {
compressEnabled: boolean
autoCopyMarkdown: boolean
copyFormat: CopyFormat
clientWebpQuality: number
lastTagIds: number[]
}
const DEFAULTS: UploadPreferences = {
compressEnabled: false,
autoCopyMarkdown: false,
copyFormat: 'markdown',
clientWebpQuality: 80
clientWebpQuality: 80,
lastTagIds: []
}
function clampQuality(value: unknown): number {
@@ -23,7 +25,7 @@ function clampQuality(value: unknown): number {
}
function isCopyFormat(value: unknown): value is CopyFormat {
return value === 'url' || value === 'markdown' || value === 'html'
return value === 'url' || value === 'markdown' || value === 'html' || value === 'bbcode'
}
function readStored(): UploadPreferences {
@@ -37,7 +39,10 @@ function readStored(): UploadPreferences {
compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
clientWebpQuality: clampQuality(parsed.clientWebpQuality)
clientWebpQuality: clampQuality(parsed.clientWebpQuality),
lastTagIds: Array.isArray(parsed.lastTagIds)
? parsed.lastTagIds.filter(id => Number.isInteger(id) && id > 0)
: DEFAULTS.lastTagIds
}
} catch {
return { ...DEFAULTS }
@@ -54,6 +59,7 @@ export function useUploadPreferences() {
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
const lastTagIds = useState<number[]>('upload-pref-last-tag-ids', () => DEFAULTS.lastTagIds)
const loaded = useState('upload-pref-loaded', () => false)
function loadPreferences() {
@@ -62,6 +68,7 @@ export function useUploadPreferences() {
autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat
clientWebpQuality.value = stored.clientWebpQuality
lastTagIds.value = stored.lastTagIds
loaded.value = true
}
@@ -74,11 +81,20 @@ export function useUploadPreferences() {
compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value,
clientWebpQuality: clampQuality(clientWebpQuality.value)
clientWebpQuality: clampQuality(clientWebpQuality.value),
lastTagIds: lastTagIds.value
})
}
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
function resetToDefaults() {
compressEnabled.value = DEFAULTS.compressEnabled
autoCopyMarkdown.value = DEFAULTS.autoCopyMarkdown
copyFormat.value = DEFAULTS.copyFormat
clientWebpQuality.value = DEFAULTS.clientWebpQuality
savePreferences()
}
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality, lastTagIds], () => {
if (loaded.value) {
savePreferences()
}
@@ -89,8 +105,10 @@ export function useUploadPreferences() {
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
lastTagIds,
loaded,
loadPreferences,
savePreferences
savePreferences,
resetToDefaults
}
}