release: v1.2.0 — unified images/ storage and legacy migration
ci / ci (22, ubuntu-latest) (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
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (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
GitHub Release / release (push) Canceled after 0s
Consolidate local images under data/images/, add docker migrate CLI, startup index sync with orphan cleanup, and remove upload folder UI/API.
This commit is contained in:
1 parent
3dee0437e6
commit
469d3be735
50 files changed
+1273
-956
No files matched your search
+13
-60
@@ -52,34 +52,13 @@ const authHeader = computed(() =>
|
||||
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
|
||||
|
||||
const apiDocs = computed<ApiDocItem[]>(() => {
|
||||
const uploadDescription = isAdmin.value
|
||||
? t('api.docs.upload.descAdmin')
|
||||
: t('api.docs.upload.descUser')
|
||||
const uploadCurl = isAdmin.value
|
||||
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||
${authHeaderFlag.value} \\
|
||||
-F "image=@./demo.png"
|
||||
|
||||
${t('api.docs.upload.curlCustomFolder')}
|
||||
curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||
${authHeaderFlag.value} \\
|
||||
-F "folder=blog" \\
|
||||
-F "image=@./demo.png"`
|
||||
: `curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||
const uploadDescription = t('api.docs.upload.desc')
|
||||
const uploadCurl = `curl -X POST "${baseUrl.value}/api/images/upload" \\
|
||||
${authHeaderFlag.value} \\
|
||||
-F "image=@./demo.png"`
|
||||
|
||||
const listDescription = isAdmin.value
|
||||
? t('api.docs.list.descAdmin')
|
||||
: t('api.docs.list.descUser')
|
||||
const listCurl = isAdmin.value
|
||||
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
|
||||
${authHeaderFlag.value}
|
||||
|
||||
${t('api.docs.list.curlFolderFilter')}
|
||||
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
|
||||
${authHeaderFlag.value}`
|
||||
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
|
||||
const listDescription = t('api.docs.list.desc')
|
||||
const listCurl = `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
|
||||
${authHeaderFlag.value}`
|
||||
|
||||
return [
|
||||
@@ -156,10 +135,12 @@ function methodBadgeColor(method: ApiDocItem['method']) {
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
return 'info' as const
|
||||
case 'POST':
|
||||
return 'warning' as const
|
||||
case 'DELETE':
|
||||
return 'error' as const
|
||||
default:
|
||||
return 'primary' as const
|
||||
return 'neutral' as const
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,15 +200,6 @@ async function confirmRegenerate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurl(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.add({ title: t('copy.curl'), color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: t('copy.failed'), color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
function closeConfirmRegenerate() {
|
||||
confirmRegenerateOpen.value = false
|
||||
}
|
||||
@@ -404,18 +376,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<p class="mb-2 text-xs font-medium text-muted">
|
||||
{{ t('api.curlExample') }}
|
||||
</p>
|
||||
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
:label="t('common.copy')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
|
||||
@click="copyCurl(doc.curl)"
|
||||
/>
|
||||
<pre class="max-h-80 overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ doc.curl }}</code></pre>
|
||||
</div>
|
||||
<ApiCurlBlock :code="doc.curl" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -431,7 +392,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<div class="min-w-0 space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UBadge
|
||||
color="neutral"
|
||||
:color="methodBadgeColor(twikooDoc.method)"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
@@ -454,18 +415,10 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<p class="mb-2 text-xs font-medium text-muted">
|
||||
{{ t('api.curlExample') }}
|
||||
</p>
|
||||
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
|
||||
<UButton
|
||||
icon="i-lucide-copy"
|
||||
:label="t('common.copy')"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
|
||||
@click="copyCurl(twikooDoc.curl)"
|
||||
/>
|
||||
<pre class="overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ twikooDoc.curl }}</code></pre>
|
||||
</div>
|
||||
<ApiCurlBlock
|
||||
:code="twikooDoc.curl"
|
||||
:scrollable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+2
-29
@@ -4,17 +4,14 @@ import type { ImageItem } from '~/types/image'
|
||||
const { uploading, progressItems, uploadFiles } = useImageUpload()
|
||||
|
||||
const {
|
||||
defaultFolder,
|
||||
autoCopyMarkdown,
|
||||
copyFormat,
|
||||
loadPreferences
|
||||
} = useUploadPreferences()
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
|
||||
= useAuth()
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const folderOptions = ref<string[]>(['images'])
|
||||
const sessionItems = ref<ImageItem[]>([])
|
||||
const deletingKeys = ref<Set<string>>(new Set())
|
||||
const emptySelection = ref(new Set<string>())
|
||||
@@ -30,22 +27,9 @@ const showProgress = computed(
|
||||
)
|
||||
)
|
||||
|
||||
async function loadFolders() {
|
||||
const data = await $fetch<{ folders: string[] }>('/api/folders', {
|
||||
credentials: 'include'
|
||||
})
|
||||
folderOptions.value = [...data.folders]
|
||||
if (!folderOptions.value.includes(defaultFolder.value)) {
|
||||
defaultFolder.value = folderOptions.value[0] ?? 'images'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
try {
|
||||
loadPreferences()
|
||||
if (isAdmin.value) {
|
||||
await loadFolders()
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
@@ -112,20 +96,9 @@ async function handleUpload(files: File[]) {
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadTarget = isAdmin.value
|
||||
? (defaultFolder.value.trim() || 'images')
|
||||
: 'images'
|
||||
const result = await uploadFiles(files, uploadTarget, {
|
||||
includeFolder: isAdmin.value
|
||||
})
|
||||
const result = await uploadFiles(files)
|
||||
if (result?.items.length) {
|
||||
sessionItems.value = [...result.items, ...sessionItems.value]
|
||||
if (isAdmin.value) {
|
||||
const folder = uploadTarget
|
||||
if (!folderOptions.value.includes(folder)) {
|
||||
folderOptions.value = [...folderOptions.value, folder]
|
||||
}
|
||||
}
|
||||
if (autoCopyMarkdown.value && result.items[0]) {
|
||||
const first = result.items[0]
|
||||
const text
|
||||
|
||||
+1
-4
@@ -38,8 +38,7 @@ const actionItems = computed(() => [
|
||||
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 }
|
||||
{ label: t('logs.sourceApi'), value: 'api' as const }
|
||||
])
|
||||
|
||||
const userItems = computed(() => {
|
||||
@@ -93,8 +92,6 @@ function sourceLabel(source: LogSource) {
|
||||
return t('logs.sourceWeb')
|
||||
case 'api':
|
||||
return t('logs.sourceApi')
|
||||
case 'twikoo':
|
||||
return t('logs.sourceTwikoo')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-135
@@ -27,12 +27,10 @@ interface SettingsResponse {
|
||||
}
|
||||
|
||||
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
|
||||
const { defaultFolder, loadPreferences } = useUploadPreferences()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const settings = ref<SettingsResponse | null>(null)
|
||||
const folderOptions = ref<string[]>(['images'])
|
||||
const savingServer = ref(false)
|
||||
|
||||
const refererDraft = ref('')
|
||||
@@ -42,7 +40,6 @@ const hideFolderInUrlDraft = ref(false)
|
||||
const storageUseDatePathDraft = ref(true)
|
||||
const allowRegistrationDraft = ref(false)
|
||||
const domainSeparationDraft = ref(false)
|
||||
const hideFolderConfirmOpen = ref(false)
|
||||
|
||||
interface ReleaseCheckResponse {
|
||||
currentVersion: string
|
||||
@@ -53,10 +50,6 @@ interface ReleaseCheckResponse {
|
||||
|
||||
const releaseCheck = ref<ReleaseCheckResponse | null>(null)
|
||||
|
||||
const folderSelectItems = computed(() =>
|
||||
folderOptions.value.map(folder => ({ label: folder, value: folder }))
|
||||
)
|
||||
|
||||
const hasServerChanges = computed(() => {
|
||||
if (!settings.value) return false
|
||||
return refererDraft.value !== settings.value.allowedRefererHosts
|
||||
@@ -131,28 +124,6 @@ function handlePatchError(error: unknown, fallback: string) {
|
||||
toast.add({ title: fallback, color: 'error' })
|
||||
}
|
||||
|
||||
function onHideFolderDraftChange(next: boolean | 'indeterminate') {
|
||||
if (next !== true) {
|
||||
hideFolderInUrlDraft.value = false
|
||||
return
|
||||
}
|
||||
if (folderOptions.value.length > 1) {
|
||||
hideFolderConfirmOpen.value = true
|
||||
return
|
||||
}
|
||||
hideFolderInUrlDraft.value = true
|
||||
}
|
||||
|
||||
function confirmHideFolderEnable() {
|
||||
hideFolderInUrlDraft.value = true
|
||||
hideFolderConfirmOpen.value = false
|
||||
}
|
||||
|
||||
function cancelHideFolderEnable() {
|
||||
hideFolderInUrlDraft.value = false
|
||||
hideFolderConfirmOpen.value = false
|
||||
}
|
||||
|
||||
function onDomainSeparationDraftChange(next: boolean | 'indeterminate') {
|
||||
const enabled = next === true
|
||||
domainSeparationDraft.value = enabled
|
||||
@@ -186,16 +157,6 @@ async function saveServerSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
const data = await $fetch<{ folders: string[] }>('/api/folders', {
|
||||
credentials: 'include'
|
||||
})
|
||||
folderOptions.value = [...data.folders]
|
||||
if (!folderOptions.value.includes(defaultFolder.value)) {
|
||||
defaultFolder.value = folderOptions.value[0] ?? 'images'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const data = await $fetch<SettingsResponse>('/api/settings', {
|
||||
@@ -225,8 +186,7 @@ async function loadPage() {
|
||||
await navigateTo('/')
|
||||
return
|
||||
}
|
||||
loadPreferences()
|
||||
await Promise.all([loadSettings(), loadFolders(), checkLatestRelease()])
|
||||
await Promise.all([loadSettings(), checkLatestRelease()])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -354,7 +314,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
:model-value="hideFolderInUrlDraft"
|
||||
class="mt-0.5 shrink-0"
|
||||
:disabled="settings.hideFolderInUrlSource === 'env'"
|
||||
@update:model-value="onHideFolderDraftChange"
|
||||
@update:model-value="(v) => hideFolderInUrlDraft = v === true"
|
||||
/>
|
||||
<span class="min-w-0">
|
||||
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
@@ -375,50 +335,11 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-side-panel flex h-full min-w-0 flex-col rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
|
||||
<div class="shrink-0 space-y-1.5">
|
||||
<label class="text-sm font-medium">{{ t('settings.defaultFolder') }}</label>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.defaultFolderHint') }}
|
||||
</p>
|
||||
<USelect
|
||||
v-model="defaultFolder"
|
||||
:items="folderSelectItems"
|
||||
class="settings-folder-select w-40 max-w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex h-full min-w-0 flex-col space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('settings.domainSettings') }}
|
||||
</h3>
|
||||
|
||||
<div class="settings-referer-section mt-4 space-y-1.5 border-t border-default/60 pt-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="text-sm font-medium">{{ t('settings.accessControl') }}</label>
|
||||
<UBadge
|
||||
:color="sourceBadge(settings.refererSource).color"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ sourceBadge(settings.refererSource).label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.refererHint') }}
|
||||
</p>
|
||||
<UTextarea
|
||||
v-model="refererDraft"
|
||||
:placeholder="t('settings.refererPlaceholder')"
|
||||
:rows="4"
|
||||
:autoresize="false"
|
||||
class="settings-referer-field flex min-h-0 w-full max-w-xl flex-1 flex-col font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('settings.domainSettings') }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
|
||||
<UCheckbox
|
||||
:model-value="domainSeparationDraft"
|
||||
@@ -469,7 +390,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<UInput
|
||||
v-model="siteBaseUrlDraft"
|
||||
:placeholder="t('settings.siteBaseUrlPlaceholder')"
|
||||
class="w-full max-w-xl font-mono text-sm"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
<p
|
||||
v-if="settings.siteBaseUrl"
|
||||
@@ -497,7 +418,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<UInput
|
||||
v-model="imageBaseUrlDraft"
|
||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||
class="w-full max-w-xl font-mono text-sm"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
<p
|
||||
v-if="settings.imageBaseUrl"
|
||||
@@ -529,7 +450,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
<UInput
|
||||
v-model="imageBaseUrlDraft"
|
||||
:placeholder="t('settings.imageBaseUrlPlaceholder')"
|
||||
class="w-full max-w-xl font-mono text-sm"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
<p
|
||||
v-if="settings.imageBaseUrl"
|
||||
@@ -541,6 +462,31 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-referer-section space-y-1.5 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ t('settings.accessControl') }}
|
||||
</h3>
|
||||
<UBadge
|
||||
:color="sourceBadge(settings.refererSource).color"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ sourceBadge(settings.refererSource).label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.refererHint') }}
|
||||
</p>
|
||||
<UTextarea
|
||||
v-model="refererDraft"
|
||||
:placeholder="t('settings.refererPlaceholder')"
|
||||
:rows="4"
|
||||
:autoresize="false"
|
||||
class="settings-referer-field w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -618,57 +564,11 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
</div>
|
||||
</section>
|
||||
</AppShell>
|
||||
|
||||
<UModal
|
||||
:open="hideFolderConfirmOpen"
|
||||
:title="t('settings.hideFolderConfirmTitle')"
|
||||
:description="t('settings.hideFolderConfirmDesc')"
|
||||
@update:open="(open) => { if (!open) cancelHideFolderEnable() }"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
:label="t('common.cancel')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="cancelHideFolderEnable"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('settings.hideFolderConfirmEnable')"
|
||||
color="warning"
|
||||
@click="confirmHideFolderEnable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-side-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-folder-select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-folder-select :deep(button),
|
||||
.settings-folder-select :deep([role='combobox']) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.settings-referer-section {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-referer-field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -680,7 +580,6 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
}
|
||||
|
||||
.settings-referer-field :deep(textarea) {
|
||||
height: 100%;
|
||||
min-height: 5.5rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
+55
-123
@@ -12,17 +12,7 @@ interface StatsResponse {
|
||||
bySource: {
|
||||
web: number
|
||||
api: number
|
||||
twikoo: number
|
||||
}
|
||||
byFolder: Array<{
|
||||
folder: string
|
||||
count: number
|
||||
bytes: number
|
||||
}>
|
||||
byFolderUploads: Array<{
|
||||
folder: string
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -38,7 +28,6 @@ const {
|
||||
fetchList,
|
||||
fetchSearch,
|
||||
fetchTotal,
|
||||
setActiveFolder,
|
||||
submitSearch,
|
||||
goToPage,
|
||||
removeItems,
|
||||
@@ -63,14 +52,7 @@ const deleteModalOpen = ref(false)
|
||||
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(() => [
|
||||
{ label: t('stats.allFolders'), value: 'all' },
|
||||
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
|
||||
])
|
||||
|
||||
const storageSelectItems = computed(() => [
|
||||
{ label: t('stats.filterStorageAll'), value: 'all' },
|
||||
@@ -82,41 +64,29 @@ const storageSelectItems = computed(() => [
|
||||
|
||||
const selectedCount = computed(() => selectedKeys.value.size)
|
||||
|
||||
const FOLDER_CHART_COLORS = [
|
||||
const SOURCE_CHART_COLORS = [
|
||||
'var(--ui-primary)',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4'
|
||||
'#10b981'
|
||||
]
|
||||
|
||||
const folderUploadItems = computed(() =>
|
||||
(stats.value?.byFolderUploads ?? []).map((item, index) => ({
|
||||
key: item.folder,
|
||||
label: item.folder,
|
||||
count: item.count,
|
||||
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]!
|
||||
}))
|
||||
const sourceUploadItems = computed(() => {
|
||||
const bySource = stats.value?.bySource
|
||||
if (!bySource) return []
|
||||
return [
|
||||
{ key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_CHART_COLORS[0]! },
|
||||
{ key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_CHART_COLORS[1]! }
|
||||
]
|
||||
})
|
||||
|
||||
const sourceUploadTotal = computed(() =>
|
||||
sourceUploadItems.value.reduce((sum, item) => sum + item.count, 0)
|
||||
)
|
||||
|
||||
const donutSegments = computed(() =>
|
||||
folderUploadItems.value.map(item => ({
|
||||
label: item.label,
|
||||
value: item.count,
|
||||
color: item.color
|
||||
}))
|
||||
)
|
||||
|
||||
const folderStats = computed(() => stats.value?.byFolder ?? [])
|
||||
|
||||
const folderMaxBytes = computed(() =>
|
||||
Math.max(...folderStats.value.map(item => item.bytes), 1)
|
||||
)
|
||||
|
||||
const uploadChartTotal = computed(() =>
|
||||
folderUploadItems.value.reduce((sum, item) => sum + item.count, 0)
|
||||
)
|
||||
function sourcePercent(count: number) {
|
||||
const total = sourceUploadTotal.value
|
||||
if (!total) return 0
|
||||
return Math.round((count / total) * 100)
|
||||
}
|
||||
|
||||
const statCards = computed(() => {
|
||||
const s = stats.value
|
||||
@@ -143,17 +113,6 @@ function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function folderPercent(bytes: number) {
|
||||
return Math.round((bytes / folderMaxBytes.value) * 100)
|
||||
}
|
||||
|
||||
async function loadFolders() {
|
||||
const data = await $fetch<{ folders: string[] }>('/api/folders', {
|
||||
credentials: 'include'
|
||||
})
|
||||
folderOptions.value = [...data.folders]
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
stats.value = await $fetch<StatsResponse>('/api/stats', {
|
||||
credentials: 'include'
|
||||
@@ -178,7 +137,7 @@ async function refreshGallery() {
|
||||
if (!isAuthenticated.value) return
|
||||
selectedKeys.value = new Set()
|
||||
try {
|
||||
await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
|
||||
await Promise.all([refreshList(), loadStorageBackendOptions()])
|
||||
currentStorage.value = activeStorageBackend.value
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
@@ -202,7 +161,6 @@ async function refreshAll() {
|
||||
await Promise.all([
|
||||
fetchStats(),
|
||||
reloadGallery(),
|
||||
loadFolders(),
|
||||
loadStorageBackendOptions()
|
||||
])
|
||||
currentStorage.value = activeStorageBackend.value
|
||||
@@ -216,17 +174,6 @@ async function refreshAll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFolderChange(folder: string) {
|
||||
if (!isAuthenticated.value) return
|
||||
currentFolder.value = folder
|
||||
selectedKeys.value = new Set()
|
||||
try {
|
||||
await setActiveFolder(folder)
|
||||
} catch (error: unknown) {
|
||||
handleAuthError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStorageChange(backendId: string) {
|
||||
if (!isAuthenticated.value) return
|
||||
currentStorage.value = backendId
|
||||
@@ -406,67 +353,60 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div
|
||||
v-for="item in statCards"
|
||||
:key="item.label"
|
||||
class="rounded-xl border border-default bg-elevated p-4"
|
||||
>
|
||||
<p class="text-xs text-muted">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<h2 class="mb-3 text-sm font-medium text-muted">
|
||||
{{ t('stats.folderDistribution') }}
|
||||
</h2>
|
||||
<section class="grid gap-3 lg:grid-cols-3 lg:items-stretch">
|
||||
<div class="grid grid-cols-2 gap-3 lg:col-span-2 lg:grid-cols-4">
|
||||
<div
|
||||
v-if="!uploadChartTotal"
|
||||
class="py-6 text-center text-xs text-muted"
|
||||
v-for="item in statCards"
|
||||
:key="item.label"
|
||||
class="rounded-xl border border-default bg-elevated p-4"
|
||||
>
|
||||
{{ t('stats.noData') }}
|
||||
<p class="text-xs text-muted">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-semibold tabular-nums">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
<StatsDonut
|
||||
v-else
|
||||
:segments="donutSegments"
|
||||
:size="88"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-default bg-elevated p-4">
|
||||
<h2 class="mb-3 text-sm font-medium text-muted">
|
||||
{{ t('stats.folderUsage') }}
|
||||
<div class="flex h-full min-h-0 flex-col rounded-xl border border-default bg-elevated p-4 lg:col-span-1">
|
||||
<h2 class="shrink-0 text-sm font-medium text-muted">
|
||||
{{ t('stats.sourceDistribution') }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="!folderStats.length"
|
||||
class="py-6 text-center text-xs text-muted"
|
||||
v-if="!sourceUploadTotal"
|
||||
class="flex flex-1 items-center justify-center text-center text-xs text-muted"
|
||||
>
|
||||
{{ t('stats.noData') }}
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
class="space-y-2.5"
|
||||
class="mt-4 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<li
|
||||
v-for="item in folderStats"
|
||||
:key="item.folder"
|
||||
v-for="item in sourceUploadItems"
|
||||
:key="item.key"
|
||||
class="flex flex-1 flex-col justify-center gap-2 py-1"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="font-medium">{{ item.folder }}</span>
|
||||
<span class="tabular-nums text-muted">
|
||||
{{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }}
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:style="{ background: item.color }"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 tabular-nums text-muted">
|
||||
{{ item.count }} ({{ sourcePercent(item.count) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
|
||||
<div class="h-2 rounded-full bg-muted/40">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary/70"
|
||||
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }"
|
||||
class="h-full rounded-full transition-[width]"
|
||||
:style="{
|
||||
width: `${sourcePercent(item.count)}%`,
|
||||
backgroundColor: item.color
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -500,14 +440,6 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
>
|
||||
{{ t('stats.selected', { n: selectedCount }) }}
|
||||
</UBadge>
|
||||
<USelect
|
||||
v-model="currentFolder"
|
||||
:items="folderSelectItems"
|
||||
class="w-[5.5rem] shrink-0 sm:w-32"
|
||||
size="sm"
|
||||
:aria-label="t('stats.folder')"
|
||||
@update:model-value="handleFolderChange"
|
||||
/>
|
||||
<USelect
|
||||
v-model="currentStorage"
|
||||
:items="storageSelectItems"
|
||||
|
||||
Reference in new issue
Block a user