feat: activity logs, gallery storage filter, and upload rate limits
Add per-user activity logs with storage backend tracking, gallery storage filter, and IP/token upload rate limiting. Fix storage delete modal flash, pin local backend in filter order, and link settings version to GitHub releases.
This commit is contained in:
1 parent
901fefb3f5
commit
6fc054328d
34 files changed
+1357
-72
No files matched your search
@@ -23,6 +23,11 @@ const navItems = computed(() => {
|
||||
})
|
||||
|
||||
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
|
||||
{
|
||||
label: t('nav.logs'),
|
||||
icon: 'i-lucide-scroll-text',
|
||||
to: '/logs'
|
||||
},
|
||||
{
|
||||
label: t('nav.changePassword'),
|
||||
icon: 'i-lucide-key-round',
|
||||
|
||||
@@ -9,10 +9,12 @@ const props = withDefaults(defineProps<{
|
||||
deleting?: boolean
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
showStorage?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
showStorage: false,
|
||||
compact: false
|
||||
})
|
||||
|
||||
@@ -62,6 +64,10 @@ const copySuccessTitle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const storageIcon = computed(() =>
|
||||
props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
)
|
||||
|
||||
function toggleSelected(value: boolean | 'indeterminate') {
|
||||
emit('update:selected', value === true)
|
||||
}
|
||||
@@ -106,7 +112,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
</div>
|
||||
<div
|
||||
v-if="image.owner"
|
||||
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]"
|
||||
class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
|
||||
@@ -119,6 +125,22 @@ function toggleSelected(value: boolean | 'indeterminate') {
|
||||
<span class="truncate">{{ image.owner.username }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showStorage && image.storage"
|
||||
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
|
||||
:class="image.owner ? 'right-2' : 'left-2'"
|
||||
>
|
||||
<span
|
||||
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
|
||||
:title="t('image.storageBackend', { name: image.storage.name })"
|
||||
>
|
||||
<UIcon
|
||||
:name="storageIcon"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ image.storage.name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -7,12 +7,14 @@ const props = withDefaults(defineProps<{
|
||||
deletingKeys: Set<string>
|
||||
selectable?: boolean
|
||||
showKey?: boolean
|
||||
showStorage?: boolean
|
||||
emptyText?: string
|
||||
dense?: boolean
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
showStorage: false,
|
||||
dense: false,
|
||||
compact: false
|
||||
})
|
||||
@@ -48,6 +50,7 @@ function updateSelection(key: string, selected: boolean) {
|
||||
:deleting="deletingKeys.has(image.key)"
|
||||
:selectable="selectable"
|
||||
:show-key="showKey"
|
||||
:show-storage="showStorage"
|
||||
:compact="compact"
|
||||
@update:selected="updateSelection(image.key, $event)"
|
||||
@delete="emit('delete', image.key)"
|
||||
|
||||
@@ -136,6 +136,24 @@ function toggleOpen() {
|
||||
{{ t('nav.account') }}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<NuxtLink
|
||||
to="/logs"
|
||||
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
|
||||
@click="open = false"
|
||||
>
|
||||
<span class="flex items-center gap-2.5">
|
||||
<UIcon
|
||||
name="i-lucide-scroll-text"
|
||||
class="size-4 opacity-70"
|
||||
/>
|
||||
{{ t('nav.logs') }}
|
||||
</span>
|
||||
<UIcon
|
||||
name="i-lucide-chevron-right"
|
||||
class="size-4 text-muted"
|
||||
/>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="/account/password"
|
||||
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
|
||||
|
||||
@@ -216,7 +216,10 @@ function handleSubmit() {
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.bucket') }}</label>
|
||||
<label class="text-sm">
|
||||
{{ t('storage.bucket') }}
|
||||
<span class="text-error">*</span>
|
||||
</label>
|
||||
<UInput
|
||||
v-model="bucket"
|
||||
class="w-full font-mono text-sm"
|
||||
@@ -241,7 +244,13 @@ function handleSubmit() {
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.accessKey') }}</label>
|
||||
<label class="text-sm">
|
||||
{{ t('storage.accessKey') }}
|
||||
<span
|
||||
v-if="createMode"
|
||||
class="text-error"
|
||||
>*</span>
|
||||
</label>
|
||||
<UInput
|
||||
v-model="accessKeyId"
|
||||
:placeholder="backend?.secretsMasked.accessKeyId || ''"
|
||||
@@ -250,7 +259,13 @@ function handleSubmit() {
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm">{{ t('storage.secretKey') }}</label>
|
||||
<label class="text-sm">
|
||||
{{ t('storage.secretKey') }}
|
||||
<span
|
||||
v-if="createMode"
|
||||
class="text-error"
|
||||
>*</span>
|
||||
</label>
|
||||
<UInput
|
||||
v-model="secretAccessKey"
|
||||
type="password"
|
||||
|
||||
@@ -17,6 +17,8 @@ export function useImageList() {
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const activeFolder = ref('all')
|
||||
const activeStorageBackend = ref('all')
|
||||
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
|
||||
|
||||
function folderQueryParams(): Record<string, string> {
|
||||
const folder = activeFolder.value.trim()
|
||||
@@ -24,18 +26,53 @@ export function useImageList() {
|
||||
return { folder }
|
||||
}
|
||||
|
||||
function storageQueryParams(): Record<string, string> {
|
||||
const backendId = activeStorageBackend.value.trim()
|
||||
if (!backendId || backendId === 'all') return {}
|
||||
return { backendId }
|
||||
}
|
||||
|
||||
function listQueryParams(): Record<string, string> {
|
||||
return {
|
||||
...folderQueryParams(),
|
||||
...storageQueryParams()
|
||||
}
|
||||
}
|
||||
|
||||
function matchesActiveFolder(key: string) {
|
||||
const folder = activeFolder.value.trim()
|
||||
if (!folder || folder === 'all') return true
|
||||
return key.startsWith(`${folder}/`)
|
||||
}
|
||||
|
||||
function matchesActiveStorage(item: ImageItem) {
|
||||
const backendId = activeStorageBackend.value.trim()
|
||||
if (!backendId || backendId === 'all') return true
|
||||
return item.storage?.id === backendId
|
||||
}
|
||||
|
||||
async function loadStorageBackendOptions() {
|
||||
try {
|
||||
const data = await $fetch<{
|
||||
backends: Array<{ id: string, name: string, type: string }>
|
||||
}>('/api/storage-backends', {
|
||||
credentials: 'include'
|
||||
})
|
||||
storageBackendOptions.value = data.backends.map(backend => ({
|
||||
id: backend.id,
|
||||
name: backend.name
|
||||
}))
|
||||
} catch {
|
||||
storageBackendOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTotal() {
|
||||
loadingTotal.value = true
|
||||
try {
|
||||
const data = await $fetch<{ total: number }>('/api/images/count', {
|
||||
credentials: 'include',
|
||||
query: folderQueryParams()
|
||||
query: listQueryParams()
|
||||
})
|
||||
folderTotal.value = data.total
|
||||
} catch {
|
||||
@@ -53,7 +90,7 @@ export function useImageList() {
|
||||
query: {
|
||||
limit: PAGE_SIZE,
|
||||
page: targetPage,
|
||||
...folderQueryParams()
|
||||
...listQueryParams()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -81,7 +118,7 @@ export function useImageList() {
|
||||
q,
|
||||
limit: PAGE_SIZE,
|
||||
page: targetPage,
|
||||
...folderQueryParams()
|
||||
...listQueryParams()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -107,7 +144,7 @@ export function useImageList() {
|
||||
page.value = 1
|
||||
activeSearch.value = ''
|
||||
searchQuery.value = ''
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
|
||||
}
|
||||
|
||||
async function setActiveFolder(folder: string) {
|
||||
@@ -122,6 +159,18 @@ export function useImageList() {
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveStorageBackend(backendId: string) {
|
||||
const next = backendId.trim() || 'all'
|
||||
if (next === activeStorageBackend.value) return
|
||||
activeStorageBackend.value = next
|
||||
page.value = 1
|
||||
if (activeSearch.value) {
|
||||
await Promise.all([fetchSearch(1), fetchTotal()])
|
||||
} else {
|
||||
await Promise.all([fetchList(1), fetchTotal()])
|
||||
}
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
const q = searchQuery.value.trim()
|
||||
activeSearch.value = q
|
||||
@@ -143,7 +192,9 @@ export function useImageList() {
|
||||
}
|
||||
|
||||
function prependItems(newItems: ImageItem[]) {
|
||||
const matched = newItems.filter(item => matchesActiveFolder(item.key))
|
||||
const matched = newItems.filter(item =>
|
||||
matchesActiveFolder(item.key) && matchesActiveStorage(item)
|
||||
)
|
||||
if (!matched.length || page.value !== 1) return
|
||||
items.value = [...matched, ...items.value]
|
||||
total.value += matched.length
|
||||
@@ -164,16 +215,41 @@ export function useImageList() {
|
||||
}
|
||||
}
|
||||
|
||||
const activeStorageLabel = computed(() => {
|
||||
if (activeStorageBackend.value === 'all') return ''
|
||||
return storageBackendOptions.value.find(
|
||||
backend => backend.id === activeStorageBackend.value
|
||||
)?.name ?? activeStorageBackend.value
|
||||
})
|
||||
|
||||
const listSummary = computed(() => {
|
||||
const folder = activeFolder.value.trim() || 'all'
|
||||
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
|
||||
const storageLabel = activeStorageLabel.value
|
||||
if (loadingTotal.value && folderTotal.value === null) {
|
||||
return `${folderLabel} · ${t('stats.counting')}`
|
||||
return storageLabel
|
||||
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}`
|
||||
: `${folderLabel} · ${t('stats.counting')}`
|
||||
}
|
||||
if (activeSearch.value) {
|
||||
if (storageLabel) {
|
||||
return t('stats.summarySearchStorage', {
|
||||
folder: folderLabel,
|
||||
storage: storageLabel,
|
||||
q: activeSearch.value,
|
||||
total: total.value
|
||||
})
|
||||
}
|
||||
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
|
||||
}
|
||||
const count = folderTotal.value === null ? total.value : folderTotal.value
|
||||
if (storageLabel) {
|
||||
return t('stats.summaryTotalStorage', {
|
||||
folder: folderLabel,
|
||||
storage: storageLabel,
|
||||
total: count
|
||||
})
|
||||
}
|
||||
return t('stats.summaryTotal', { folder: folderLabel, total: count })
|
||||
})
|
||||
|
||||
@@ -184,6 +260,8 @@ export function useImageList() {
|
||||
total,
|
||||
loading,
|
||||
activeFolder,
|
||||
activeStorageBackend,
|
||||
storageBackendOptions,
|
||||
searchQuery,
|
||||
activeSearch,
|
||||
listSummary,
|
||||
@@ -193,6 +271,8 @@ export function useImageList() {
|
||||
refreshList,
|
||||
initializeList,
|
||||
setActiveFolder,
|
||||
setActiveStorageBackend,
|
||||
loadStorageBackendOptions,
|
||||
submitSearch,
|
||||
goToPage,
|
||||
prependItems,
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs'
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useI18n()
|
||||
const { formatFileSize } = useFileSize()
|
||||
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const actionFilter = ref<'all' | LogAction>('all')
|
||||
const sourceFilter = ref<'all' | LogSource>('all')
|
||||
const userFilter = ref<'all' | number>('all')
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const listData = ref<ActivityLogListResponse | null>(null)
|
||||
|
||||
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 }
|
||||
])
|
||||
|
||||
const sourceItems = computed(() => [
|
||||
{ label: t('logs.filterSourceAll'), value: 'all' as const },
|
||||
{ label: t('logs.sourceWeb'), value: 'web' as const },
|
||||
{ label: t('logs.sourceApi'), value: 'api' as const },
|
||||
{ label: t('logs.sourceTwikoo'), value: 'twikoo' as const }
|
||||
])
|
||||
|
||||
const userItems = computed(() => {
|
||||
const items: Array<{ label: string, value: 'all' | number }> = [
|
||||
{ label: t('logs.filterUserAll'), value: 'all' }
|
||||
]
|
||||
for (const user of listData.value?.users ?? []) {
|
||||
items.push({ label: user.username, value: user.id })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
function formatTime(value: string) {
|
||||
try {
|
||||
return new Date(value).toLocaleString(locale.value, { hour12: false })
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeShort(value: string) {
|
||||
try {
|
||||
return new Date(value).toLocaleString(locale.value, {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function folderFromKey(key: string) {
|
||||
const slash = key.indexOf('/')
|
||||
return slash > 0 ? key.slice(0, slash) : '(root)'
|
||||
}
|
||||
|
||||
function actionColor(action: LogAction) {
|
||||
return action === 'upload' ? 'success' : 'error'
|
||||
}
|
||||
|
||||
function actionIcon(action: LogAction) {
|
||||
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
|
||||
}
|
||||
|
||||
function sourceLabel(source: LogSource) {
|
||||
switch (source) {
|
||||
case 'web':
|
||||
return t('logs.sourceWeb')
|
||||
case 'api':
|
||||
return t('logs.sourceApi')
|
||||
case 'twikoo':
|
||||
return t('logs.sourceTwikoo')
|
||||
}
|
||||
}
|
||||
|
||||
function storageIcon(storage: ActivityLogStorage | null) {
|
||||
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
}
|
||||
|
||||
function imageUrl(key: string) {
|
||||
if (!import.meta.client) return ''
|
||||
return `${window.location.origin}/${key}`
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const query: Record<string, string | number> = {
|
||||
page: page.value,
|
||||
limit: pageSize
|
||||
}
|
||||
if (actionFilter.value !== 'all') {
|
||||
query.action = actionFilter.value
|
||||
}
|
||||
if (sourceFilter.value !== 'all') {
|
||||
query.source = sourceFilter.value
|
||||
}
|
||||
if (isAdmin.value && userFilter.value !== 'all') {
|
||||
query.userId = userFilter.value
|
||||
}
|
||||
if (activeSearch.value) {
|
||||
query.q = activeSearch.value
|
||||
}
|
||||
|
||||
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
|
||||
credentials: 'include',
|
||||
query
|
||||
})
|
||||
page.value = listData.value.page
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
toast.add({ title: t('logs.loadFailed'), color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
activeSearch.value = searchQuery.value.trim()
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
activeSearch.value = ''
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function handlePageChange(target: number) {
|
||||
page.value = target
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
await checkSession()
|
||||
if (!isAuthenticated.value) {
|
||||
await navigateTo('/')
|
||||
return
|
||||
}
|
||||
await loadLogs()
|
||||
})
|
||||
|
||||
watch(isAuthenticated, async (authed, prev) => {
|
||||
if (authed && prev === false) {
|
||||
await loadLogs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<div
|
||||
v-if="isChecking"
|
||||
class="flex min-h-screen items-center justify-center"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3 text-muted">
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-8 animate-spin"
|
||||
/>
|
||||
<p class="text-sm">
|
||||
{{ t('common.loadingSession') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminLoginGate v-else-if="!isAuthenticated" />
|
||||
|
||||
<AppShell v-else>
|
||||
<section class="min-w-0 overflow-x-hidden rounded-2xl border border-default bg-elevated shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-4 py-4 sm:px-6">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon
|
||||
name="i-lucide-scroll-text"
|
||||
class="mt-0.5 size-5 shrink-0 text-primary"
|
||||
/>
|
||||
<div>
|
||||
<h1 class="text-base font-semibold">
|
||||
{{ t('logs.pageTitle') }}
|
||||
</h1>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:aria-label="t('common.refresh')"
|
||||
:loading="loading"
|
||||
@click="loadLogs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="border-b border-default px-4 py-3 sm:px-6"
|
||||
@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 min-w-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
<USelect
|
||||
v-if="isAdmin"
|
||||
v-model="userFilter"
|
||||
:items="userItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-full sm:w-36"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
<USelect
|
||||
v-model="actionFilter"
|
||||
:items="actionItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
<USelect
|
||||
v-model="sourceFilter"
|
||||
:items="sourceItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('logs.searchPlaceholder')"
|
||||
class="min-w-0 flex-1 sm:w-40 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>
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="activeSearch"
|
||||
type="button"
|
||||
icon="i-lucide-x"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
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">
|
||||
<UBadge
|
||||
:color="actionColor(row.action)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="mr-1 size-3"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ sourceLabel(row.source) }}
|
||||
</UBadge>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="hidden overflow-x-auto sm:block">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium sm:px-6">
|
||||
{{ t('logs.colTime') }}
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-3 font-medium"
|
||||
>
|
||||
{{ t('logs.colUser') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
{{ t('logs.colAction') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
{{ t('logs.colSource') }}
|
||||
</th>
|
||||
<th class="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="hidden px-4 py-3 font-medium lg:table-cell">
|
||||
{{ t('logs.colFolder') }}
|
||||
</th>
|
||||
<th class="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
|
||||
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-6">
|
||||
{{ formatTime(row.createdAt) }}
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-3 text-xs"
|
||||
>
|
||||
{{ row.username ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge
|
||||
:color="actionColor(row.action)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="mr-1 size-3"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ sourceLabel(row.source) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
v-if="row.storage"
|
||||
class="inline-flex max-w-[8rem] items-center gap-1 truncate"
|
||||
:title="row.storage.name"
|
||||
>
|
||||
<UIcon
|
||||
:name="storageIcon(row.storage)"
|
||||
class="size-3.5 shrink-0 opacity-70"
|
||||
/>
|
||||
<span class="truncate">{{ row.storage.name }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td
|
||||
class="hidden max-w-[12rem] truncate px-4 py-3 md:table-cell"
|
||||
:title="row.originalName"
|
||||
>
|
||||
{{ row.originalName }}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 font-mono text-xs text-muted lg:table-cell">
|
||||
{{ folderFromKey(row.key) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
||||
{{ formatFileSize(row.size) }}
|
||||
</td>
|
||||
<td class="hidden px-4 py-3 xl:table-cell">
|
||||
<div class="flex max-w-[16rem] items-center gap-1">
|
||||
<a
|
||||
v-if="row.action === 'upload'"
|
||||
:href="imageUrl(row.key)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate font-mono text-xs text-primary hover:underline"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
class="truncate font-mono text-xs text-dimmed"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</span>
|
||||
<CopyButton
|
||||
icon="i-lucide-copy"
|
||||
icon-only
|
||||
:label="t('common.copy')"
|
||||
:value="row.key"
|
||||
:success-title="t('copy.copiedUrl')"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
</AppShell>
|
||||
</div>
|
||||
</template>
|
||||
@@ -333,7 +333,20 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span>
|
||||
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource), version: settings.appVersion }) }}
|
||||
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
|
||||
<a
|
||||
href="https://github.com/O96u/PicHost/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-0.5 text-primary underline-offset-2 hover:underline"
|
||||
:title="t('settings.viewReleases')"
|
||||
>
|
||||
v{{ settings.appVersion }}
|
||||
<UIcon
|
||||
name="i-lucide-external-link"
|
||||
class="size-3 opacity-70"
|
||||
/>
|
||||
</a>
|
||||
<template v-if="hasServerChanges">
|
||||
· <span class="text-warning">{{ t('settings.unsavedChanges') }}</span>
|
||||
</template>
|
||||
|
||||
+39
-3
@@ -41,7 +41,11 @@ const {
|
||||
setActiveFolder,
|
||||
submitSearch,
|
||||
goToPage,
|
||||
removeItems
|
||||
removeItems,
|
||||
activeStorageBackend,
|
||||
storageBackendOptions,
|
||||
setActiveStorageBackend,
|
||||
loadStorageBackendOptions
|
||||
} = useImageList()
|
||||
|
||||
const { formatFileSize } = useFileSize()
|
||||
@@ -60,6 +64,7 @@ const deleteTargetKeys = ref<string[]>([])
|
||||
const batchDeleting = ref(false)
|
||||
const showScrollTop = ref(false)
|
||||
const currentFolder = ref('all')
|
||||
const currentStorage = ref('all')
|
||||
const folderOptions = ref<string[]>(['images'])
|
||||
|
||||
const folderSelectItems = computed(() => [
|
||||
@@ -67,6 +72,14 @@ const folderSelectItems = computed(() => [
|
||||
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
|
||||
])
|
||||
|
||||
const storageSelectItems = computed(() => [
|
||||
{ label: t('stats.filterStorageAll'), value: 'all' },
|
||||
...storageBackendOptions.value.map(backend => ({
|
||||
label: backend.name,
|
||||
value: backend.id
|
||||
}))
|
||||
])
|
||||
|
||||
const selectedCount = computed(() => selectedKeys.value.size)
|
||||
|
||||
const FOLDER_CHART_COLORS = [
|
||||
@@ -165,7 +178,8 @@ async function refreshGallery() {
|
||||
if (!isAuthenticated.value) return
|
||||
selectedKeys.value = new Set()
|
||||
try {
|
||||
await Promise.all([refreshList(), loadFolders()])
|
||||
await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
|
||||
currentStorage.value = activeStorageBackend.value
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
@@ -188,8 +202,10 @@ async function refreshAll() {
|
||||
await Promise.all([
|
||||
fetchStats(),
|
||||
reloadGallery(),
|
||||
loadFolders()
|
||||
loadFolders(),
|
||||
loadStorageBackendOptions()
|
||||
])
|
||||
currentStorage.value = activeStorageBackend.value
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
if (isAuthenticated.value) {
|
||||
@@ -211,6 +227,17 @@ async function handleFolderChange(folder: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStorageChange(backendId: string) {
|
||||
if (!isAuthenticated.value) return
|
||||
currentStorage.value = backendId
|
||||
selectedKeys.value = new Set()
|
||||
try {
|
||||
await setActiveStorageBackend(backendId)
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!isAuthenticated.value) return
|
||||
selectedKeys.value = new Set()
|
||||
@@ -481,6 +508,14 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
:aria-label="t('stats.folder')"
|
||||
@update:model-value="handleFolderChange"
|
||||
/>
|
||||
<USelect
|
||||
v-model="currentStorage"
|
||||
:items="storageSelectItems"
|
||||
class="w-[6.5rem] shrink-0 sm:w-36"
|
||||
size="sm"
|
||||
:aria-label="t('stats.storage')"
|
||||
@update:model-value="handleStorageChange"
|
||||
/>
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
@@ -542,6 +577,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<ImageGrid
|
||||
v-else
|
||||
:show-key="false"
|
||||
show-storage
|
||||
:items="items"
|
||||
:selected-keys="selectedKeys"
|
||||
:deleting-keys="deletingKeys"
|
||||
|
||||
@@ -18,6 +18,7 @@ const savingForm = ref(false)
|
||||
|
||||
const deleteOpen = ref(false)
|
||||
const deleteTarget = ref<StorageBackendItem | null>(null)
|
||||
const deleteConfirmName = ref('')
|
||||
const deleting = ref(false)
|
||||
|
||||
async function loadStorage() {
|
||||
@@ -165,14 +166,16 @@ function confirmDelete(backend: StorageBackendItem) {
|
||||
return
|
||||
}
|
||||
deleteTarget.value = backend
|
||||
deleteConfirmName.value = backend.name
|
||||
deleteOpen.value = true
|
||||
}
|
||||
|
||||
async function executeDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
const target = deleteTarget.value
|
||||
if (!target) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await $fetch(`/api/admin/storage/${deleteTarget.value.id}`, {
|
||||
await $fetch(`/api/admin/storage/${target.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
})
|
||||
@@ -326,7 +329,7 @@ watch(isAuthenticated, async (authed) => {
|
||||
{{ t('storage.deleteConfirmTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
{{ t('storage.deleteConfirmBody', { name: deleteTarget?.name ?? '' }) }}
|
||||
{{ t('storage.deleteConfirmBody', { name: deleteConfirmName }) }}
|
||||
</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<UButton
|
||||
|
||||
@@ -36,6 +36,12 @@ export interface ImageItem {
|
||||
userId: number | null
|
||||
username: string
|
||||
}
|
||||
/** 图片所在存储后端(统计/管理列表) */
|
||||
storage?: {
|
||||
id: string
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
}
|
||||
}
|
||||
|
||||
export interface UploadErrorItem {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export type LogAction = 'upload' | 'delete'
|
||||
export type LogSource = 'web' | 'api' | 'twikoo'
|
||||
|
||||
export interface ActivityLogUser {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface ActivityLogStorage {
|
||||
id: string
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
}
|
||||
|
||||
export interface ActivityLogItem {
|
||||
id: number
|
||||
action: LogAction
|
||||
key: string
|
||||
originalName: string
|
||||
size: number
|
||||
contentType: string
|
||||
source: LogSource
|
||||
userId: number | null
|
||||
username: string | null
|
||||
storage: ActivityLogStorage | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ActivityLogSummary {
|
||||
total: number
|
||||
upload: number
|
||||
delete: number
|
||||
}
|
||||
|
||||
export interface ActivityLogListResponse {
|
||||
items: ActivityLogItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
summary: ActivityLogSummary
|
||||
users?: ActivityLogUser[]
|
||||
}
|
||||
Reference in new issue
Block a user