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

+5
View File
@@ -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',
+23 -1
View File
@@ -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
+3
View File
@@ -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)"
+18
View File
@@ -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"
+18 -3
View File
@@ -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"
+86 -6
View File
@@ -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,
+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
+6
View File
@@ -36,6 +36,12 @@ export interface ImageItem {
userId: number | null
username: string
}
/** 图片所在存储后端(统计/管理列表) */
storage?: {
id: string
name: string
type: 'local' | 's3'
}
}
export interface UploadErrorItem {
+43
View File
@@ -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[]
}