优化 2026 前端无障碍与响应式体验

完善 2026 主题的移动端与桌面端交互体验,修复多项可访问性和信息展示问题。

- 允许浏览器缩放,补充焦点样式、按钮名称、表单标签和对话框键盘焦点管理\n- 支持 reduced-motion,扩大公共页、后台导航、记录列表和分页控件的触控区域\n- 优化系统设置分区导航、密码显示、字段语义和底部粘性保存栏\n- 压缩移动文件列表操作区,改善桌面表格滚动可达性和统计值截断\n- 优化仪表盘指标、趋势摘要、进度条语义及中英文文案
This commit is contained in:
2026-07-21 11:03:13 +08:00
parent 2f020f5d42
commit b60c003ec2
29 changed files with 638 additions and 312 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="/assets/logo_small.png" /> <link rel="icon" href="/assets/logo_small.png" />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" content="width=device-width, initial-scale=1.0"
/> />
<meta name="description" content="{{description}}" /> <meta name="description" content="{{description}}" />
<meta name="keywords" content="{{keywords}}" /> <meta name="keywords" content="{{keywords}}" />
+2 -2
View File
@@ -21,7 +21,7 @@ const {
<LanguageSwitcher /> <LanguageSwitcher />
<ThemeToggle v-model="isDarkMode" /> <ThemeToggle v-model="isDarkMode" />
</div> </div>
<div v-if="isLoading" class="loading-overlay"> <div v-if="isLoading" class="loading-overlay" role="status" aria-live="polite" aria-label="Loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
@@ -36,7 +36,7 @@ const {
<style> <style>
.app-container { .app-container {
min-height: 100vh; min-height: 100dvh;
width: 100%; width: 100%;
transition: background-color 0.5s ease; transition: background-color 0.5s ease;
} }
+16
View File
@@ -17,6 +17,11 @@ html {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
:focus-visible {
outline: 3px solid rgba(95, 135, 150, 0.78);
outline-offset: 3px;
}
button, button,
input, input,
select, select,
@@ -32,6 +37,17 @@ textarea {
color-scheme: dark; color-scheme: dark;
} }
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.native-date-input::-webkit-calendar-picker-indicator { .native-date-input::-webkit-calendar-picker-indicator {
cursor: pointer; cursor: pointer;
min-height: 2.75rem; min-height: 2.75rem;
+4 -1
View File
@@ -7,6 +7,8 @@
<div <div
v-for="alert in alerts" v-for="alert in alerts"
:key="alert.id" :key="alert.id"
:role="alert.type === 'error' ? 'alert' : 'status'"
:aria-live="alert.type === 'error' ? 'assertive' : 'polite'"
:class="[ :class="[
'w-full rounded-[30px] shadow-xl overflow-hidden', 'w-full rounded-[30px] shadow-xl overflow-hidden',
'bg-gradient-to-r', 'bg-gradient-to-r',
@@ -23,8 +25,9 @@
</div> </div>
<div class="ml-4 flex-shrink-0 flex"> <div class="ml-4 flex-shrink-0 flex">
<button <button
type="button"
@click="removeAlert(alert.id)" @click="removeAlert(alert.id)"
class="inline-flex text-white hover:text-gray-200 focus:outline-none transition-colors duration-200" class="inline-flex h-11 w-11 items-center justify-center rounded-lg text-white transition-colors duration-200 hover:bg-white/10 hover:text-gray-100 focus:outline-none focus:ring-2 focus:ring-white"
> >
<span class="sr-only">{{ t('common.close') }}</span> <span class="sr-only">{{ t('common.close') }}</span>
<X class="h-5 w-5" /> <X class="h-5 w-5" />
+85 -44
View File
@@ -6,24 +6,20 @@
class="fixed inset-0 z-50 overflow-y-auto" class="fixed inset-0 z-50 overflow-y-auto"
@click="handleBackdropClick" @click="handleBackdropClick"
> >
<!-- 背景遮罩 --> <div class="fixed inset-0 bg-black/50 transition-opacity" aria-hidden="true"></div>
<div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
@click.stop="handleBackdropClick"
></div>
<!-- 模态框容器 -->
<div class="flex min-h-full items-center justify-center p-4"> <div class="flex min-h-full items-center justify-center p-4">
<div <div
ref="modalRef" ref="modalRef"
class="relative flex max-h-[calc(100vh-2rem)] transform flex-col overflow-hidden rounded-[30px] shadow-xl transition-all" role="dialog"
:class="[ aria-modal="true"
sizeClasses, :aria-label="title || t('common.dialog')"
isDarkMode ? 'bg-gray-800' : 'bg-white' tabindex="-1"
]" class="relative flex max-h-[calc(100dvh-2rem)] transform flex-col overflow-hidden rounded-[24px] shadow-xl transition-all sm:rounded-[30px]"
:class="[sizeClasses, isDarkMode ? 'bg-gray-800' : 'bg-white']"
@click.stop @click.stop
@keydown="handleKeydown"
> >
<!-- 头部 -->
<div <div
v-if="$slots.header || title" v-if="$slots.header || title"
class="flex shrink-0 items-center justify-between border-b px-4 py-3 sm:px-6 sm:py-4" class="flex shrink-0 items-center justify-between border-b px-4 py-3 sm:px-6 sm:py-4"
@@ -36,27 +32,28 @@
</slot> </slot>
<button <button
v-if="closable" v-if="closable"
@click="$emit('close')" type="button"
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg transition-colors" class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg transition-colors"
:class="[ :class="[
isDarkMode isDarkMode
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' ? 'text-gray-300 hover:bg-gray-700 hover:text-white'
: 'text-gray-400 hover:text-gray-500 hover:bg-gray-100' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-800'
]" ]"
:aria-label="t('common.close')"
:title="t('common.close')"
@click="emit('close')"
> >
<X class="h-5 w-5" /> <X class="h-5 w-5" aria-hidden="true" />
</button> </button>
</div> </div>
<!-- 内容 --> <div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6" tabindex="0">
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6">
<slot></slot> <slot></slot>
</div> </div>
<!-- 底部 -->
<div <div
v-if="$slots.footer" v-if="$slots.footer"
class="flex shrink-0 items-center justify-end space-x-3 border-t px-4 py-3 sm:px-6 sm:py-4" class="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t px-4 py-3 sm:px-6 sm:py-4"
:class="[isDarkMode ? 'border-gray-700 bg-gray-900/50' : 'border-gray-200 bg-gray-50']" :class="[isDarkMode ? 'border-gray-700 bg-gray-900/50' : 'border-gray-200 bg-gray-50']"
> >
<slot name="footer"></slot> <slot name="footer"></slot>
@@ -69,8 +66,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, ref } from 'vue' import { computed, inject, nextTick, ref, watch } from 'vue'
import { X } from 'lucide-vue-next' import { X } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
interface Props { interface Props {
show: boolean show: boolean
@@ -86,48 +84,91 @@ const props = withDefaults(defineProps<Props>(), {
closeOnBackdrop: true closeOnBackdrop: true
}) })
const emit = defineEmits<{ const emit = defineEmits<{ close: [] }>()
close: [] const { t } = useI18n()
}>()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const modalRef = ref<HTMLElement>() const modalRef = ref<HTMLElement>()
let returnFocusElement: HTMLElement | null = null
const sizeClasses = computed(() => { const sizeClasses = computed(() => ({
const sizes = { sm: 'max-w-md w-full',
sm: 'max-w-md w-full', md: 'max-w-lg w-full',
md: 'max-w-lg w-full', lg: 'max-w-2xl w-full',
lg: 'max-w-2xl w-full', xl: 'max-w-4xl w-full'
xl: 'max-w-4xl w-full' })[props.size])
}
return sizes[props.size] const focusableSelector =
}) 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const handleBackdropClick = () => { const handleBackdropClick = () => {
if (props.closeOnBackdrop) { if (props.closeOnBackdrop) emit('close')
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && props.closable) {
event.preventDefault()
emit('close') emit('close')
return
}
if (event.key !== 'Tab' || !modalRef.value) return
const focusable = [...modalRef.value.querySelectorAll<HTMLElement>(focusableSelector)]
if (!focusable.length) {
event.preventDefault()
modalRef.value.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
} }
} }
watch(
() => props.show,
async (show) => {
if (show) {
returnFocusElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
const first = modalRef.value?.querySelector<HTMLElement>(focusableSelector)
;(first || modalRef.value)?.focus()
} else {
await nextTick()
returnFocusElement?.focus()
returnFocusElement = null
}
}
)
</script> </script>
<style scoped> <style scoped>
.modal-enter-active, .modal-enter-active,
.modal-leave-active { .modal-leave-active {
transition: opacity 0.3s ease; transition: opacity 0.24s ease;
} }
.modal-enter-active .relative, .modal-enter-active .relative,
.modal-leave-active .relative { .modal-leave-active .relative {
transition: all 0.3s ease; transition: transform 0.24s ease, opacity 0.24s ease;
} }
.modal-enter-from, .modal-enter-from,
.modal-leave-to { .modal-leave-to {
opacity: 0; opacity: 0;
} }
.modal-enter-from .relative, .modal-enter-from .relative,
.modal-leave-to .relative { .modal-leave-to .relative {
transform: scale(0.95) translateY(-20px); opacity: 0;
transform: scale(0.97) translateY(-10px);
}
@media (prefers-reduced-motion: reduce) {
.modal-enter-active,
.modal-leave-active,
.modal-enter-active .relative,
.modal-leave-active .relative {
transition-duration: 0.01ms;
}
} }
</style> </style>
+53 -76
View File
@@ -1,53 +1,46 @@
<template> <template>
<div <div
class="mt-4 flex flex-col gap-3 border-t px-6 py-4 sm:flex-row sm:items-center sm:justify-between" class="mt-4 flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6"
:class="[isDarkMode ? 'border-[#31454d]' : 'border-[#c8d7da]']" :class="[isDarkMode ? 'border-[#31454d]' : 'border-[#c8d7da]']"
> >
<div <div class="text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
class="flex items-center text-sm" {{ t('components.pagination.range', { start: rangeStart, end: rangeEnd, total }) }}
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
>
显示第 {{ (currentPage - 1) * pageSize + 1 }}
{{ Math.min(currentPage * pageSize, total) }} {{ total }}
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<select <select
:value="pageSize" :value="pageSize"
class="h-9 rounded-[30px] border px-3 text-sm shadow-sm transition-colors" class="min-h-11 rounded-[30px] border px-3 text-sm shadow-sm transition-colors sm:min-h-9"
:class="[ :class="[
isDarkMode isDarkMode
? 'border-[#40545c] bg-[#263941] text-[#d8e3e5] hover:border-[#7199a8]' ? 'border-[#40545c] bg-[#263941] text-[#d8e3e5] hover:border-[#7199a8]'
: 'border-[#c8d8dc] bg-[#edf4f5] text-[#415d66] hover:border-[#9db6bd]' : 'border-[#c8d8dc] bg-[#edf4f5] text-[#415d66] hover:border-[#9db6bd]'
]" ]"
:aria-label="t('components.pagination.pageSize')"
@change="$emit('page-size-change', Number(($event.target as HTMLSelectElement).value))" @change="$emit('page-size-change', Number(($event.target as HTMLSelectElement).value))"
> >
<option v-for="size in pageSizeOptions" :key="size" :value="size">{{ size }}/</option> <option v-for="size in pageSizeOptions" :key="size" :value="size">
{{ t('components.pagination.perPage', { size }) }}
</option>
</select> </select>
<button <button
@click="$emit('page-change', currentPage - 1)" type="button"
:disabled="currentPage === 1" :disabled="currentPage === 1"
class="inline-flex items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md" class="inline-flex min-h-11 items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md sm:min-h-9"
:class="[ :class="buttonClass(currentPage === 1)"
isDarkMode @click="$emit('page-change', currentPage - 1)"
? currentPage === 1
? 'cursor-not-allowed bg-[#223039] text-[#657a80]'
: 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
: currentPage === 1
? 'cursor-not-allowed bg-[#e8eeee] text-[#9aa9ad]'
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
]"
> >
<ChevronLeftIcon class="w-4 h-4" /> <ChevronLeftIcon class="h-4 w-4" aria-hidden="true" />
上一页 {{ t('common.previous') }}
</button> </button>
<div class="flex items-center space-x-1"> <div class="flex items-center gap-1">
<template v-for="pageNum in displayedPages" :key="pageNum"> <template v-for="pageNum in displayedPages" :key="pageNum">
<button <button
v-if="pageNum !== '...'" v-if="pageNum !== '...'"
@click="$emit('page-change', pageNum as number)" type="button"
class="inline-flex items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md" class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md sm:min-h-9 sm:min-w-9"
:class="[ :class="[
currentPage === pageNum currentPage === pageNum
? 'bg-[#5f8796] text-white shadow-[#9fb8bf]/35' ? 'bg-[#5f8796] text-white shadow-[#9fb8bf]/35'
@@ -55,39 +48,34 @@
? 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]' ? 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]' : 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
]" ]"
:aria-label="t('components.pagination.goToPage', { page: pageNum })"
:aria-current="currentPage === pageNum ? 'page' : undefined"
@click="$emit('page-change', pageNum as number)"
> >
{{ pageNum }} {{ pageNum }}
</button> </button>
<span v-else class="px-2" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"> <span v-else class="px-1" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"></span>
...
</span>
</template> </template>
</div> </div>
<button <button
@click="$emit('page-change', currentPage + 1)" type="button"
:disabled="currentPage >= totalPages" :disabled="currentPage >= totalPages"
class="inline-flex items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md" class="inline-flex min-h-11 items-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md sm:min-h-9"
:class="[ :class="buttonClass(currentPage >= totalPages)"
isDarkMode @click="$emit('page-change', currentPage + 1)"
? currentPage >= totalPages
? 'cursor-not-allowed bg-[#223039] text-[#657a80]'
: 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
: currentPage >= totalPages
? 'cursor-not-allowed bg-[#e8eeee] text-[#9aa9ad]'
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
]"
> >
下一页 {{ t('common.next') }}
<ChevronRightIcon class="w-4 h-4" /> <ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
</button> </button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, computed } from 'vue' import { computed, inject, type Ref } from 'vue'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-vue-next' import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
interface Props { interface Props {
currentPage: number currentPage: number
@@ -105,44 +93,33 @@ defineEmits<{
'page-size-change': [size: number] 'page-size-change': [size: number]
}>() }>()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject<Ref<boolean>>('isDarkMode', computed(() => false))
const { t } = useI18n()
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
const rangeStart = computed(() => (props.total ? (props.currentPage - 1) * props.pageSize + 1 : 0))
const rangeEnd = computed(() => Math.min(props.currentPage * props.pageSize, props.total))
// 计算总页数
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
// 计算要显示的页码
const displayedPages = computed(() => { const displayedPages = computed(() => {
const current = props.currentPage const current = props.currentPage
const total = totalPages.value const total = totalPages.value
const delta = 2 // 当前页码前后显示的页码数 const pages: (number | string)[] = [1]
const left = Math.max(2, current - 2)
const pages: (number | string)[] = [] const right = Math.min(total - 1, current + 2)
if (left > 2) pages.push('...')
// 始终显示第一页 for (let i = left; i <= right; i++) pages.push(i)
pages.push(1) if (right < total - 1) pages.push('...')
if (total > 1) pages.push(total)
// 计算显示范围
const left = Math.max(2, current - delta)
const right = Math.min(total - 1, current + delta)
// 添加省略号和页码
if (left > 2) {
pages.push('...')
}
for (let i = left; i <= right; i++) {
pages.push(i)
}
if (right < total - 1) {
pages.push('...')
}
// 始终显示最后一页
if (total > 1) {
pages.push(total)
}
return pages return pages
}) })
const buttonClass = (disabled: boolean) => {
if (isDarkMode.value) {
return disabled
? 'cursor-not-allowed bg-[#223039] text-[#71878d]'
: 'bg-[#263941] text-[#d8e3e5] hover:bg-[#2d4751]'
}
return disabled
? 'cursor-not-allowed bg-[#e8eeee] text-[#7d8d91]'
: 'bg-[#edf4f5] text-[#415d66] hover:bg-[#e2ecee]'
}
</script> </script>
+9 -4
View File
@@ -18,16 +18,18 @@
<slot name="toolbar"></slot> <slot name="toolbar"></slot>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto" tabindex="0" role="region" :aria-label="title">
<table <table
class="min-w-full divide-y" class="divide-y"
:class="[isDarkMode ? 'divide-[#31454d]' : 'divide-[#d7e3e5]']" :class="[tableClass, isDarkMode ? 'divide-[#31454d]' : 'divide-[#d7e3e5]']"
> >
<caption class="sr-only">{{ title }}</caption>
<thead> <thead>
<tr> <tr>
<th <th
v-for="header in headers" v-for="header in headers"
:key="header" :key="header"
scope="col"
class="px-6 py-3.5 text-left text-xs font-medium uppercase tracking-wider" class="px-6 py-3.5 text-left text-xs font-medium uppercase tracking-wider"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-500']"
> >
@@ -53,9 +55,12 @@ import { inject, useSlots } from 'vue'
interface Props { interface Props {
title: string title: string
headers: string[] headers: string[]
tableClass?: string
} }
defineProps<Props>() withDefaults(defineProps<Props>(), {
tableClass: 'min-w-full'
})
const slots = useSlots() const slots = useSlots()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
+5 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<label :class="['text-sm font-medium', isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label for="send-expiration-value" :class="['text-sm font-medium', isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ t('send.expiration.label') }} {{ t('send.expiration.label') }}
</label> </label>
<div class="relative flex-grow group"> <div class="relative flex-grow group">
@@ -14,6 +14,7 @@
> >
<template v-if="expirationMethod !== 'forever'"> <template v-if="expirationMethod !== 'forever'">
<input <input
id="send-expiration-value"
:value="expirationValue" :value="expirationValue"
@input="updateValue" @input="updateValue"
type="number" type="number"
@@ -35,6 +36,7 @@
> >
<button <button
type="button" type="button"
:aria-label="t('send.expiration.increment')"
@click="incrementValue(1)" @click="incrementValue(1)"
class="flex-1 px-2 flex items-center justify-center transition-all duration-200" class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
:class="[ :class="[
@@ -54,6 +56,7 @@
</button> </button>
<button <button
type="button" type="button"
:aria-label="t('send.expiration.decrement')"
@click="incrementValue(-1)" @click="incrementValue(-1)"
class="flex-1 px-2 flex items-center justify-center transition-all duration-200" class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
:class="[ :class="[
@@ -74,6 +77,7 @@
</div> </div>
</template> </template>
<select <select
:aria-label="t('send.expiration.method')"
:value="expirationMethod" :value="expirationMethod"
@change="updateMethod" @change="updateMethod"
:class="[ :class="[
+14 -3
View File
@@ -29,8 +29,11 @@
</div> </div>
<div class="flex-shrink-0 flex space-x-2"> <div class="flex-shrink-0 flex space-x-2">
<button <button
type="button"
:aria-label="t('fileRecord.viewDetails')"
:title="t('fileRecord.viewDetails')"
@click="$emit('view-details', record)" @click="$emit('view-details', record)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300" class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[ :class="[
isDarkMode isDarkMode
? 'hover:bg-indigo-400 text-indigo-400' ? 'hover:bg-indigo-400 text-indigo-400'
@@ -40,8 +43,11 @@
<EyeIcon class="w-5 h-5" /> <EyeIcon class="w-5 h-5" />
</button> </button>
<button <button
type="button"
:aria-label="t('fileRecord.download')"
:title="t('fileRecord.download')"
@click="$emit('download-record', record)" @click="$emit('download-record', record)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300" class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[ :class="[
isDarkMode isDarkMode
? 'hover:bg-green-400 text-green-400' ? 'hover:bg-green-400 text-green-400'
@@ -51,8 +57,11 @@
<DownloadIcon class="w-5 h-5" /> <DownloadIcon class="w-5 h-5" />
</button> </button>
<button <button
type="button"
:aria-label="t('fileRecord.deleteRecord')"
:title="t('fileRecord.deleteRecord')"
@click="$emit('delete-record', record.id)" @click="$emit('delete-record', record.id)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300" class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[ :class="[
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600' isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
]" ]"
@@ -69,6 +78,7 @@
import { inject } from 'vue' import { inject } from 'vue'
import { FileIcon, EyeIcon, DownloadIcon, TrashIcon } from 'lucide-vue-next' import { FileIcon, EyeIcon, DownloadIcon, TrashIcon } from 'lucide-vue-next'
import type { ReceivedFileRecord } from '@/types' import type { ReceivedFileRecord } from '@/types'
import { useI18n } from 'vue-i18n'
interface Props { interface Props {
records: ReceivedFileRecord[] records: ReceivedFileRecord[]
@@ -83,6 +93,7 @@ interface Emits {
defineProps<Props>() defineProps<Props>()
defineEmits<Emits>() defineEmits<Emits>()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const { t } = useI18n()
</script> </script>
<style scoped> <style scoped>
+11 -3
View File
@@ -1,13 +1,17 @@
<template> <template>
<div ref="switcherRef" class="relative"> <div ref="switcherRef" class="relative">
<button <button
type="button"
@click="toggleDropdown" @click="toggleDropdown"
:class="[ :class="[
'flex items-center space-x-2 px-3 py-2 rounded-lg transition-all duration-200', 'flex min-h-11 items-center space-x-2 rounded-lg px-3 py-2 transition-all duration-200',
isDarkMode isDarkMode
? 'bg-gray-800/60 hover:bg-gray-700/80 text-gray-300 hover:text-white' ? 'bg-gray-800/60 hover:bg-gray-700/80 text-gray-300 hover:text-white'
: 'bg-gray-100 hover:bg-gray-200 text-gray-700 hover:text-gray-900' : 'bg-gray-100 hover:bg-gray-200 text-gray-700 hover:text-gray-900'
]" ]"
:aria-expanded="isDropdownOpen"
aria-haspopup="listbox"
:aria-label="t('common.language')"
> >
<GlobeIcon class="w-4 h-4" /> <GlobeIcon class="w-4 h-4" />
<span class="text-sm font-medium">{{ currentLanguage.name }}</span> <span class="text-sm font-medium">{{ currentLanguage.name }}</span>
@@ -29,6 +33,7 @@
> >
<div <div
v-if="isDropdownOpen" v-if="isDropdownOpen"
role="listbox"
:class="[ :class="[
'absolute right-0 mt-2 w-32 rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 z-50', 'absolute right-0 mt-2 w-32 rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 z-50',
isDarkMode isDarkMode
@@ -40,9 +45,12 @@
<button <button
v-for="language in availableLocales" v-for="language in availableLocales"
:key="language.code" :key="language.code"
type="button"
role="option"
:aria-selected="currentLocale === language.code"
@click="switchLanguage(language.code)" @click="switchLanguage(language.code)"
:class="[ :class="[
'w-full text-left px-4 py-2 text-sm transition-colors duration-150', 'min-h-11 w-full px-4 py-2 text-left text-sm transition-colors duration-150',
currentLocale === language.code currentLocale === language.code
? isDarkMode ? isDarkMode
? 'bg-indigo-600 text-white' ? 'bg-indigo-600 text-white'
@@ -66,7 +74,7 @@ import { useI18n } from 'vue-i18n'
import { GlobeIcon, ChevronDownIcon } from 'lucide-vue-next' import { GlobeIcon, ChevronDownIcon } from 'lucide-vue-next'
import { availableLocales, setLocale } from '@/i18n/index' import { availableLocales, setLocale } from '@/i18n/index'
const { locale } = useI18n() const { locale, t } = useI18n()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const isDropdownOpen = ref(false) const isDropdownOpen = ref(false)
const switcherRef = ref<HTMLElement | null>(null) const switcherRef = ref<HTMLElement | null>(null)
+6 -3
View File
@@ -3,7 +3,7 @@
<div class="mb-6 text-center" v-if="linkText && linkTo"> <div class="mb-6 text-center" v-if="linkText && linkTo">
<router-link <router-link
:to="linkTo" :to="linkTo"
class="text-indigo-400 hover:text-indigo-300 transition duration-300" class="inline-flex min-h-11 items-center rounded-lg px-3 text-[#3f6a77] transition duration-200 hover:bg-[#dcebed] hover:text-[#315c69] dark:text-[#b8d4dc] dark:hover:bg-[#294047]"
> >
{{ linkText }} {{ linkText }}
</router-link> </router-link>
@@ -23,7 +23,9 @@
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<router-link <router-link
to="/login" to="/login"
class="text-sm hover:text-indigo-300 transition duration-300 flex items-center" class="flex h-11 w-11 items-center justify-center rounded-lg text-sm transition duration-200 hover:bg-[#dcebed] dark:hover:bg-[#294047]"
:aria-label="t('admin.login.title')"
:title="t('admin.login.title')"
:class="[ :class="[
isDarkMode isDarkMode
? 'text-gray-400 hover:text-indigo-400' ? 'text-gray-400 hover:text-indigo-400'
@@ -33,8 +35,9 @@
<UserIcon class="w-4 h-4" /> <UserIcon class="w-4 h-4" />
</router-link> </router-link>
<button <button
type="button"
@click="$emit('toggle-drawer')" @click="$emit('toggle-drawer')"
class="text-sm hover:text-indigo-300 transition duration-300 flex items-center" class="flex min-h-11 items-center rounded-lg px-2 text-sm transition duration-200 hover:bg-[#dcebed] dark:hover:bg-[#294047]"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
> >
{{ drawerText }} {{ drawerText }}
+12 -4
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<div class="flex justify-center mb-8"> <div class="mb-5 flex justify-center" aria-hidden="true">
<div <div
class="rounded-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 p-1 animate-spin-slow" class="rounded-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 p-1 animate-spin-slow"
> >
@@ -9,17 +9,19 @@
</div> </div>
</div> </div>
</div> </div>
<h2 <button
type="button"
@click="$emit('title-click')" @click="$emit('title-click')"
class="text-3xl cursor-pointer font-extrabold text-center mb-6" class="mb-6 min-h-11 rounded-lg px-2 text-center text-3xl font-extrabold focus:outline-none focus:ring-2 focus:ring-indigo-500"
:class="[ :class="[
isDarkMode isDarkMode
? 'text-transparent bg-clip-text bg-gradient-to-r from-indigo-300 via-purple-300 to-pink-300' ? 'text-transparent bg-clip-text bg-gradient-to-r from-indigo-300 via-purple-300 to-pink-300'
: 'text-indigo-600' : 'text-indigo-600'
]" ]"
:aria-label="title"
> >
{{ title }} {{ title }}
</h2> </button>
</div> </div>
</template> </template>
@@ -54,4 +56,10 @@ const isDarkMode = inject('isDarkMode')
.animate-spin-slow { .animate-spin-slow {
animation: spin-slow 3s linear infinite; animation: spin-slow 3s linear infinite;
} }
@media (prefers-reduced-motion: reduce) {
.animate-spin-slow {
animation: none;
}
}
</style> </style>
+5 -2
View File
@@ -12,6 +12,8 @@
id="code" id="code"
v-model="code" v-model="code"
type="text" type="text"
inputmode="text"
autocomplete="one-time-code"
ref="codeInput" ref="codeInput"
class="w-full px-4 py-3 rounded-lg placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition duration-300 pr-10" class="w-full px-4 py-3 rounded-lg placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition duration-300 pr-10"
:class="[ :class="[
@@ -21,6 +23,7 @@
]" ]"
:placeholder="t('retrieve.codeInput.placeholder')" :placeholder="t('retrieve.codeInput.placeholder')"
required required
:aria-invalid="error"
:readonly="inputStatus.readonly" :readonly="inputStatus.readonly"
maxlength="5" maxlength="5"
@focus="isInputFocused = true" @focus="isInputFocused = true"
@@ -42,8 +45,8 @@
</div> </div>
<button <button
type="submit" type="submit"
class="w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-bold py-3 px-4 rounded-lg hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 transition duration-300 transform hover:scale-105 hover:shadow-lg relative overflow-hidden group" class="group relative w-full overflow-hidden rounded-lg bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 px-4 py-3 font-bold text-white transition duration-300 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-opacity-50 disabled:cursor-not-allowed disabled:opacity-50 motion-safe:hover:scale-[1.02] disabled:hover:scale-100"
:disabled="inputStatus.loading" :disabled="inputStatus.loading || code.trim().length !== 5"
> >
<span class="flex items-center justify-center relative z-10"> <span class="flex items-center justify-center relative z-10">
<span>{{ inputStatus.loading ? t('common.loading') : t('retrieve.submit') }}</span> <span>{{ inputStatus.loading ? t('common.loading') : t('retrieve.submit') }}</span>
+2 -2
View File
@@ -4,7 +4,7 @@
type="button" type="button"
@click="selectType('file')" @click="selectType('file')"
:class="[ :class="[
'px-4 py-2 rounded-lg transition-colors duration-300', 'min-h-11 px-4 py-2 rounded-lg transition-colors duration-300',
selectedType === 'file' selectedType === 'file'
? 'bg-indigo-600 text-white' ? 'bg-indigo-600 text-white'
: isDarkMode : isDarkMode
@@ -18,7 +18,7 @@
type="button" type="button"
@click="selectType('text')" @click="selectType('text')"
:class="[ :class="[
'px-4 py-2 rounded-lg transition-colors duration-300', 'min-h-11 px-4 py-2 rounded-lg transition-colors duration-300',
selectedType === 'text' selectedType === 'text'
? 'bg-indigo-600 text-white' ? 'bg-indigo-600 text-white'
: isDarkMode : isDarkMode
+14 -3
View File
@@ -26,8 +26,11 @@
</div> </div>
<div class="flex-shrink-0 flex space-x-2"> <div class="flex-shrink-0 flex space-x-2">
<button <button
type="button"
:aria-label="t('common.copy')"
:title="t('common.copy')"
@click="$emit('copy-link', record)" @click="$emit('copy-link', record)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300" class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[ :class="[
isDarkMode ? 'hover:bg-blue-400 text-blue-400' : 'hover:bg-blue-100 text-blue-600' isDarkMode ? 'hover:bg-blue-400 text-blue-400' : 'hover:bg-blue-100 text-blue-600'
]" ]"
@@ -35,8 +38,11 @@
<ClipboardCopyIcon class="w-5 h-5" /> <ClipboardCopyIcon class="w-5 h-5" />
</button> </button>
<button <button
type="button"
:aria-label="t('fileRecord.viewDetails')"
:title="t('fileRecord.viewDetails')"
@click="$emit('view-details', record)" @click="$emit('view-details', record)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300" class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[ :class="[
isDarkMode isDarkMode
? 'hover:bg-green-400 text-green-400' ? 'hover:bg-green-400 text-green-400'
@@ -46,8 +52,11 @@
<EyeIcon class="w-5 h-5" /> <EyeIcon class="w-5 h-5" />
</button> </button>
<button <button
type="button"
:aria-label="t('fileRecord.deleteRecord')"
:title="t('fileRecord.deleteRecord')"
@click="$emit('delete-record', record.id)" @click="$emit('delete-record', record.id)"
class="p-2 rounded-full hover:bg-opacity-20 transition duration-300" class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[ :class="[
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600' isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
]" ]"
@@ -64,6 +73,7 @@
import { inject } from 'vue' import { inject } from 'vue'
import { ClipboardCopyIcon, EyeIcon, FileIcon, TrashIcon } from 'lucide-vue-next' import { ClipboardCopyIcon, EyeIcon, FileIcon, TrashIcon } from 'lucide-vue-next'
import type { SentFileRecord } from '@/types' import type { SentFileRecord } from '@/types'
import { useI18n } from 'vue-i18n'
defineProps<{ defineProps<{
records: SentFileRecord[] records: SentFileRecord[]
@@ -76,6 +86,7 @@ defineEmits<{
}>() }>()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const { t } = useI18n()
</script> </script>
<style scoped> <style scoped>
+11 -20
View File
@@ -1,17 +1,18 @@
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <label :for="inputId" class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ label }} {{ label }}
</label> </label>
<div class="flex items-center space-x-2"> <div class="flex items-center gap-2">
<input <input
:id="inputId"
type="number" type="number"
:value="modelValue" :value="modelValue"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="min-h-11 w-28 rounded-md border px-4 py-2.5 shadow-sm outline-none transition-all duration-200 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? 'border-gray-600 bg-gray-700 text-white hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500' : 'border-gray-300 bg-white text-gray-900 hover:border-gray-400'
]" ]"
@input="handleInput" @input="handleInput"
/> />
@@ -21,19 +22,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue' import { computed, inject } from 'vue'
defineProps<{
label: string
modelValue: number
suffix: string
}>()
const props = defineProps<{ label: string; modelValue: number; suffix: string }>()
const inputId = computed(() => `setting-number-${props.label.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, '-')}`)
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
const handleInput = (event: Event) => { const handleInput = (event: Event) => {
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
@@ -41,10 +35,7 @@ const handleInput = (event: Event) => {
emit('update:modelValue', 0) emit('update:modelValue', 0)
return return
} }
const nextValue = input.valueAsNumber const nextValue = input.valueAsNumber
if (!Number.isNaN(nextValue)) { if (!Number.isNaN(nextValue)) emit('update:modelValue', nextValue)
emit('update:modelValue', nextValue)
}
} }
</script> </script>
+19 -30
View File
@@ -1,32 +1,29 @@
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium mb-2" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <span :id="labelId" class="block text-sm font-medium" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ label }} {{ label }}
</label> </span>
<div class="flex items-center"> <div class="flex min-h-11 items-center gap-3">
<button <button
type="button" type="button"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent shadow-sm transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2" class="flex h-11 w-14 shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2"
:class="[
modelValue === 1
? 'bg-[#6f9688]'
: isDarkMode
? 'bg-[#40545c]'
: 'bg-[#cfdadc]'
]"
role="switch" role="switch"
:aria-checked="modelValue === 1" :aria-checked="modelValue === 1"
:aria-labelledby="labelId"
@click="$emit('toggle')" @click="$emit('toggle')"
> >
<span <span
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" class="relative inline-flex h-6 w-11 rounded-full border-2 border-transparent shadow-sm transition-colors duration-200"
:class="[ :class="modelValue === 1 ? 'bg-[#6f9688]' : isDarkMode ? 'bg-[#40545c]' : 'bg-[#cfdadc]'"
modelValue === 1 ? 'translate-x-5' : 'translate-x-0', aria-hidden="true"
isDarkMode && modelValue !== 1 ? 'bg-gray-100' : 'bg-white' >
]" <span
/> class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200"
:class="modelValue === 1 ? 'translate-x-5' : 'translate-x-0'"
></span>
</span>
</button> </button>
<span class="ml-3 text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']"> <span class="text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-700']">
{{ modelValue === 1 ? enabledText : disabledText }} {{ modelValue === 1 ? enabledText : disabledText }}
</span> </span>
</div> </div>
@@ -34,18 +31,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue' import { computed, inject } from 'vue'
defineProps<{
label: string
modelValue: number
enabledText: string
disabledText: string
}>()
defineEmits<{
toggle: []
}>()
const props = defineProps<{ label: string; modelValue: number; enabledText: string; disabledText: string }>()
defineEmits<{ toggle: [] }>()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const labelId = computed(() => `setting-switch-${props.label.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, '-')}`)
</script> </script>
+102 -41
View File
@@ -1,63 +1,124 @@
<template> <template>
<transition name="drawer"> <Teleport to="body">
<div <transition name="drawer">
v-if="visible" <div v-if="visible" class="fixed inset-0 z-50">
class="fixed inset-y-0 right-0 w-full sm:w-120 bg-opacity-70 backdrop-filter backdrop-blur-xl shadow-2xl z-50 overflow-hidden flex flex-col"
:class="[isDarkMode ? 'bg-gray-900' : 'bg-white']"
>
<div
class="flex justify-between items-center p-6 border-b"
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
>
<h3 class="text-2xl font-bold" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ title }}
</h3>
<button <button
@click="$emit('close')" type="button"
class="hover:text-white transition duration-300" class="absolute inset-0 bg-black/45"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-800']" :aria-label="t('common.close')"
@click="emit('close')"
></button>
<aside
ref="drawerRef"
role="dialog"
aria-modal="true"
:aria-label="title"
tabindex="-1"
class="absolute inset-y-0 right-0 flex w-full flex-col overflow-hidden bg-opacity-95 shadow-2xl backdrop-blur-xl sm:w-[30rem]"
:class="[isDarkMode ? 'bg-gray-900' : 'bg-white']"
@keydown="handleKeydown"
> >
<XIcon class="w-6 h-6" /> <div
</button> class="flex items-center justify-between border-b p-4 sm:p-6"
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
>
<h3 class="text-xl font-bold sm:text-2xl" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ title }}
</h3>
<button
type="button"
class="flex h-11 w-11 items-center justify-center rounded-lg transition duration-200"
:class="[isDarkMode ? 'text-gray-300 hover:bg-gray-800 hover:text-white' : 'text-gray-700 hover:bg-gray-100']"
:aria-label="t('common.close')"
:title="t('common.close')"
@click="emit('close')"
>
<XIcon class="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto" tabindex="0">
<slot></slot>
</div>
</aside>
</div> </div>
<slot></slot> </transition>
</div> </Teleport>
</transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue' import { inject, nextTick, ref, watch } from 'vue'
import { XIcon } from 'lucide-vue-next' import { XIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
interface Props { const props = defineProps<{ visible: boolean; title: string }>()
visible: boolean const emit = defineEmits<{ close: [] }>()
title: string const { t } = useI18n()
}
interface Emits {
close: []
}
defineProps<Props>()
defineEmits<Emits>()
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const drawerRef = ref<HTMLElement>()
let returnFocusElement: HTMLElement | null = null
const focusableSelector =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
emit('close')
return
}
if (event.key !== 'Tab' || !drawerRef.value) return
const focusable = [...drawerRef.value.querySelectorAll<HTMLElement>(focusableSelector)]
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (!first || !last) return
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
watch(
() => props.visible,
async (visible) => {
if (visible) {
returnFocusElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
await nextTick()
const first = drawerRef.value?.querySelector<HTMLElement>(focusableSelector)
;(first || drawerRef.value)?.focus()
} else {
await nextTick()
returnFocusElement?.focus()
returnFocusElement = null
}
}
)
</script> </script>
<style scoped> <style scoped>
.drawer-enter-active, .drawer-enter-active,
.drawer-leave-active { .drawer-leave-active {
transition: transform 0.3s ease; transition: opacity 0.24s ease;
}
.drawer-enter-active aside,
.drawer-leave-active aside {
transition: transform 0.24s ease;
} }
.drawer-enter-from, .drawer-enter-from,
.drawer-leave-to { .drawer-leave-to {
opacity: 0;
}
.drawer-enter-from aside,
.drawer-leave-to aside {
transform: translateX(100%); transform: translateX(100%);
} }
@media (prefers-reduced-motion: reduce) {
@media (min-width: 640px) { .drawer-enter-active,
.sm\:w-120 { .drawer-leave-active,
width: 30rem; .drawer-enter-active aside,
/* 480px */ .drawer-leave-active aside {
transition-duration: 0.01ms;
} }
} }
</style> </style>
+4 -4
View File
@@ -9,10 +9,10 @@
> >
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-xs sm:text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']"> <p class="line-clamp-2 text-xs leading-tight sm:text-sm" :title="title" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
{{ title }} {{ title }}
</p> </p>
<h3 class="mt-1 truncate text-xl font-bold tabular-nums sm:text-2xl" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <h3 class="mt-1 break-words text-xl font-bold leading-tight tabular-nums sm:text-2xl" :title="String(value)" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ value }} {{ value }}
</h3> </h3>
</div> </div>
@@ -20,10 +20,10 @@
class="shrink-0 rounded-full p-2.5 transition-transform duration-300 group-hover:scale-105 sm:p-3" class="shrink-0 rounded-full p-2.5 transition-transform duration-300 group-hover:scale-105 sm:p-3"
:class="iconBgClass" :class="iconBgClass"
> >
<component :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" :class="iconClass" /> <component :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" :class="iconClass" aria-hidden="true" />
</div> </div>
</div> </div>
<p class="mt-2 line-clamp-1 text-xs sm:text-sm" :class="descriptionClass"> <p class="mt-2 line-clamp-2 text-xs sm:text-sm" :class="descriptionClass">
<slot name="description"></slot> <slot name="description"></slot>
</p> </p>
</div> </div>
+6 -1
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue' import { inject } from 'vue'
import { SunIcon, MoonIcon } from 'lucide-vue-next' import { SunIcon, MoonIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
const isDarkMode = inject('isDarkMode') as { value: boolean } const isDarkMode = inject('isDarkMode') as { value: boolean }
const toggleTheme = inject('toggleTheme') as () => void const toggleTheme = inject('toggleTheme') as () => void
const { t } = useI18n()
const toggleColorMode = () => { const toggleColorMode = () => {
toggleTheme() toggleTheme()
@@ -12,8 +14,11 @@ const toggleColorMode = () => {
<template> <template>
<button <button
type="button"
@click="toggleColorMode" @click="toggleColorMode"
class="rounded-full p-2 shadow-sm transition-all duration-500 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2 transform hover:rotate-180" class="flex h-11 w-11 items-center justify-center rounded-full shadow-sm transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:ring-offset-2 motion-safe:hover:rotate-180"
:aria-label="isDarkMode ? t('common.switchToLight') : t('common.switchToDark')"
:title="isDarkMode ? t('common.switchToLight') : t('common.switchToDark')"
:class=" :class="
isDarkMode isDarkMode
? 'bg-[#263941] text-[#d8c98e]' ? 'bg-[#263941] text-[#d8c98e]'
+12 -4
View File
@@ -1,4 +1,5 @@
import { computed, ref, reactive } from 'vue' import { computed, ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { StatsService } from '@/services' import { StatsService } from '@/services'
import type { DashboardData, DashboardHealthSummary, DashboardViewData } from '@/types' import type { DashboardData, DashboardHealthSummary, DashboardViewData } from '@/types'
import { formatFileSize, getErrorMessage } from '@/utils/common' import { formatFileSize, getErrorMessage } from '@/utils/common'
@@ -54,12 +55,12 @@ const clampRatio = (value: number) => Math.max(0, Math.min(100, Math.round(value
const hasOwn = (target: object, key: string) => Object.prototype.hasOwnProperty.call(target, key) const hasOwn = (target: object, key: string) => Object.prototype.hasOwnProperty.call(target, key)
const formatDuration = (startTimestamp: number | null) => { const formatDuration = (startTimestamp: number | null, dayLabel: string, hourLabel: string) => {
if (!startTimestamp) return '-' if (!startTimestamp) return '-'
const uptime = Date.now() - startTimestamp const uptime = Date.now() - startTimestamp
const days = Math.floor(uptime / (24 * 60 * 60 * 1000)) const days = Math.floor(uptime / (24 * 60 * 60 * 1000))
const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000)) const hours = Math.floor((uptime % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000))
return `${days}${hours}小时` return `${days}${dayLabel} ${hours}${hourLabel}`
} }
const healthSummaryKeys: (keyof DashboardHealthSummary)[] = [ const healthSummaryKeys: (keyof DashboardHealthSummary)[] = [
@@ -91,12 +92,15 @@ const normalizeHealthSummary = (detail: DashboardData): DashboardHealthSummary =
}) })
export function useDashboardStats(options: UseDashboardStatsOptions = {}) { export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
const { t, locale } = useI18n()
const dashboardData = reactive<DashboardViewData>(emptyDashboardData()) const dashboardData = reactive<DashboardViewData>(emptyDashboardData())
const isLoading = ref(false) const isLoading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const lastUpdatedAt = ref<Date | null>(null) const lastUpdatedAt = ref<Date | null>(null)
const lastUpdatedText = computed(() => const lastUpdatedText = computed(() =>
lastUpdatedAt.value ? lastUpdatedAt.value.toLocaleString() : '-' lastUpdatedAt.value
? new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(lastUpdatedAt.value)
: '-'
) )
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
@@ -140,7 +144,11 @@ export function useDashboardStats(options: UseDashboardStatsOptions = {}) {
dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize) dashboardData.yesterdaySizeText = formatFileSize(dashboardData.yesterdaySize)
dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize) dashboardData.todaySizeText = formatFileSize(dashboardData.todaySize)
dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit) dashboardData.uploadSizeLimitText = formatFileSize(dashboardData.uploadSizeLimit)
dashboardData.sysUptimeText = formatDuration(dashboardData.sysUptime) dashboardData.sysUptimeText = formatDuration(
dashboardData.sysUptime,
t('common.dayShort'),
t('common.hourShort')
)
dashboardData.activeRatio = dashboardData.totalFiles dashboardData.activeRatio = dashboardData.totalFiles
? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100) ? clampRatio((dashboardData.activeCount / dashboardData.totalFiles) * 100)
: 0 : 0
+45
View File
@@ -10,6 +10,14 @@ export default {
add: 'Add', add: 'Add',
back: 'Back', back: 'Back',
next: 'Next', next: 'Next',
language: 'Select language',
switchToLight: 'Switch to light mode',
switchToDark: 'Switch to dark mode',
showPassword: 'Show password',
hidePassword: 'Hide password',
openMenu: 'Open navigation menu',
closeMenu: 'Close navigation menu',
dialog: 'Dialog',
previous: 'Previous', previous: 'Previous',
loading: 'Loading...', loading: 'Loading...',
noData: 'No Data', noData: 'No Data',
@@ -46,6 +54,8 @@ export default {
second: 'second', second: 'second',
hour: 'hour', hour: 'hour',
day: 'day', day: 'day',
dayShort: 'd',
hourShort: 'h',
times: 'times', times: 'times',
appName: 'FileCodeBox - File Transfer', appName: 'FileCodeBox - File Transfer',
appDescription: 'Ready-to-use file transfer system' appDescription: 'Ready-to-use file transfer system'
@@ -111,6 +121,29 @@ export default {
title: 'Permanent', title: 'Permanent',
description: 'View long-retention share records' description: 'View long-retention share records'
} }
},
topDownloads: {
title: 'Top 5 Downloads',
description: 'Downloads and traffic in the selected date range',
file: 'File',
downloads: 'Downloads',
traffic: 'Traffic',
status: 'Status',
empty: 'No download records',
current: 'Current',
history: 'Historical'
},
trend: {
title: 'Download / Upload Trend',
mobileHint: 'Tap or slide to inspect; drag the bottom date axis to pan',
startDate: 'Start date',
endDate: 'End date',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
reset: 'Reset',
noData: 'No data',
loadFailed: 'Failed to load analytics',
summary: '{window}: {downloads} downloads / {downloadTraffic}, {uploads} uploads / {uploadTraffic}'
} }
}, },
fileManage: { fileManage: {
@@ -313,6 +346,9 @@ export default {
forever: 'Forever', forever: 'Forever',
count: 'By Count', count: 'By Count',
label: 'Expiration Time', label: 'Expiration Time',
method: 'Expiration method',
increment: 'Increase expiration value',
decrement: 'Decrease expiration value',
placeholders: { placeholders: {
days: 'Enter days', days: 'Enter days',
hours: 'Enter hours', hours: 'Enter hours',
@@ -741,6 +777,11 @@ export default {
unsavedChanges: 'Unsaved configuration changes', unsavedChanges: 'Unsaved configuration changes',
allChangesSaved: 'All configuration changes are saved', allChangesSaved: 'All configuration changes are saved',
refreshBlocked: 'Save current changes before refreshing' refreshBlocked: 'Save current changes before refreshing'
,sectionNavigation: 'Settings section navigation'
,reloadSaved: 'Reload saved settings'
,robotsWarning: 'A Disallow: / rule blocks search engines from crawling the entire site.'
,fileSizeUnit: 'File size unit'
,timeUnit: 'Time unit'
}, },
systemSettings: { systemSettings: {
title: 'System Settings', title: 'System Settings',
@@ -924,6 +965,10 @@ export default {
// Components // Components
components: { components: {
pagination: { pagination: {
range: 'Showing {start} to {end} of {total}',
pageSize: 'Items per page',
perPage: '{size}/page',
goToPage: 'Go to page {page}',
showing: 'Showing', showing: 'Showing',
to: 'to', to: 'to',
of: 'of', of: 'of',
+45
View File
@@ -10,6 +10,14 @@ export default {
add: '添加', add: '添加',
back: '返回', back: '返回',
next: '下一页', next: '下一页',
language: '选择语言',
switchToLight: '切换到浅色模式',
switchToDark: '切换到深色模式',
showPassword: '显示密码',
hidePassword: '隐藏密码',
openMenu: '打开导航菜单',
closeMenu: '关闭导航菜单',
dialog: '对话框',
previous: '上一页', previous: '上一页',
loading: '加载中...', loading: '加载中...',
noData: '暂无数据', noData: '暂无数据',
@@ -46,6 +54,8 @@ export default {
second: '秒', second: '秒',
hour: '小时', hour: '小时',
day: '天', day: '天',
dayShort: '天',
hourShort: '小时',
times: '次', times: '次',
appName: '文件快递柜 - FileCodeBox', appName: '文件快递柜 - FileCodeBox',
appDescription: '开箱即用的文件快传系统' appDescription: '开箱即用的文件快传系统'
@@ -111,6 +121,29 @@ export default {
title: '永久有效', title: '永久有效',
description: '查看长期保留的分享记录' description: '查看长期保留的分享记录'
} }
},
topDownloads: {
title: '热门下载 Top 5',
description: '统计当前日期范围内的下载次数和流量',
file: '文件',
downloads: '下载',
traffic: '流量',
status: '状态',
empty: '暂无下载记录',
current: '现存',
history: '历史'
},
trend: {
title: '下载/上传趋势',
mobileHint: '轻触或滑动查看数据;拖动底部日期轴平移图表',
startDate: '开始日期',
endDate: '结束日期',
zoomIn: '放大',
zoomOut: '缩小',
reset: '重置',
noData: '无数据',
loadFailed: '统计加载失败',
summary: '{window},下载 {downloads} 次 / {downloadTraffic},上传 {uploads} 次 / {uploadTraffic}'
} }
}, },
fileManage: { fileManage: {
@@ -342,6 +375,9 @@ export default {
}, },
expiration: { expiration: {
label: '过期时间', label: '过期时间',
method: '过期方式',
increment: '增加过期值',
decrement: '减少过期值',
placeholders: { placeholders: {
days: '输入天数', days: '输入天数',
hours: '输入小时数', hours: '输入小时数',
@@ -769,6 +805,11 @@ export default {
unsavedChanges: '有未保存的配置变更', unsavedChanges: '有未保存的配置变更',
allChangesSaved: '所有配置已保存', allChangesSaved: '所有配置已保存',
refreshBlocked: '请先保存当前变更再刷新' refreshBlocked: '请先保存当前变更再刷新'
,sectionNavigation: '设置分区导航'
,reloadSaved: '重新加载已保存配置'
,robotsWarning: '如果包含 Disallow: /,搜索引擎将无法抓取整个站点。'
,fileSizeUnit: '文件大小单位'
,timeUnit: '时间单位'
}, },
dashboard: { dashboard: {
title: '仪表盘', title: '仪表盘',
@@ -944,6 +985,10 @@ export default {
// 组件相关 // 组件相关
components: { components: {
pagination: { pagination: {
range: '显示第 {start} 到 {end} 条,共 {total} 条',
pageSize: '每页显示数量',
perPage: '{size}/页',
goToPage: '前往第 {page} 页',
showing: '显示第', showing: '显示第',
to: '到', to: '到',
of: '条,共', of: '条,共',
+4 -4
View File
@@ -38,7 +38,7 @@
{{ t('common.appName') }} {{ t('common.appName') }}
</h1> </h1>
</div> </div>
<button @click="toggleSidebar" class="lg:hidden"> <button type="button" :aria-label="t('common.closeMenu')" :title="t('common.closeMenu')" @click="toggleSidebar" class="flex h-11 w-11 items-center justify-center rounded-lg lg:hidden">
<XIcon class="w-6 h-6" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']" /> <XIcon class="w-6 h-6" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']" />
</button> </button>
</div> </div>
@@ -49,7 +49,7 @@
<li v-for="item in menuItems" :key="item.id"> <li v-for="item in menuItems" :key="item.id">
<RouterLink <RouterLink
:to="item.redirect" :to="item.redirect"
class="flex h-10 w-full items-center rounded-lg border-l-4 px-3 text-sm font-medium" class="flex min-h-11 w-full items-center rounded-lg border-l-4 px-3 text-sm font-medium"
:class="[ :class="[
route.name === item.id route.name === item.id
? isDarkMode ? isDarkMode
@@ -72,7 +72,7 @@
<div class="p-4 border-t" :class="[isDarkMode ? 'border-[#31454d]' : 'border-[#c8d7da]']"> <div class="p-4 border-t" :class="[isDarkMode ? 'border-[#31454d]' : 'border-[#c8d7da]']">
<button <button
@click="handleLogout" @click="handleLogout"
class="flex items-center w-full p-2 rounded-lg transition-colors duration-200" class="flex min-h-11 w-full items-center rounded-lg p-2 transition-colors duration-200"
:class="[ :class="[
isDarkMode isDarkMode
? 'text-[#aebfc3] hover:bg-[#22353c] hover:text-[#edf4f4]' ? 'text-[#aebfc3] hover:bg-[#22353c] hover:text-[#edf4f4]'
@@ -93,7 +93,7 @@
:class="[isDarkMode ? 'bg-[#1a2930] border-[#31454d]' : 'bg-[#f2f6f5] border-[#c8d7da]']" :class="[isDarkMode ? 'bg-[#1a2930] border-[#31454d]' : 'bg-[#f2f6f5] border-[#c8d7da]']"
> >
<div class="flex h-14 items-center justify-between px-4"> <div class="flex h-14 items-center justify-between px-4">
<button @click="toggleSidebar" class="lg:hidden"> <button type="button" :aria-label="t('common.openMenu')" :title="t('common.openMenu')" @click="toggleSidebar" class="flex h-11 w-11 items-center justify-center rounded-lg lg:hidden">
<MenuIcon class="w-6 h-6" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']" /> <MenuIcon class="w-6 h-6" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']" />
</button> </button>
</div> </div>
+2
View File
@@ -7,6 +7,8 @@ import App from './App.vue'
import router from './router' import router from './router'
import i18n from './i18n' import i18n from './i18n'
document.documentElement.lang = i18n.global.locale.value
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
+3 -2
View File
@@ -82,7 +82,7 @@
</button> </button>
</form> </form>
<div class="mt-6 text-center"> <div class="mt-6 text-center">
<router-link to="/" class="text-indigo-400 hover:text-indigo-300 transition duration-300"> <router-link to="/" class="inline-flex min-h-11 items-center rounded-lg px-3 text-[#3f6a77] transition duration-200 hover:bg-[#dcebed] hover:text-[#315c69] dark:text-[#b8d4dc] dark:hover:bg-[#294047]">
{{ t('send.needRetrieveFile') }} {{ t('send.needRetrieveFile') }}
</router-link> </router-link>
</div> </div>
@@ -100,8 +100,9 @@
{{ t('send.secureEncryption') }} {{ t('send.secureEncryption') }}
</span> </span>
<button <button
type="button"
@click="toggleDrawer" @click="toggleDrawer"
class="text-sm hover:text-indigo-300 transition duration-300 flex items-center" class="flex min-h-11 items-center rounded-lg px-2 text-sm transition duration-200 hover:bg-indigo-50 dark:hover:bg-gray-700"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
> >
{{ t('send.sendRecords') }} {{ t('send.sendRecords') }}
+36 -28
View File
@@ -236,18 +236,18 @@
<div class="mb-5 flex flex-col gap-4"> <div class="mb-5 flex flex-col gap-4">
<div class="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-start xl:justify-between"> <div class="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<div class="min-w-0"> <div class="min-w-0">
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3> <h3 class="text-lg font-semibold" :class="[primaryTextClass]">{{ t('admin.dashboard.trend.title') }}</h3>
<p class="break-words text-sm leading-5" :class="[mutedTextClass]"> <p class="break-words text-sm leading-5" :class="[mutedTextClass]">
{{ analyticsSummaryText }} {{ analyticsSummaryText }}
</p> </p>
<p class="mt-1 text-xs sm:hidden" :class="[mutedTextClass]">轻触或滑动查看数据拖动底部日期轴平移图表</p> <p class="mt-1 text-xs sm:hidden" :class="[mutedTextClass]">{{ t('admin.dashboard.trend.mobileHint') }}</p>
</div> </div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 xl:flex-nowrap"> <div class="flex shrink-0 flex-wrap items-center justify-end gap-2 xl:flex-nowrap">
<div class="analytics-date-range flex min-w-0 items-center justify-center gap-1.5 sm:gap-2"> <div class="analytics-date-range flex min-w-0 items-center justify-center gap-1.5 sm:gap-2">
<NativeDateSelect <NativeDateSelect
v-model="analyticsStart" v-model="analyticsStart"
:field-class="fieldClass" :field-class="fieldClass"
aria-label="开始日期" :aria-label="t('admin.dashboard.trend.startDate')"
@change="fetchAnalyticsData" @change="fetchAnalyticsData"
/> />
<span <span
@@ -259,7 +259,7 @@
<NativeDateSelect <NativeDateSelect
v-model="analyticsEnd" v-model="analyticsEnd"
:field-class="fieldClass" :field-class="fieldClass"
aria-label="结束日期" :aria-label="t('admin.dashboard.trend.endDate')"
@change="fetchAnalyticsData" @change="fetchAnalyticsData"
/> />
</div> </div>
@@ -270,7 +270,7 @@
class="h-9 min-w-16" class="h-9 min-w-16"
@click="trendCanvas?.zoom(0.75)" @click="trendCanvas?.zoom(0.75)"
> >
放大 {{ t('admin.dashboard.trend.zoomIn') }}
</BaseButton> </BaseButton>
<BaseButton <BaseButton
variant="outline" variant="outline"
@@ -278,7 +278,7 @@
class="h-9 min-w-16" class="h-9 min-w-16"
@click="trendCanvas?.zoom(1.33)" @click="trendCanvas?.zoom(1.33)"
> >
缩小 {{ t('admin.dashboard.trend.zoomOut') }}
</BaseButton> </BaseButton>
<BaseButton <BaseButton
variant="outline" variant="outline"
@@ -286,7 +286,7 @@
class="h-9 min-w-16" class="h-9 min-w-16"
@click="trendCanvas?.resetWindow()" @click="trendCanvas?.resetWindow()"
> >
重置 {{ t('admin.dashboard.trend.reset') }}
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
@@ -317,15 +317,15 @@
> >
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
<div> <div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">热门下载 Top 5</h3> <h3 class="text-lg font-semibold" :class="[primaryTextClass]">{{ t('admin.dashboard.topDownloads.title') }}</h3>
<p class="text-sm" :class="[mutedTextClass]">统计当前日期范围内的下载次数和流量</p> <p class="text-sm" :class="[mutedTextClass]">{{ t('admin.dashboard.topDownloads.description') }}</p>
</div> </div>
<DownloadCloudIcon <DownloadCloudIcon
class="h-5 w-5" class="h-5 w-5"
:class="[isDarkMode ? 'text-[#b8d4dc]' : 'text-[#5f8796]']" :class="[isDarkMode ? 'text-[#b8d4dc]' : 'text-[#5f8796]']"
/> />
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto" tabindex="0" role="region" :aria-label="t('admin.dashboard.topDownloads.title')">
<table <table
class="min-w-full divide-y" class="min-w-full divide-y"
:class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-200']"
@@ -336,32 +336,32 @@
class="px-4 py-3 text-left text-xs font-semibold uppercase" class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]" :class="[mutedTextClass]"
> >
文件 {{ t('admin.dashboard.topDownloads.file') }}
</th> </th>
<th <th
class="px-4 py-3 text-left text-xs font-semibold uppercase" class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]" :class="[mutedTextClass]"
> >
下载 {{ t('admin.dashboard.topDownloads.downloads') }}
</th> </th>
<th <th
class="px-4 py-3 text-left text-xs font-semibold uppercase" class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]" :class="[mutedTextClass]"
> >
流量 {{ t('admin.dashboard.topDownloads.traffic') }}
</th> </th>
<th <th
class="px-4 py-3 text-left text-xs font-semibold uppercase" class="px-4 py-3 text-left text-xs font-semibold uppercase"
:class="[mutedTextClass]" :class="[mutedTextClass]"
> >
状态 {{ t('admin.dashboard.topDownloads.status') }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']"> <tbody class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-gray-100']">
<tr v-if="analyticsTopFiles.length === 0"> <tr v-if="analyticsTopFiles.length === 0">
<td colspan="4" class="px-4 py-8 text-center text-sm" :class="[mutedTextClass]"> <td colspan="4" class="px-4 py-8 text-center text-sm" :class="[mutedTextClass]">
暂无下载记录 {{ t('admin.dashboard.topDownloads.empty') }}
</td> </td>
</tr> </tr>
<tr <tr
@@ -383,7 +383,7 @@
{{ formatBytes(file.download_traffic) }} {{ formatBytes(file.download_traffic) }}
</td> </td>
<td class="px-4 py-3 text-sm" :class="[mutedTextClass]"> <td class="px-4 py-3 text-sm" :class="[mutedTextClass]">
{{ file.current ? '现存' : '历史' }} {{ file.current ? t('admin.dashboard.topDownloads.current') : t('admin.dashboard.topDownloads.history') }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -497,7 +497,7 @@ const analyticsEnd = ref(today())
const analyticsData = ref<AnalyticsData | null>(null) const analyticsData = ref<AnalyticsData | null>(null)
const analyticsError = ref('') const analyticsError = ref('')
const isAnalyticsLoading = ref(false) const isAnalyticsLoading = ref(false)
const analyticsWindowText = ref('无数据') const analyticsWindowText = ref(t('admin.dashboard.trend.noData'))
const analyticsDailyRows = computed(() => analyticsData.value?.daily || []) const analyticsDailyRows = computed(() => analyticsData.value?.daily || [])
const analyticsTopFiles = computed<AnalyticsFileRow[]>(() => analyticsData.value?.topFiles || []) const analyticsTopFiles = computed<AnalyticsFileRow[]>(() => analyticsData.value?.topFiles || [])
const analyticsUploadTrafficText = computed(() => const analyticsUploadTrafficText = computed(() =>
@@ -506,13 +506,14 @@ const analyticsUploadTrafficText = computed(() =>
const analyticsDownloadTrafficText = computed(() => const analyticsDownloadTrafficText = computed(() =>
formatBytes(analyticsData.value?.totals?.downloadTraffic || 0) formatBytes(analyticsData.value?.totals?.downloadTraffic || 0)
) )
const analyticsSummaryText = computed( const analyticsSummaryText = computed(() =>
() => t('admin.dashboard.trend.summary', {
`${analyticsWindowText.value},下载 ${analyticsData.value?.totals?.totalDownloads || 0} 次 / ${ window: analyticsWindowText.value,
analyticsDownloadTrafficText.value downloads: analyticsData.value?.totals?.totalDownloads || 0,
},上传 ${analyticsData.value?.totals?.totalUploads || 0} 次 / ${ downloadTraffic: analyticsDownloadTrafficText.value,
analyticsUploadTrafficText.value uploads: analyticsData.value?.totals?.totalUploads || 0,
}` uploadTraffic: analyticsUploadTrafficText.value
})
) )
const formatBytes = (value: number | string | undefined) => formatFileSize(Number(value || 0), 1) const formatBytes = (value: number | string | undefined) => formatFileSize(Number(value || 0), 1)
@@ -522,7 +523,7 @@ const fetchAnalyticsData = async () => {
try { try {
analyticsData.value = await fetchAnalytics(analyticsStart.value, analyticsEnd.value) analyticsData.value = await fetchAnalytics(analyticsStart.value, analyticsEnd.value)
} catch (error) { } catch (error) {
analyticsError.value = error instanceof Error ? error.message : '统计加载失败' analyticsError.value = error instanceof Error ? error.message : t('admin.dashboard.trend.loadFailed')
} finally { } finally {
isAnalyticsLoading.value = false isAnalyticsLoading.value = false
} }
@@ -641,10 +642,17 @@ const MetricProgress = defineComponent({
h('span', { class: 'line-clamp-1 text-gray-500 dark:text-gray-400' }, props.label), h('span', { class: 'line-clamp-1 text-gray-500 dark:text-gray-400' }, props.label),
h('span', { class: 'font-medium tabular-nums text-gray-900 dark:text-white' }, `${props.value}%`) h('span', { class: 'font-medium tabular-nums text-gray-900 dark:text-white' }, `${props.value}%`)
]), ]),
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [ h('div', {
class: 'h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700',
role: 'progressbar',
'aria-label': props.label,
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': props.value
}, [
h('div', { h('div', {
class: ['h-full rounded-full', toneClass.value], class: ['h-full rounded-full', toneClass.value],
style: { width: `${props.value}%` } style: { width: `${Math.max(0, Math.min(100, props.value))}%` }
}) })
]), ]),
h('p', { class: 'mt-2 hidden text-sm text-gray-500 sm:block dark:text-gray-400' }, props.detail) h('p', { class: 'mt-2 hidden text-sm text-gray-500 sm:block dark:text-gray-400' }, props.detail)
@@ -669,7 +677,7 @@ const PolicyRow = defineComponent({
}, },
[ [
h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label), h('span', { class: 'text-sm text-gray-500 dark:text-gray-400' }, props.label),
h('span', { class: 'text-sm font-medium text-gray-900 dark:text-white' }, props.value) h('span', { class: 'min-w-0 break-words text-right text-sm font-medium text-gray-900 dark:text-white' }, props.value)
] ]
) )
} }
+7 -13
View File
@@ -21,7 +21,7 @@
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<p class="text-xs sm:text-sm" :class="[mutedTextClass]">{{ card.label }}</p> <p class="text-xs sm:text-sm" :class="[mutedTextClass]">{{ card.label }}</p>
<p class="mt-1.5 truncate text-xl font-semibold tabular-nums sm:mt-2 sm:text-2xl" :class="[primaryTextClass]"> <p class="mt-1.5 break-words text-xl font-semibold leading-tight tabular-nums sm:mt-2 sm:text-2xl" :title="String(card.value)" :class="[primaryTextClass]">
{{ card.value }} {{ card.value }}
</p> </p>
</div> </div>
@@ -58,7 +58,7 @@
</template> </template>
{{ t('fileManage.resetFilters') }} {{ t('fileManage.resetFilters') }}
</BaseButton> </BaseButton>
<BaseButton variant="secondary" @click="isFilterPanelOpen = !isFilterPanelOpen"> <BaseButton variant="secondary" :aria-expanded="filterBodyVisible" aria-controls="file-filter-panel" @click="isFilterPanelOpen = !isFilterPanelOpen">
<template #icon> <template #icon>
<FilterIcon class="mr-2 h-4 w-4" /> <FilterIcon class="mr-2 h-4 w-4" />
</template> </template>
@@ -68,7 +68,7 @@
</div> </div>
<Transition name="fcb-expand-120"> <Transition name="fcb-expand-120">
<div v-if="filterBodyVisible" class="fcb-expand-content mt-4 grid gap-4"> <div id="file-filter-panel" v-if="filterBodyVisible" class="fcb-expand-content mt-4 grid gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center">
<label class="min-w-0 flex-1"> <label class="min-w-0 flex-1">
<span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span> <span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span>
@@ -331,15 +331,9 @@
</div> </div>
</button> </button>
</div> </div>
<div class="mt-3 grid grid-cols-3 gap-2 pl-14"> <div class="mt-3 pl-14">
<button type="button" class="flex min-h-11 items-center justify-center rounded-lg border text-sm font-medium" :class="detailActionClass" @click="openFileDetail(file)"> <button type="button" class="flex min-h-11 w-full items-center justify-center rounded-lg border text-sm font-medium" :class="detailActionClass" @click="openFileDetail(file)">
<FileTextIcon class="mr-1.5 h-4 w-4" />{{ t('fileManage.detail') }} <FileTextIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />{{ t('fileManage.detail') }}
</button>
<button type="button" class="flex min-h-11 items-center justify-center rounded-lg border text-sm font-medium" :class="detailActionClass" :disabled="Boolean(downloadingFileId)" @click="downloadFile(file)">
<DownloadIcon class="mr-1.5 h-4 w-4" />{{ file.isTextFile ? t('fileManage.exportText') : t('fileManage.downloadFile') }}
</button>
<button type="button" class="flex min-h-11 items-center justify-center rounded-lg border text-sm font-medium" :class="detailActionClass" @click="openEditModal(file)">
<PencilIcon class="mr-1.5 h-4 w-4" />{{ t('common.edit') }}
</button> </button>
</div> </div>
</article> </article>
@@ -357,7 +351,7 @@
</section> </section>
<div class="hidden md:block"> <div class="hidden md:block">
<DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders"> <DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders" table-class="min-w-[1120px]">
<template #actions> <template #actions>
<BaseButton variant="secondary" :loading="isLoading" @click="refreshFileList"> <BaseButton variant="secondary" :loading="isLoading" @click="refreshFileList">
<template #icon> <template #icon>
+103 -16
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted } from 'vue' import { inject, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { RefreshCwIcon, SaveIcon } from 'lucide-vue-next' import { EyeIcon, EyeOffIcon, RefreshCwIcon, SaveIcon } from 'lucide-vue-next'
import BaseButton from '@/components/common/BaseButton.vue' import BaseButton from '@/components/common/BaseButton.vue'
import SettingNumberInput from '@/components/common/SettingNumberInput.vue' import SettingNumberInput from '@/components/common/SettingNumberInput.vue'
import SettingSwitch from '@/components/common/SettingSwitch.vue' import SettingSwitch from '@/components/common/SettingSwitch.vue'
@@ -9,6 +9,7 @@ import { useSystemConfig } from '@/composables'
const isDarkMode = inject('isDarkMode') const isDarkMode = inject('isDarkMode')
const { t } = useI18n() const { t } = useI18n()
const showAdminPassword = ref(false)
const { const {
config, config,
isRefreshing, isRefreshing,
@@ -29,7 +30,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="p-6"> <div class="p-4 pb-28 sm:p-6 sm:pb-28">
<div <div
class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between" class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"
> >
@@ -68,8 +69,28 @@ onMounted(() => {
</div> </div>
</div> </div>
<nav
class="mb-4 flex gap-2 overflow-x-auto pb-2"
:aria-label="t('manage.settings.sectionNavigation')"
>
<a
v-for="section in [
['general', t('manage.settings.basicSettings')],
['storage', t('manage.settings.storageSettings')],
['upload', t('manage.settings.uploadLimits')],
['access', t('manage.settings.errorLimits')]
]"
:key="section[0]"
:href="`#settings-${section[0]}`"
class="inline-flex min-h-11 shrink-0 items-center rounded-full border px-4 text-sm font-medium"
:class="isDarkMode ? 'border-[#40545c] bg-[#263941] text-[#d8e3e5]' : 'border-[#c8d8dc] bg-[#edf4f5] text-[#415d66]'"
>
{{ section[1] }}
</a>
</nav>
<div <div
class="theme-2026-card theme-2026-card-hover space-y-8 rounded-[30px] border p-6 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg" class="theme-2026-card theme-2026-card-hover mx-auto max-w-6xl space-y-8 rounded-[24px] border p-4 shadow-md transition-all duration-200 hover:shadow-lg sm:rounded-[30px] sm:p-6"
:class="[ :class="[
isDarkMode isDarkMode
? 'border-[#40545c] bg-[#202f36]/90 hover:border-[#7199a8] hover:bg-[#263941] hover:shadow-black/25' ? 'border-[#40545c] bg-[#202f36]/90 hover:border-[#7199a8] hover:bg-[#263941] hover:shadow-black/25'
@@ -77,7 +98,7 @@ onMounted(() => {
]" ]"
> >
<!-- 基本设置 --> <!-- 基本设置 -->
<section class="space-y-4"> <section id="settings-general" class="scroll-mt-4 space-y-4">
<h3 class="text-lg font-medium mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"> <h3 class="text-lg font-medium mb-4" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ t('admin.settings.basicSettings') }} {{ t('admin.settings.basicSettings') }}
</h3> </h3>
@@ -92,8 +113,10 @@ onMounted(() => {
{{ t('admin.settings.siteName') }} {{ t('admin.settings.siteName') }}
</label> </label>
<input <input
id="settings-site-name"
type="text" type="text"
v-model="config.name" v-model="config.name"
:aria-label="t('admin.settings.siteName')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -111,8 +134,10 @@ onMounted(() => {
{{ t('admin.settings.websiteDescription') }} {{ t('admin.settings.websiteDescription') }}
</label> </label>
<input <input
id="settings-description"
type="text" type="text"
v-model="config.description" v-model="config.description"
:aria-label="t('admin.settings.websiteDescription')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -131,24 +156,35 @@ onMounted(() => {
</label> </label>
<div class="relative"> <div class="relative">
<input <input
type="password" id="settings-admin-password"
:type="showAdminPassword ? 'text' : 'password'"
minlength="6" minlength="6"
v-model="config.admin_token" v-model="config.admin_token"
:placeholder="t('admin.settings.passwordPlaceholder')" :placeholder="t('admin.settings.passwordPlaceholder')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" autocomplete="new-password"
aria-describedby="settings-admin-password-note"
:aria-label="t('admin.settings.adminPassword')"
class="w-full rounded-md border px-4 py-2.5 pr-14 shadow-sm outline-none transition-all duration-200 ease-in-out focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
:class="[ :class="[
isDarkMode isDarkMode
? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500' ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400 hover:border-gray-500'
: 'border-gray-300 hover:border-gray-400 placeholder-gray-500' : 'border-gray-300 hover:border-gray-400 placeholder-gray-500'
]" ]"
/> />
<div <button
class="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-gray-400" type="button"
:class="[isDarkMode ? 'text-gray-500' : 'text-gray-400']" class="absolute inset-y-0 right-1 flex w-11 items-center justify-center rounded-lg"
:aria-label="showAdminPassword ? t('common.hidePassword') : t('common.showPassword')"
:title="showAdminPassword ? t('common.hidePassword') : t('common.showPassword')"
@click="showAdminPassword = !showAdminPassword"
> >
<span class="text-xs">{{ t('admin.settings.passwordNote') }}</span> <EyeOffIcon v-if="showAdminPassword" class="h-5 w-5" aria-hidden="true" />
</div> <EyeIcon v-else class="h-5 w-5" aria-hidden="true" />
</button>
</div> </div>
<p id="settings-admin-password-note" class="text-xs" :class="isDarkMode ? 'text-gray-300' : 'text-gray-600'">
{{ t('admin.settings.passwordNote') }}
</p>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -159,8 +195,10 @@ onMounted(() => {
{{ t('admin.settings.keywords') }} {{ t('admin.settings.keywords') }}
</label> </label>
<input <input
id="settings-keywords"
type="text" type="text"
v-model="config.keywords" v-model="config.keywords"
:aria-label="t('admin.settings.keywords')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -179,7 +217,9 @@ onMounted(() => {
{{ t('manage.settings.themeSelection') }} {{ t('manage.settings.themeSelection') }}
</label> </label>
<select <select
id="settings-theme"
v-model="config.themesSelect" v-model="config.themesSelect"
:aria-label="t('manage.settings.themeSelection')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer"
:class="[ :class="[
isDarkMode isDarkMode
@@ -203,8 +243,10 @@ onMounted(() => {
{{ t('manage.settings.robotsFile') }} {{ t('manage.settings.robotsFile') }}
</label> </label>
<textarea <textarea
id="settings-robots"
v-model="config.robotsText" v-model="config.robotsText"
rows="3" :aria-label="t('manage.settings.robotsFile')"
rows="5"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -227,6 +269,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.notify_title" v-model="config.notify_title"
:aria-label="t('manage.settings.notificationTitle')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -245,6 +288,7 @@ onMounted(() => {
</label> </label>
<textarea <textarea
v-model="config.notify_content" v-model="config.notify_content"
:aria-label="t('manage.settings.notificationContent')"
rows="3" rows="3"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
@@ -257,7 +301,7 @@ onMounted(() => {
</div> </div>
<!-- 存储设置 --> <!-- 存储设置 -->
<div class="space-y-4"> <div id="settings-storage" class="scroll-mt-4 space-y-4 border-t pt-8" :class="isDarkMode ? 'border-gray-700' : 'border-gray-200'">
<h3 <h3
class="text-lg font-medium mb-4" class="text-lg font-medium mb-4"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"
@@ -276,6 +320,7 @@ onMounted(() => {
type="text" type="text"
:placeholder="t('manage.settings.storagePathPlaceholder')" :placeholder="t('manage.settings.storagePathPlaceholder')"
v-model="config.storage_path" v-model="config.storage_path"
:aria-label="t('manage.settings.storagePath')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -295,6 +340,7 @@ onMounted(() => {
</label> </label>
<select <select
v-model="config.file_storage" v-model="config.file_storage"
:aria-label="t('manage.settings.storageMethod')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border appearance-none bg-no-repeat bg-right focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none cursor-pointer"
:class="[ :class="[
isDarkMode isDarkMode
@@ -331,6 +377,7 @@ onMounted(() => {
type="text" type="text"
:placeholder="t('manage.settings.webdavUrlPlaceholder')" :placeholder="t('manage.settings.webdavUrlPlaceholder')"
v-model="config.webdav_url" v-model="config.webdav_url"
:aria-label="'WebDAV URL'"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -351,6 +398,7 @@ onMounted(() => {
type="text" type="text"
:placeholder="t('manage.settings.webdavUsernamePlaceholder')" :placeholder="t('manage.settings.webdavUsernamePlaceholder')"
v-model="config.webdav_username" v-model="config.webdav_username"
:aria-label="'WebDAV Username'"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -371,6 +419,7 @@ onMounted(() => {
type="password" type="password"
:placeholder="t('manage.settings.webdavPasswordPlaceholder')" :placeholder="t('manage.settings.webdavPasswordPlaceholder')"
v-model="config.webdav_password" v-model="config.webdav_password"
:aria-label="'WebDAV Password'"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -394,6 +443,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.s3_access_key_id" v-model="config.s3_access_key_id"
:aria-label="t('manage.settings.s3AccessKeyId')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -413,6 +463,7 @@ onMounted(() => {
<input <input
type="password" type="password"
v-model="config.s3_secret_access_key" v-model="config.s3_secret_access_key"
:aria-label="t('manage.settings.s3SecretAccessKey')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -432,6 +483,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.s3_bucket_name" v-model="config.s3_bucket_name"
:aria-label="t('manage.settings.s3BucketName')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -451,6 +503,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.s3_endpoint_url" v-model="config.s3_endpoint_url"
:aria-label="t('manage.settings.s3EndpointUrl')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -470,6 +523,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.s3_region_name" v-model="config.s3_region_name"
:aria-label="t('manage.settings.s3RegionName')"
:placeholder="t('manage.settings.autoPlaceholder')" :placeholder="t('manage.settings.autoPlaceholder')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
@@ -489,6 +543,7 @@ onMounted(() => {
</label> </label>
<select <select
v-model="config.s3_signature_version" v-model="config.s3_signature_version"
:aria-label="t('manage.settings.s3SignatureVersion')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -511,6 +566,7 @@ onMounted(() => {
<input <input
type="text" type="text"
v-model="config.s3_hostname" v-model="config.s3_hostname"
:aria-label="t('manage.settings.s3Hostname')"
class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-full rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -541,7 +597,7 @@ onMounted(() => {
</div> </div>
<!-- 上传限制 --> <!-- 上传限制 -->
<div class="mt-8"> <div id="settings-upload" class="scroll-mt-4 border-t pt-8" :class="isDarkMode ? 'border-gray-700' : 'border-gray-200'">
<h3 <h3
class="text-lg font-medium mb-4" class="text-lg font-medium mb-4"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"
@@ -573,6 +629,7 @@ onMounted(() => {
<input <input
type="number" type="number"
v-model="fileSize" v-model="fileSize"
:aria-label="t('manage.settings.fileSizeLimit')"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -582,6 +639,7 @@ onMounted(() => {
/> />
<select <select
v-model="sizeUnit" v-model="sizeUnit"
:aria-label="t('manage.settings.fileSizeUnit')"
class="rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -644,6 +702,7 @@ onMounted(() => {
<input <input
type="number" type="number"
v-model="saveTime" v-model="saveTime"
:aria-label="t('manage.settings.maxSaveTime')"
class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="w-24 rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -653,6 +712,7 @@ onMounted(() => {
/> />
<select <select
v-model="saveTimeUnit" v-model="saveTimeUnit"
:aria-label="t('manage.settings.timeUnit')"
class="rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none" class="rounded-md shadow-sm px-4 py-2.5 transition-all duration-200 ease-in-out border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none"
:class="[ :class="[
isDarkMode isDarkMode
@@ -679,7 +739,7 @@ onMounted(() => {
</div> </div>
<!-- 错误限制 --> <!-- 错误限制 -->
<div class="mt-8"> <div id="settings-access" class="scroll-mt-4 border-t pt-8" :class="isDarkMode ? 'border-gray-700' : 'border-gray-200'">
<h3 <h3
class="text-lg font-medium mb-4" class="text-lg font-medium mb-4"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']" :class="[isDarkMode ? 'text-white' : 'text-gray-800']"
@@ -703,6 +763,33 @@ onMounted(() => {
</div> </div>
</section> </section>
</div> </div>
<div
class="fixed inset-x-0 bottom-0 z-40 border-t px-4 py-3 backdrop-blur-xl lg:left-64"
:class="isDarkMode ? 'border-[#31454d] bg-[#17252b]/95' : 'border-[#c8d7da] bg-[#f2f6f5]/95'"
role="status"
aria-live="polite"
>
<div class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<p class="text-sm font-medium" :class="isDirty ? 'text-amber-600 dark:text-amber-300' : isDarkMode ? 'text-gray-300' : 'text-[#415d66]'">
{{ isDirty ? t('manage.settings.unsavedChanges') : t('manage.settings.allChangesSaved') }}
</p>
<div class="flex items-center gap-2">
<BaseButton
variant="secondary"
:loading="isRefreshing"
:disabled="isSaving || isDirty"
@click="refreshConfig"
>
{{ t('manage.settings.reloadSaved') }}
</BaseButton>
<BaseButton :loading="isSaving" :disabled="!isDirty || isRefreshing" @click="submitConfig">
<template #icon><SaveIcon class="mr-2 h-4 w-4" /></template>
{{ isSaving ? t('manage.settings.saving') : t('manage.settings.saveChanges') }}
</BaseButton>
</div>
</div>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>