release: v1.4.0 — 标签系统、操作日志扩展与文档截图更新
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
- 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标 - 操作日志记录登录、删除、设置与标签操作 - API 标签/打标接口;README 与文档站截图同步
This commit is contained in:
1 parent
f17d79b236
commit
737235acff
110 files changed
+5881
-778
No files matched your search
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="divide-y divide-default overflow-hidden rounded-lg border border-default">
|
||||
<div class="divide-y divide-default">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs'
|
||||
import type { ActivityLogListResponse, LogAction, LogSource, LogStatus } from '~/types/logs'
|
||||
|
||||
const { isAuthenticated, handleAuthError, isAdmin } = useAuth()
|
||||
const toast = useToast()
|
||||
@@ -8,36 +8,30 @@ const { formatFileSize } = useFileSize()
|
||||
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const actionFilter = ref<'all' | LogAction>('all')
|
||||
const sourceFilter = ref<'all' | LogSource>('all')
|
||||
const userFilter = ref<'all' | number>('all')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const searchQuery = ref('')
|
||||
const activeSearch = ref('')
|
||||
const listData = ref<ActivityLogListResponse | null>(null)
|
||||
const imageLinkBase = ref('')
|
||||
const hideFolderInUrl = ref(false)
|
||||
|
||||
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 }
|
||||
{ label: t('logs.actionDelete'), value: 'delete' as const },
|
||||
{ label: t('logs.actionLogin'), value: 'login' as const },
|
||||
{ label: t('logs.actionEdit'), value: 'edit' as const },
|
||||
{ label: t('logs.actionSettings'), value: 'settings' as const },
|
||||
{ label: t('logs.actionTags'), value: 'tags' as const }
|
||||
])
|
||||
|
||||
const sourceItems = computed(() => [
|
||||
{ label: t('logs.filterSourceAll'), value: 'all' as const },
|
||||
{ label: t('logs.sourceWeb'), value: 'web' as const },
|
||||
{ label: t('logs.sourceAdmin'), value: 'admin' as const },
|
||||
{ label: t('logs.sourceApi'), value: 'api' as const }
|
||||
])
|
||||
|
||||
@@ -73,66 +67,104 @@ function formatTimeShort(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(action: LogAction) {
|
||||
const labels: Record<LogAction, string> = {
|
||||
upload: t('logs.actionUpload'),
|
||||
delete: t('logs.actionDelete'),
|
||||
login: t('logs.actionLogin'),
|
||||
edit: t('logs.actionEdit'),
|
||||
settings: t('logs.actionSettings'),
|
||||
tags: t('logs.actionTags')
|
||||
}
|
||||
return labels[action]
|
||||
}
|
||||
|
||||
function actionBadgeClass(action: LogAction) {
|
||||
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
|
||||
return action === 'upload'
|
||||
? `${base} bg-success/10 text-success`
|
||||
: `${base} bg-error/10 text-error`
|
||||
switch (action) {
|
||||
case 'upload':
|
||||
return `${base} bg-success/10 text-success`
|
||||
case 'delete':
|
||||
return `${base} bg-error/10 text-error`
|
||||
case 'login':
|
||||
return `${base} bg-sky-500/10 text-sky-600 dark:text-sky-400`
|
||||
case 'edit':
|
||||
return `${base} bg-blue-500/10 text-blue-600 dark:text-blue-400`
|
||||
case 'settings':
|
||||
return `${base} bg-slate-500/10 text-slate-600 dark:text-slate-400`
|
||||
case 'tags':
|
||||
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
|
||||
}
|
||||
}
|
||||
|
||||
function actionIcon(action: LogAction) {
|
||||
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
|
||||
switch (action) {
|
||||
case 'upload':
|
||||
return 'i-lucide-upload'
|
||||
case 'delete':
|
||||
return 'i-lucide-trash-2'
|
||||
case 'login':
|
||||
return 'i-lucide-log-in'
|
||||
case 'edit':
|
||||
return 'i-lucide-pencil'
|
||||
case 'settings':
|
||||
return 'i-lucide-settings'
|
||||
case 'tags':
|
||||
return 'i-lucide-tag'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceIcon(source: LogSource) {
|
||||
switch (source) {
|
||||
case 'web':
|
||||
return 'i-lucide-monitor'
|
||||
case 'admin':
|
||||
return 'i-lucide-layout-dashboard'
|
||||
case 'api':
|
||||
return 'i-lucide-code-2'
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabel(source: LogSource) {
|
||||
switch (source) {
|
||||
case 'web':
|
||||
return t('logs.sourceWeb')
|
||||
case 'admin':
|
||||
return t('logs.sourceAdmin')
|
||||
case 'api':
|
||||
return t('logs.sourceApi')
|
||||
}
|
||||
}
|
||||
|
||||
function storageIcon(storage: ActivityLogStorage | null) {
|
||||
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
|
||||
}
|
||||
|
||||
function publicPathFromKey(key: string) {
|
||||
if (hideFolderInUrl.value) {
|
||||
const slash = key.lastIndexOf('/')
|
||||
return slash >= 0 ? key.slice(slash + 1) : key
|
||||
function sourceBadgeClass(source: LogSource) {
|
||||
const base = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap'
|
||||
switch (source) {
|
||||
case 'web':
|
||||
return `${base} bg-primary/10 text-primary`
|
||||
case 'admin':
|
||||
return `${base} bg-orange-500/10 text-orange-600 dark:text-orange-400`
|
||||
case 'api':
|
||||
return `${base} bg-violet-500/10 text-violet-600 dark:text-violet-400`
|
||||
}
|
||||
const prefix = 'images/'
|
||||
return key.startsWith(prefix) ? key.slice(prefix.length) : key
|
||||
}
|
||||
|
||||
function imageUrl(key: string) {
|
||||
const base = imageLinkBase.value.replace(/\/$/, '')
|
||||
const path = publicPathFromKey(key)
|
||||
if (!base) return `/${path}`
|
||||
return `${base}/${path}`
|
||||
function statusLabel(status: LogStatus) {
|
||||
return status === 'failure' ? t('logs.resultFailure') : t('logs.resultSuccess')
|
||||
}
|
||||
|
||||
async function loadImageLinkBase() {
|
||||
try {
|
||||
if (isAdmin.value) {
|
||||
const data = await $fetch<{ effectiveImageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', {
|
||||
credentials: 'include'
|
||||
})
|
||||
imageLinkBase.value = data.effectiveImageBaseUrl
|
||||
hideFolderInUrl.value = data.hideFolderInUrl
|
||||
return
|
||||
}
|
||||
const data = await $fetch<{ env: { imageBaseUrl: string, hideFolderInUrl: boolean } }>('/api/user/api-token', {
|
||||
credentials: 'include'
|
||||
})
|
||||
imageLinkBase.value = data.env.imageBaseUrl
|
||||
hideFolderInUrl.value = data.env.hideFolderInUrl
|
||||
} catch {
|
||||
if (import.meta.client) {
|
||||
imageLinkBase.value = window.location.origin
|
||||
}
|
||||
}
|
||||
function statusDotClass(status: LogStatus) {
|
||||
return status === 'failure' ? 'bg-error' : 'bg-success'
|
||||
}
|
||||
|
||||
function formatIp(value: string | null) {
|
||||
if (!value || value === '-') return '—'
|
||||
return value
|
||||
}
|
||||
|
||||
function formatSize(action: LogAction, size: number) {
|
||||
if (action !== 'upload' && action !== 'delete') return '—'
|
||||
if (action === 'delete' && size <= 0) return '—'
|
||||
return formatFileSize(size)
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
@@ -140,7 +172,7 @@ async function loadLogs() {
|
||||
try {
|
||||
const query: Record<string, string | number> = {
|
||||
page: page.value,
|
||||
limit: pageSize
|
||||
limit: pageSize.value
|
||||
}
|
||||
if (actionFilter.value !== 'all') {
|
||||
query.action = actionFilter.value
|
||||
@@ -154,6 +186,12 @@ async function loadLogs() {
|
||||
if (activeSearch.value) {
|
||||
query.q = activeSearch.value
|
||||
}
|
||||
if (dateFrom.value) {
|
||||
query.from = dateFrom.value
|
||||
}
|
||||
if (dateTo.value) {
|
||||
query.to = dateTo.value
|
||||
}
|
||||
|
||||
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
|
||||
credentials: 'include',
|
||||
@@ -191,21 +229,32 @@ function handlePageChange(target: number) {
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadImageLinkBase()
|
||||
await loadLogs()
|
||||
function handlePageSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadLogs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">
|
||||
{{ t('logs.pageTitle') }}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-4 w-1 shrink-0 rounded-full bg-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 class="text-sm font-semibold text-highlighted">
|
||||
{{ t('logs.pageTitle') }}
|
||||
</h2>
|
||||
</div>
|
||||
<p class="mt-1.5 pl-3 text-xs leading-relaxed text-muted">
|
||||
{{ isAdmin ? t('settings.pageSubtitleLogs') : t('logs.pageSubtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
@@ -215,35 +264,45 @@ onMounted(async () => {
|
||||
size="sm"
|
||||
:label="t('common.refresh')"
|
||||
:loading="loading"
|
||||
class="shrink-0"
|
||||
@click="loadLogs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="border-b border-default px-5 py-3 sm:px-6"
|
||||
class="mb-4 flex flex-col gap-3"
|
||||
@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 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<UInput
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
size="sm"
|
||||
class="min-w-0 w-full sm:w-36"
|
||||
:aria-label="t('logs.dateFrom')"
|
||||
:disabled="loading"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
<span class="hidden text-xs text-muted sm:inline">{{ t('logs.dateRangeSeparator') }}</span>
|
||||
<UInput
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
size="sm"
|
||||
class="min-w-0 w-full sm:w-36"
|
||||
:aria-label="t('logs.dateTo')"
|
||||
:disabled="loading"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 xl:justify-end">
|
||||
<USelect
|
||||
v-if="isAdmin"
|
||||
v-model="userFilter"
|
||||
:items="userItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-full sm:w-36"
|
||||
class="min-w-0 w-full sm:w-32"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
@@ -251,7 +310,7 @@ onMounted(async () => {
|
||||
v-model="actionFilter"
|
||||
:items="actionItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
@@ -259,7 +318,7 @@ onMounted(async () => {
|
||||
v-model="sourceFilter"
|
||||
:items="sourceItems"
|
||||
value-key="value"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
|
||||
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-28"
|
||||
size="sm"
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
@@ -268,19 +327,17 @@ onMounted(async () => {
|
||||
v-model="searchQuery"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('logs.searchPlaceholder')"
|
||||
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
|
||||
class="min-w-0 flex-1 sm:w-44 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>
|
||||
{{ t('common.search') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="activeSearch"
|
||||
@@ -292,263 +349,209 @@ onMounted(async () => {
|
||||
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">
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary whitespace-nowrap">
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
<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 class="overflow-hidden rounded-xl border border-default bg-default">
|
||||
<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>
|
||||
|
||||
<div class="hidden min-w-0 sm:block">
|
||||
<table class="w-full table-fixed text-left text-sm">
|
||||
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
||||
<tr>
|
||||
<th class="w-36 px-4 py-3 font-medium sm:px-6">
|
||||
{{ t('logs.colTime') }}
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="w-20 px-4 py-3 font-medium"
|
||||
>
|
||||
{{ t('logs.colUser') }}
|
||||
</th>
|
||||
<th class="w-20 whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colAction') }}
|
||||
</th>
|
||||
<th class="w-16 whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colSource') }}
|
||||
</th>
|
||||
<th class="w-24 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="w-20 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
|
||||
<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="hover:bg-muted/20"
|
||||
class="min-w-0 px-4 py-3.5"
|
||||
>
|
||||
<td class="px-4 py-3 text-xs text-muted sm:px-6">
|
||||
<span class="block truncate">{{ formatTime(row.createdAt) }}</span>
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="px-4 py-3 text-xs"
|
||||
>
|
||||
<span class="block truncate">{{ row.username ?? '—' }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<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">
|
||||
{{ formatSize(row.action, row.size) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
|
||||
{{ actionLabel(row.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<span :class="sourceBadgeClass(row.source)">
|
||||
<UIcon
|
||||
:name="sourceIcon(row.source)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
v-if="row.storage"
|
||||
class="flex min-w-0 items-center gap-1"
|
||||
:title="row.storage.name"
|
||||
v-if="isAdmin"
|
||||
class="text-xs text-muted"
|
||||
>
|
||||
<UIcon
|
||||
:name="storageIcon(row.storage)"
|
||||
class="size-3.5 shrink-0 opacity-70"
|
||||
/>
|
||||
<span class="truncate">{{ row.storage.name }}</span>
|
||||
{{ row.username ?? '—' }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted"
|
||||
>—</span>
|
||||
</td>
|
||||
<td
|
||||
class="hidden max-w-0 truncate px-4 py-3 md:table-cell"
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mt-2 truncate text-sm"
|
||||
:title="row.originalName"
|
||||
>
|
||||
{{ row.originalName }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
||||
{{ formatFileSize(row.size) }}
|
||||
</td>
|
||||
<td class="hidden max-w-0 px-4 py-3 xl:table-cell">
|
||||
<div class="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 truncate font-mono text-xs text-primary hover:underline"
|
||||
:title="row.key"
|
||||
>
|
||||
{{ row.key }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>{{ formatIp(row.ipAddress) }}</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
v-else
|
||||
class="min-w-0 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')"
|
||||
class="size-1.5 rounded-full"
|
||||
:class="statusDotClass(row.status)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden min-w-0 overflow-x-auto sm:block">
|
||||
<table class="w-full min-w-[56rem] text-left text-sm">
|
||||
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
|
||||
<tr>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('logs.colTime') }}
|
||||
</th>
|
||||
<th
|
||||
v-if="isAdmin"
|
||||
class="whitespace-nowrap px-4 py-3 font-medium"
|
||||
>
|
||||
{{ t('logs.colUser') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colAction') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colSource') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 font-medium">
|
||||
{{ t('logs.colFile') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colSize') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium">
|
||||
{{ t('logs.colIp') }}
|
||||
</th>
|
||||
<th class="whitespace-nowrap px-4 py-3 font-medium sm:px-5">
|
||||
{{ t('logs.colResult') }}
|
||||
</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-5">
|
||||
{{ formatTime(row.createdAt) }}
|
||||
</td>
|
||||
<td
|
||||
v-if="isAdmin"
|
||||
class="whitespace-nowrap px-4 py-3 text-xs"
|
||||
>
|
||||
{{ row.username ?? '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span :class="actionBadgeClass(row.action)">
|
||||
<UIcon
|
||||
:name="actionIcon(row.action)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ actionLabel(row.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span :class="sourceBadgeClass(row.source)">
|
||||
<UIcon
|
||||
:name="sourceIcon(row.source)"
|
||||
class="size-3 shrink-0"
|
||||
/>
|
||||
{{ sourceLabel(row.source) }}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
class="max-w-[12rem] truncate px-4 py-3"
|
||||
:title="row.originalName"
|
||||
>
|
||||
{{ row.originalName }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
|
||||
{{ formatSize(row.action, row.size) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 font-mono text-xs text-muted">
|
||||
{{ formatIp(row.ipAddress) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 sm:px-5">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
<span
|
||||
class="size-1.5 rounded-full"
|
||||
:class="statusDotClass(row.status)"
|
||||
/>
|
||||
{{ statusLabel(row.status) }}
|
||||
</span>
|
||||
</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>
|
||||
</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
|
||||
v-if="listData && listData.total > 0"
|
||||
class="border-t border-default px-4 py-4 sm:px-5"
|
||||
>
|
||||
<PaginationBar
|
||||
:page="listData.page"
|
||||
:total-pages="listData.totalPages"
|
||||
:total="listData.total"
|
||||
:unit="t('logs.entriesUnit')"
|
||||
:page-size="pageSize"
|
||||
:loading="loading"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="mb-0 flex items-start gap-3 border-b border-default pb-6">
|
||||
<div class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<UIcon
|
||||
:name="icon"
|
||||
class="size-5 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 pt-0.5">
|
||||
<h1 class="text-lg font-semibold tracking-tight text-highlighted">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-1 text-xs leading-relaxed text-muted"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-xl border border-default bg-default p-5 sm:p-6">
|
||||
<div class="space-y-8">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="mt-6 space-y-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
hint?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden rounded-xl border border-default bg-default">
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-sm font-semibold text-highlighted">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<slot name="badge" />
|
||||
</div>
|
||||
<p
|
||||
v-if="hint"
|
||||
class="mt-1 text-xs leading-relaxed text-muted"
|
||||
>
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-default px-4 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
export type SettingsRadioOption = {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
items: SettingsRadioOption[]
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
|
||||
const radioUi = {
|
||||
fieldset: 'flex flex-col divide-y divide-default',
|
||||
item: 'flex items-start gap-4 py-4',
|
||||
wrapper: 'min-w-0 flex-1 pr-2',
|
||||
base: 'mt-0.5 shrink-0',
|
||||
label: 'block text-sm font-medium text-highlighted',
|
||||
description: 'mt-1 block text-xs leading-relaxed text-muted'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<URadioGroup
|
||||
v-model="model"
|
||||
:items="items"
|
||||
variant="list"
|
||||
:ui="radioUi"
|
||||
>
|
||||
<template #label="{ item }">
|
||||
<span class="text-sm font-medium text-highlighted">{{ item.label }}</span>
|
||||
</template>
|
||||
<template #description="{ item }">
|
||||
<span
|
||||
v-if="item.description"
|
||||
class="mt-1 block text-xs leading-relaxed text-muted"
|
||||
>{{ item.description }}</span>
|
||||
</template>
|
||||
</URadioGroup>
|
||||
</template>
|
||||
@@ -6,8 +6,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<section class="overflow-hidden rounded-xl border border-default bg-default">
|
||||
<div class="flex items-start justify-between gap-4 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
@@ -32,6 +32,8 @@ defineProps<{
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
<div class="border-t border-default px-4">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: []
|
||||
restore: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -13,13 +14,19 @@ const { t } = useI18n()
|
||||
|
||||
<template>
|
||||
<div class="border-t border-default bg-default px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||
<p
|
||||
v-if="hasChanges"
|
||||
class="mr-auto text-xs text-warning"
|
||||
>
|
||||
{{ t('settings.unsavedChanges') }}
|
||||
</p>
|
||||
<UButton
|
||||
:label="t('settings.restoreDefaults')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
@click="emit('restore')"
|
||||
/>
|
||||
<UButton
|
||||
:label="t('settings.saveSettings')"
|
||||
icon="i-lucide-save"
|
||||
|
||||
@@ -1,39 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsTab } from '~/types/settings'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: SettingsTab
|
||||
items: Array<{ id: SettingsTab, label: string, icon: string }>
|
||||
title: string
|
||||
subtitle: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [SettingsTab]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return props.items
|
||||
return props.items.filter(item => item.label.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function selectTab(id: SettingsTab) {
|
||||
emit('update:modelValue', id)
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex w-full shrink-0 flex-col border-b border-default bg-neutral-50 dark:bg-neutral-900/30 lg:w-56 lg:border-b-0 lg:border-r xl:w-60">
|
||||
<div class="border-b border-default px-4 py-5 sm:px-5">
|
||||
<h1 class="text-lg font-semibold tracking-tight text-highlighted">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<div class="border-b border-default p-3">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
size="sm"
|
||||
:placeholder="t('settings.searchPlaceholder')"
|
||||
icon="i-lucide-search"
|
||||
class="w-full"
|
||||
>
|
||||
<template #trailing>
|
||||
<UKbd
|
||||
size="sm"
|
||||
class="hidden sm:inline-flex"
|
||||
>
|
||||
⌘K
|
||||
</UKbd>
|
||||
</template>
|
||||
</UInput>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 space-y-0.5 p-3">
|
||||
<p
|
||||
v-if="searchQuery.trim() && !filteredItems.length"
|
||||
class="px-3 py-6 text-center text-xs text-muted"
|
||||
>
|
||||
{{ t('settings.searchEmpty') }}
|
||||
</p>
|
||||
<button
|
||||
v-for="item in items"
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors"
|
||||
:class="modelValue === item.id
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted hover:bg-muted/50 hover:text-highlighted'"
|
||||
@click="emit('update:modelValue', item.id)"
|
||||
@click="selectTab(item.id)"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
|
||||
@@ -4,32 +4,64 @@ defineProps<{
|
||||
updateAvailable: boolean
|
||||
latestVersion: string | null
|
||||
releaseUrl: string | null
|
||||
checking: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
checkUpdate: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pl-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-highlighted">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4 py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.systemVersion') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.systemVersionHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium tabular-nums text-highlighted">
|
||||
v{{ appVersion }}
|
||||
</span>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
v-if="updateAvailable && latestVersion"
|
||||
color="warning"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
v{{ appVersion }}
|
||||
{{ t('settings.updateAvailable', { version: `v${latestVersion}` }) }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
>
|
||||
{{ t('settings.upToDate') }}
|
||||
</UBadge>
|
||||
<UButton
|
||||
:label="t('settings.checkUpdate')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:loading="checking"
|
||||
@click="emit('checkUpdate')"
|
||||
/>
|
||||
<a
|
||||
v-if="updateAvailable && latestVersion"
|
||||
:href="releaseUrl || 'https://github.com/O96u/PicHost/releases'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-warning hover:underline"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ t('settings.updateAvailable', { version: `v${latestVersion}` }) }}
|
||||
{{ t('settings.viewReleases') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const { items: tags, loading, fetchTags } = useTags()
|
||||
|
||||
onMounted(() => {
|
||||
void fetchTags()
|
||||
})
|
||||
|
||||
async function handleChanged() {
|
||||
await fetchTags(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPanel>
|
||||
<div
|
||||
v-if="loading && !tags.length"
|
||||
class="flex justify-center py-16"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-loader-circle"
|
||||
class="size-6 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
<TagManagePanel
|
||||
v-else
|
||||
:tags="tags"
|
||||
embedded
|
||||
@changed="handleChanged"
|
||||
/>
|
||||
</SettingsPanel>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@ const model = defineModel<boolean>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5">
|
||||
<div class="flex items-start justify-between gap-4 py-4">
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
<slot name="title">
|
||||
|
||||
@@ -19,16 +19,11 @@ const {
|
||||
const copyFormatItems = computed(() => [
|
||||
{ label: t('copy.url'), value: 'url' as const },
|
||||
{ label: t('copy.markdown'), value: 'markdown' as const },
|
||||
{ label: t('copy.html'), value: 'html' as const }
|
||||
{ label: t('copy.html'), value: 'html' as const },
|
||||
{ label: t('copy.bbcode'), value: 'bbcode' as const }
|
||||
])
|
||||
|
||||
const clientQualityLevel = computed(() => {
|
||||
if (clientWebpQuality.value >= 85) return t('preferences.qualityHigh')
|
||||
if (clientWebpQuality.value >= 60) return t('preferences.qualityMedium')
|
||||
return t('preferences.qualityLow')
|
||||
})
|
||||
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
|
||||
function setCopyFormat(value: 'url' | 'markdown' | 'html' | 'bbcode') {
|
||||
copyFormat.value = value
|
||||
}
|
||||
|
||||
@@ -37,6 +32,11 @@ function onClientQualityInput(event: Event) {
|
||||
clientWebpQuality.value = Number.isFinite(value) ? value : 80
|
||||
}
|
||||
|
||||
function onClientQualityNumberInput(value: string | number) {
|
||||
const num = Number(value)
|
||||
clientWebpQuality.value = Number.isFinite(num) ? Math.min(100, Math.max(1, Math.round(num))) : 80
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPreferences()
|
||||
void loadUserSettings()
|
||||
@@ -53,7 +53,7 @@ onMounted(() => {
|
||||
/>
|
||||
<div
|
||||
v-if="userSettings"
|
||||
class="flex items-start justify-between gap-4 px-4 py-4 sm:px-5"
|
||||
class="flex items-start justify-between gap-4 py-4"
|
||||
>
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<p class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm font-medium text-highlighted">
|
||||
@@ -87,55 +87,61 @@ onMounted(() => {
|
||||
class="size-5 animate-spin text-muted"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 py-4 sm:px-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.webpQualityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right text-xs text-muted">
|
||||
<p>{{ t('preferences.current', { n: clientWebpQuality }) }}</p>
|
||||
<p class="mt-0.5">
|
||||
{{ clientQualityLevel }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('preferences.webpQuality') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('preferences.webpQualityHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="clientWebpQuality"
|
||||
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||
@input="onClientQualityInput"
|
||||
>
|
||||
<UInput
|
||||
:model-value="clientWebpQuality"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
size="xs"
|
||||
class="w-14 shrink-0 tabular-nums"
|
||||
@update:model-value="onClientQualityNumberInput"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
:value="clientWebpQuality"
|
||||
class="mt-3 h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-primary"
|
||||
@input="onClientQualityInput"
|
||||
>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection :title="t('settings.uploadConvenience')">
|
||||
<SettingsSection :title="t('preferences.title')">
|
||||
<SettingsGroup>
|
||||
<SettingsToggleRow
|
||||
v-model="autoCopyMarkdown"
|
||||
:title="t('preferences.autoCopy')"
|
||||
:hint="t('preferences.autoCopyHint')"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div class="py-4">
|
||||
<p class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.defaultCopyFormat') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted">
|
||||
{{ t('settings.defaultCopyFormatHint') }}
|
||||
</p>
|
||||
<div
|
||||
class="flex w-full max-w-xs gap-1 rounded-lg border border-default p-0.5 sm:w-auto"
|
||||
class="mt-3 flex w-full max-w-md gap-1 rounded-lg border border-default p-0.5"
|
||||
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
|
||||
>
|
||||
<UButton
|
||||
v-for="item in copyFormatItems"
|
||||
:key="item.value"
|
||||
size="xs"
|
||||
class="flex-1 justify-center sm:min-w-[4.5rem]"
|
||||
class="flex-1 justify-center"
|
||||
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
|
||||
:color="copyFormat === item.value ? 'primary' : 'neutral'"
|
||||
:label="item.label"
|
||||
|
||||
Reference in new issue
Block a user