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:
O96u committed 2026-08-26 17:09:38 +08:00
1 parent 901fefb3f5
commit 6fc054328d
34 files changed
+1357 -72

No files matched your search

+587
View File
@@ -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>
+14 -1
View File
@@ -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
View File
@@ -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"
+6 -3
View File
@@ -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