全面优化 2026 前端响应式体验与加载性能

完成 FileCodeBox 2026 全站桌面端、平板和移动端的视觉交互复核,补齐文件选择后的批量操作闭环,并降低公共首屏的字体与低频依赖成本。

- 为移动文件列表增加全选、已选数量、批量编辑、策略与删除固定栏,并补充详情删除和空筛选重置
- 优化设置保存栏、分区导航、发送过期控件、仪表盘日期与图表操作、分页和记录抽屉
- 为后台侧栏增加遮罩、滚动锁定、Escape 关闭和焦点返回,完善登录密码可见性与国际化
- 移除全站强制 DingTalk 字体,按需加载 JSZip、Marked 和 DOMPurify,并停止空闲提示定时器
- 已通过架构检查、Vue 类型检查、ESLint、生产构建、48 组多视口明暗模式矩阵及核心流程回归
This commit is contained in:
2026-07-21 16:44:45 +08:00
parent fa6b484d17
commit aa3743b42d
26 changed files with 488 additions and 441 deletions
+8 -1
View File
@@ -17,7 +17,14 @@ const {
<template>
<div :class="['app-container', isDarkMode ? 'dark' : 'light']">
<div v-if="showGlobalControls" class="fixed top-4 right-4 z-50 flex items-center space-x-3">
<div
v-if="showGlobalControls"
class="fixed z-50 flex items-center space-x-3"
:style="{
top: 'max(1rem, env(safe-area-inset-top))',
right: 'max(1rem, env(safe-area-inset-right))'
}"
>
<LanguageSwitcher />
<ThemeToggle v-model="isDarkMode" />
</div>
+5 -10
View File
@@ -2,21 +2,16 @@
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@font-face {
font-family: 'DingTalk';
src: url('@/assets/font/DingTalk.ttf') format('truetype');
}
* {
font-family: 'DingTalk', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
font-style: normal;
}
html {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
body {
font-family: inherit;
}
:focus-visible {
outline: 3px solid rgba(95, 135, 150, 0.78);
outline-offset: 3px;
+2 -8
View File
@@ -49,14 +49,14 @@
import { storeToRefs } from 'pinia'
import { useAlertStore } from '@/stores/alertStore'
import { CheckCircle, AlertTriangle, AlertCircle, Info, X } from 'lucide-vue-next'
import { onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const alertStore = useAlertStore()
const { alerts } = storeToRefs(alertStore)
const { removeAlert, startProgressTimer, stopProgressTimer } = alertStore
const { removeAlert } = alertStore
const gradientClasses = {
success: 'from-[#6f9688] to-[#5f8377]',
@@ -72,13 +72,7 @@ const alertIcons = {
info: Info
}
onMounted(() => {
startProgressTimer()
})
onUnmounted(() => {
stopProgressTimer()
})
</script>
<style scoped>
+15 -10
View File
@@ -1,16 +1,17 @@
<template>
<div
<nav
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]']"
:aria-label="t('components.pagination.navigation')"
>
<div class="text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
{{ t('components.pagination.range', { start: rangeStart, end: rangeEnd, total }) }}
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 sm:flex sm:flex-wrap">
<select
:value="pageSize"
class="min-h-11 rounded-[30px] border px-3 text-sm shadow-sm transition-colors sm:min-h-9"
class="col-span-3 min-h-11 rounded-[30px] border px-3 text-sm shadow-sm transition-colors sm:col-auto lg:min-h-9"
:class="[
isDarkMode
? 'border-[#40545c] bg-[#263941] text-[#d8e3e5] hover:border-[#7199a8]'
@@ -27,20 +28,24 @@
<button
type="button"
:disabled="currentPage === 1"
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="inline-flex min-h-11 items-center justify-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md lg:min-h-9"
:class="buttonClass(currentPage === 1)"
@click="$emit('page-change', currentPage - 1)"
>
<ChevronLeftIcon class="h-4 w-4" aria-hidden="true" />
{{ t('common.previous') }}
<span class="max-[359px]:sr-only">{{ t('common.previous') }}</span>
</button>
<div class="flex items-center gap-1">
<span class="text-center text-sm font-medium sm:hidden" :class="[isDarkMode ? 'text-gray-200' : 'text-gray-700']">
{{ currentPage }} / {{ totalPages }}
</span>
<div class="hidden items-center gap-1 sm:flex">
<template v-for="pageNum in displayedPages" :key="pageNum">
<button
v-if="pageNum !== '...'"
type="button"
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="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 lg:min-h-9 lg:min-w-9"
:class="[
currentPage === pageNum
? 'bg-[#5f8796] text-white shadow-[#9fb8bf]/35'
@@ -61,15 +66,15 @@
<button
type="button"
:disabled="currentPage >= totalPages"
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="inline-flex min-h-11 items-center justify-center rounded-[30px] px-3 py-1.5 shadow-sm transition-all duration-200 hover:shadow-md lg:min-h-9"
:class="buttonClass(currentPage >= totalPages)"
@click="$emit('page-change', currentPage + 1)"
>
{{ t('common.next') }}
<span class="max-[359px]:sr-only">{{ t('common.next') }}</span>
<ChevronRightIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
+71 -146
View File
@@ -1,137 +1,67 @@
<template>
<div class="flex flex-col space-y-3">
<label for="send-expiration-value" :class="['text-sm font-medium', isDarkMode ? 'text-gray-300' : 'text-gray-700']">
<div class="space-y-3">
<label for="send-expiration-value" class="block text-sm font-medium" :class="isDarkMode ? 'text-gray-300' : 'text-gray-700'">
{{ t('send.expiration.label') }}
</label>
<div class="relative flex-grow group">
<div
:class="[
'relative h-12 rounded-2xl border transition-all duration-300 shadow-sm',
isDarkMode
? 'bg-gray-800/60 border-gray-700/60 group-hover:border-gray-600/80 group-hover:shadow-lg group-hover:shadow-gray-900/20'
: 'bg-white border-gray-200 group-hover:border-gray-300 group-hover:shadow-md group-hover:shadow-gray-200/50'
]"
<div v-if="expirationMethod !== 'forever'" class="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] gap-2">
<button
type="button"
class="flex h-11 w-11 items-center justify-center rounded-xl border text-xl font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40"
:class="controlClass"
:disabled="numericValue <= 1"
:aria-label="t('send.expiration.decrement')"
@click="incrementValue(-1)"
>
<template v-if="expirationMethod !== 'forever'">
<input
id="send-expiration-value"
:value="expirationValue"
@input="updateValue"
type="number"
:placeholder="getPlaceholder()"
min="1"
:class="[
'w-full h-full px-5 pr-32 rounded-2xl placeholder-gray-400 transition-all duration-300',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
'bg-transparent',
isDarkMode
? 'text-gray-100 focus:ring-indigo-500/80 placeholder-gray-500'
: 'text-gray-900 focus:ring-indigo-500/60 placeholder-gray-400'
]"
/>
<div
class="absolute right-28 top-0 h-full flex flex-col border-l"
:class="[isDarkMode ? 'border-gray-700/60' : 'border-gray-200']"
>
<button
type="button"
:aria-label="t('send.expiration.increment')"
@click="incrementValue(1)"
class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
:class="[
isDarkMode
? 'hover:bg-gray-700/60 text-gray-400 hover:text-gray-200'
: 'hover:bg-gray-50 text-gray-500 hover:text-gray-700'
]"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
</button>
<button
type="button"
:aria-label="t('send.expiration.decrement')"
@click="incrementValue(-1)"
class="flex-1 px-2 flex items-center justify-center transition-all duration-200"
:class="[
isDarkMode
? 'hover:bg-gray-700/60 text-gray-400 hover:text-gray-200'
: 'hover:bg-gray-50 text-gray-500 hover:text-gray-700'
]"
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
</template>
<select
:aria-label="t('send.expiration.method')"
:value="expirationMethod"
@change="updateMethod"
:class="[
'absolute right-0 top-0 h-full appearance-none cursor-pointer transition-all duration-300',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
expirationMethod === 'forever'
? 'w-full px-5 rounded-2xl'
: 'w-28 pl-4 pr-9 border-l rounded-r-2xl',
isDarkMode
? 'text-gray-100 border-gray-700/60 focus:ring-indigo-500/80 bg-gray-800/60'
: 'text-gray-900 border-gray-200 focus:ring-indigo-500/60 bg-white'
]"
:style="{
color: isDarkMode ? '#f3f4f6' : '#111827',
backgroundColor: isDarkMode ? 'rgba(31, 41, 55, 0.5)' : '#ffffff'
}"
>
<option
v-for="option in options"
:key="option.value"
:value="option.value"
:class="[isDarkMode ? 'bg-gray-800 text-gray-100' : 'bg-white text-gray-900']"
:style="{
color: isDarkMode ? '#f3f4f6' : '#111827',
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff'
}"
>
{{ option.label }}
</option>
</select>
<div
class="absolute pointer-events-none"
:class="[
expirationMethod === 'forever' ? 'right-3' : 'right-2',
'top-1/2 -translate-y-1/2',
isDarkMode ? 'text-gray-400' : 'text-gray-500'
]"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
<MinusIcon class="h-5 w-5" aria-hidden="true" />
</button>
<input
id="send-expiration-value"
:value="expirationValue"
type="number"
min="1"
inputmode="numeric"
class="h-11 min-w-0 rounded-xl border px-3 text-center font-semibold tabular-nums outline-none transition-colors focus:ring-2 focus:ring-indigo-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
:class="controlClass"
:placeholder="getPlaceholder()"
@input="updateValue"
/>
<button
type="button"
class="flex h-11 w-11 items-center justify-center rounded-xl border text-xl font-semibold transition-colors"
:class="controlClass"
:aria-label="t('send.expiration.increment')"
@click="incrementValue(1)"
>
<PlusIcon class="h-5 w-5" aria-hidden="true" />
</button>
</div>
<label class="block">
<span class="sr-only">{{ t('send.expiration.method') }}</span>
<div class="relative">
<select
:value="expirationMethod"
class="min-h-11 w-full cursor-pointer appearance-none rounded-xl border py-2.5 pl-4 pr-11 text-sm font-medium outline-none transition-colors focus:ring-2 focus:ring-indigo-500"
:class="controlClass"
:aria-label="t('send.expiration.method')"
@change="updateMethod"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<ChevronDownIcon class="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 opacity-70" aria-hidden="true" />
</div>
</label>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { computed, inject } from 'vue'
import { ChevronDownIcon, MinusIcon, PlusIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -139,10 +69,7 @@ const { t } = useI18n()
interface Props {
expirationMethod: string
expirationValue: string
options: Array<{
label: string
value: string
}>
options: Array<{ label: string; value: string }>
}
interface Emits {
@@ -154,34 +81,32 @@ const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
const numericValue = computed(() => Math.max(1, Number.parseInt(props.expirationValue) || 1))
const controlClass = computed(() =>
isDarkMode
? 'border-gray-600 bg-gray-800/70 text-gray-100 hover:border-gray-500'
: 'border-gray-300 bg-white text-gray-900 hover:border-gray-400'
)
const updateMethod = (event: Event) => {
const target = event.target as HTMLSelectElement
emit('update:expirationMethod', target.value)
emit('update:expirationMethod', (event.target as HTMLSelectElement).value)
}
const updateValue = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:expirationValue', target.value)
emit('update:expirationValue', (event.target as HTMLInputElement).value)
}
const incrementValue = (delta: number) => {
const currentValue = parseInt(props.expirationValue) || 0
const newValue = Math.max(1, currentValue + delta)
emit('update:expirationValue', newValue.toString())
emit('update:expirationValue', Math.max(1, numericValue.value + delta).toString())
}
const getPlaceholder = () => {
switch (props.expirationMethod) {
case 'count':
return t('send.expiration.placeholders.count')
case 'minute':
return t('send.expiration.placeholders.minutes')
case 'hour':
return t('send.expiration.placeholders.hours')
case 'day':
return t('send.expiration.placeholders.days')
default:
return t('send.expiration.placeholders.default')
case 'count': return t('send.expiration.placeholders.count')
case 'minute': return t('send.expiration.placeholders.minutes')
case 'hour': return t('send.expiration.placeholders.hours')
case 'day': return t('send.expiration.placeholders.days')
default: return t('send.expiration.placeholders.default')
}
}
</script>
+29 -91
View File
@@ -1,114 +1,52 @@
<template>
<div class="flex-grow overflow-y-auto p-6">
<transition-group name="list" tag="div" class="space-y-4">
<div
<div class="flex-grow overflow-y-auto p-3 sm:p-6">
<div v-if="records.length === 0" class="py-12 text-center text-sm" :class="isDarkMode ? 'text-gray-400' : 'text-gray-600'">
{{ t('drawer.noRecords') }}
</div>
<transition-group v-else name="list" tag="div" class="space-y-3 sm:space-y-4">
<article
v-for="record in records"
:key="record.id"
class="bg-opacity-50 rounded-lg p-4 flex items-center shadow-md hover:shadow-lg transition duration-300 transform hover:scale-102"
class="rounded-xl bg-opacity-50 p-4 shadow-md transition duration-200 hover:shadow-lg"
:class="[isDarkMode ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-100 hover:bg-white']"
>
<div class="flex-shrink-0 mr-4">
<FileIcon
class="w-10 h-10"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
<div class="flex min-w-0 items-center gap-3">
<FileIcon class="h-9 w-9 shrink-0" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="truncate text-base font-medium" :title="record.filename" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">{{ record.filename }}</p>
<p class="truncate text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">{{ record.date }} · {{ record.size }}</p>
</div>
</div>
<div class="flex-grow min-w-0 mr-4">
<p
class="font-medium text-lg truncate"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
{{ record.filename }}
</p>
<p
class="text-sm truncate"
:class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']"
>
{{ record.date }} · {{ record.size }}
</p>
</div>
<div class="flex-shrink-0 flex space-x-2">
<button
type="button"
:aria-label="t('fileRecord.viewDetails')"
:title="t('fileRecord.viewDetails')"
@click="$emit('view-details', record)"
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[
isDarkMode
? 'hover:bg-indigo-400 text-indigo-400'
: 'hover:bg-indigo-100 text-indigo-600'
]"
>
<EyeIcon class="w-5 h-5" />
<div class="mt-3 grid grid-cols-3 gap-2">
<button type="button" class="flex min-h-11 items-center justify-center rounded-xl px-2 text-xs font-medium" :class="actionClass" @click="$emit('view-details', record)">
<EyeIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />{{ t('common.fileDetails') }}
</button>
<button
type="button"
:aria-label="t('fileRecord.download')"
:title="t('fileRecord.download')"
@click="$emit('download-record', record)"
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[
isDarkMode
? 'hover:bg-green-400 text-green-400'
: 'hover:bg-green-100 text-green-600'
]"
>
<DownloadIcon class="w-5 h-5" />
<button type="button" class="flex min-h-11 items-center justify-center rounded-xl px-2 text-xs font-medium" :class="actionClass" @click="$emit('download-record', record)">
<DownloadIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />{{ t('fileRecord.download') }}
</button>
<button
type="button"
:aria-label="t('fileRecord.deleteRecord')"
:title="t('fileRecord.deleteRecord')"
@click="$emit('delete-record', record.id)"
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
]"
>
<TrashIcon class="w-5 h-5" />
<button type="button" class="flex min-h-11 items-center justify-center rounded-xl bg-red-600 px-2 text-xs font-medium text-white hover:bg-red-700" @click="$emit('delete-record', record.id)">
<TrashIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />{{ t('common.delete') }}
</button>
</div>
</div>
</article>
</transition-group>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { computed, inject } from 'vue'
import { FileIcon, EyeIcon, DownloadIcon, TrashIcon } from 'lucide-vue-next'
import type { ReceivedFileRecord } from '@/types'
import { useI18n } from 'vue-i18n'
import type { ReceivedFileRecord } from '@/types'
interface Props {
records: ReceivedFileRecord[]
}
interface Emits {
'view-details': [record: ReceivedFileRecord]
'download-record': [record: ReceivedFileRecord]
'delete-record': [id: number]
}
defineProps<Props>()
defineEmits<Emits>()
const isDarkMode = inject('isDarkMode')
defineProps<{ records: ReceivedFileRecord[] }>()
defineEmits<{ 'view-details': [record: ReceivedFileRecord]; 'download-record': [record: ReceivedFileRecord]; 'delete-record': [id: number] }>()
const { t } = useI18n()
const isDarkMode = inject('isDarkMode')
const actionClass = computed(() => isDarkMode ? 'bg-gray-700 text-gray-200 hover:bg-gray-600' : 'bg-white text-gray-700 hover:bg-gray-200')
</script>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-move {
transition: transform 0.3s ease;
}
.list-enter-active, .list-leave-active, .list-move { transition: all 0.24s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(20px); }
</style>
+3 -2
View File
@@ -66,7 +66,7 @@
<span v-if="selectedFiles && selectedFiles.length > 1" class="block">
<span v-for="(f, i) in selectedFiles" :key="i" class="block truncate">{{ f.name }}</span>
</span>
<span v-else class="block truncate">
<span v-else class="block break-words line-clamp-2">
{{ displayText }}
</span>
</p>
@@ -111,8 +111,9 @@
<!-- 错误重试按钮 -->
<button
v-if="hasError && allowRetry"
type="button"
@click.stop="handleRetry"
class="mt-3 px-4 py-2 text-sm rounded-lg transition-colors duration-200"
class="mt-3 min-h-11 rounded-lg px-4 py-2 text-sm transition-colors duration-200"
:class="[
isDarkMode
? 'bg-indigo-600 hover:bg-indigo-500 text-white'
+12 -7
View File
@@ -214,26 +214,31 @@ const updatePart = (part: DatePart, value: string) => {
@media (max-width: 639px) {
:global(.analytics-date-range) {
--native-date-width: min(34vw, 8.1rem);
--native-date-width: 100%;
display: grid !important;
width: 100%;
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem !important;
}
.native-date-strip {
min-height: clamp(2.35rem, 11vw, 2.75rem);
padding-inline: clamp(0.32ch, 1vw, 0.55ch);
min-height: 2.75rem;
padding-inline: 0.5rem;
}
.native-date-part {
height: clamp(2.25rem, 10.5vw, 2.65rem);
font-size: clamp(0.8rem, 3.65vw, 0.95rem);
height: 2.75rem;
min-width: 2.75rem;
font-size: 0.9rem;
}
.native-date-year {
width: 4ch;
width: 5.25rem;
}
.native-date-month,
.native-date-day {
width: 2ch;
width: 2.75rem;
}
.native-date-separator {
+2 -2
View File
@@ -10,7 +10,7 @@
</div>
<div
class="px-8 py-4 bg-opacity-50 flex justify-between items-center"
class="flex items-center justify-between gap-3 bg-opacity-50 px-4 py-3 sm:px-8 sm:py-4"
:class="[isDarkMode ? 'bg-gray-800' : 'bg-gray-100']"
>
<span
@@ -20,7 +20,7 @@
<ShieldCheckIcon class="w-4 h-4 mr-1 text-green-400" />
{{ t('send.secureEncryption') }}
</span>
<div class="flex items-center space-x-4">
<div class="flex items-center gap-2 sm:gap-4">
<router-link
to="/login"
class="flex h-11 w-11 items-center justify-center rounded-lg text-sm transition duration-200 hover:bg-[#dcebed] dark:hover:bg-[#294047]"
+33 -80
View File
@@ -1,103 +1,56 @@
<template>
<div class="flex-grow overflow-y-auto p-6">
<transition-group name="list" tag="div" class="space-y-4">
<div
<div class="flex-grow overflow-y-auto p-3 sm:p-6">
<div v-if="records.length === 0" class="py-12 text-center text-sm" :class="isDarkMode ? 'text-gray-400' : 'text-gray-600'">
{{ t('drawer.noRecords') }}
</div>
<transition-group v-else name="list" tag="div" class="space-y-3 sm:space-y-4">
<article
v-for="record in records"
:key="record.id"
class="bg-opacity-50 rounded-lg p-4 flex items-center shadow-md hover:shadow-lg transition duration-300 transform hover:scale-102"
class="rounded-xl bg-opacity-50 p-4 shadow-md transition duration-200 hover:shadow-lg"
:class="[isDarkMode ? 'bg-gray-800 hover:bg-gray-700' : 'bg-gray-100 hover:bg-white']"
>
<div class="flex-shrink-0 mr-4">
<FileIcon
class="w-10 h-10"
:class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']"
/>
<div class="flex min-w-0 items-center gap-3">
<FileIcon class="h-9 w-9 shrink-0" :class="[isDarkMode ? 'text-indigo-400' : 'text-indigo-600']" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="truncate text-base font-medium" :title="record.filename || 'Text'" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ record.filename || 'Text' }}
</p>
<p class="truncate text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
{{ record.date }} · {{ record.size }}
</p>
</div>
</div>
<div class="flex-grow min-w-0 mr-4">
<p
class="font-medium text-lg truncate"
:class="[isDarkMode ? 'text-white' : 'text-gray-800']"
>
{{ record.filename ? record.filename : 'Text' }}
</p>
<p class="text-sm truncate" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
{{ record.date }} · {{ record.size }}
</p>
</div>
<div class="flex-shrink-0 flex space-x-2">
<button
type="button"
:aria-label="t('common.copy')"
:title="t('common.copy')"
@click="$emit('copy-link', record)"
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[
isDarkMode ? 'hover:bg-blue-400 text-blue-400' : 'hover:bg-blue-100 text-blue-600'
]"
>
<ClipboardCopyIcon class="w-5 h-5" />
<div class="mt-3 grid grid-cols-3 gap-2">
<button type="button" class="flex min-h-11 items-center justify-center rounded-xl px-2 text-xs font-medium" :class="actionClass" @click="$emit('copy-link', record)">
<ClipboardCopyIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />{{ t('common.copy') }}
</button>
<button
type="button"
:aria-label="t('fileRecord.viewDetails')"
:title="t('fileRecord.viewDetails')"
@click="$emit('view-details', record)"
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[
isDarkMode
? 'hover:bg-green-400 text-green-400'
: 'hover:bg-green-100 text-green-600'
]"
>
<EyeIcon class="w-5 h-5" />
<button type="button" class="flex min-h-11 items-center justify-center rounded-xl px-2 text-xs font-medium" :class="actionClass" @click="$emit('view-details', record)">
<EyeIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />{{ t('common.fileDetails') }}
</button>
<button
type="button"
:aria-label="t('fileRecord.deleteRecord')"
:title="t('fileRecord.deleteRecord')"
@click="$emit('delete-record', record.id)"
class="flex h-11 w-11 items-center justify-center rounded-full transition duration-200 hover:bg-opacity-20"
:class="[
isDarkMode ? 'hover:bg-red-400 text-red-400' : 'hover:bg-red-100 text-red-600'
]"
>
<TrashIcon class="w-5 h-5" />
<button type="button" class="flex min-h-11 items-center justify-center rounded-xl bg-red-600 px-2 text-xs font-medium text-white hover:bg-red-700" @click="$emit('delete-record', record.id)">
<TrashIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />{{ t('common.delete') }}
</button>
</div>
</div>
</article>
</transition-group>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { computed, inject } from 'vue'
import { ClipboardCopyIcon, EyeIcon, FileIcon, TrashIcon } from 'lucide-vue-next'
import type { SentFileRecord } from '@/types'
import { useI18n } from 'vue-i18n'
import type { SentFileRecord } from '@/types'
defineProps<{
records: SentFileRecord[]
}>()
defineEmits<{
'copy-link': [record: SentFileRecord]
'view-details': [record: SentFileRecord]
'delete-record': [id: number]
}>()
const isDarkMode = inject('isDarkMode')
defineProps<{ records: SentFileRecord[] }>()
defineEmits<{ 'copy-link': [record: SentFileRecord]; 'view-details': [record: SentFileRecord]; 'delete-record': [id: number] }>()
const { t } = useI18n()
const isDarkMode = inject('isDarkMode')
const actionClass = computed(() => isDarkMode ? 'bg-gray-700 text-gray-200 hover:bg-gray-600' : 'bg-white text-gray-700 hover:bg-gray-200')
</script>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-enter-active, .list-leave-active { transition: all 0.24s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(20px); }
</style>
+1 -1
View File
@@ -12,7 +12,7 @@
<p class="line-clamp-2 text-xs leading-tight sm:text-sm" :title="title" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
{{ title }}
</p>
<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']">
<h3 class="mt-1 whitespace-nowrap text-lg font-bold leading-tight tabular-nums sm:text-2xl" :title="String(value)" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
{{ value }}
</h3>
</div>
+6 -4
View File
@@ -3,8 +3,10 @@ import { AuthService } from '@/services'
import { useAdminStore } from '@/stores/adminStore'
import { useAlertStore } from '@/stores/alertStore'
import { getErrorMessage } from '@/utils/common'
import { useI18n } from 'vue-i18n'
export function useAdminLogin() {
const { t } = useI18n()
const alertStore = useAlertStore()
const adminStore = useAdminStore()
const password = ref('')
@@ -12,12 +14,12 @@ export function useAdminLogin() {
const validateForm = () => {
if (!password.value) {
alertStore.showAlert('无效的密码', 'error')
alertStore.showAlert(t('manage.login.invalidPassword'), 'error')
return false
}
if (password.value.length < 6) {
alertStore.showAlert('密码长度至少为6位', 'error')
alertStore.showAlert(t('manage.login.passwordTooShort'), 'error')
return false
}
@@ -31,7 +33,7 @@ export function useAdminLogin() {
try {
const response = await AuthService.login(password.value)
if (!response.detail?.token) {
alertStore.showAlert('登录失败:未获取到有效令牌', 'error')
alertStore.showAlert(t('manage.login.noValidToken'), 'error')
return false
}
@@ -44,7 +46,7 @@ export function useAdminLogin() {
})
return true
} catch (error: unknown) {
alertStore.showAlert(getErrorMessage(error, '登录失败'), 'error')
alertStore.showAlert(getErrorMessage(error, t('manage.login.loginFailed')), 'error')
return false
} finally {
isLoading.value = false
+1
View File
@@ -174,6 +174,7 @@ export function useRetrieveFlow() {
}
const deleteRecord = (id: number) => {
if (!window.confirm(t('fileRecord.deleteRecordConfirm'))) return
const index = records.value.findIndex((record) => record.id === id)
if (index !== -1) {
fileStore.deleteReceiveData(index)
+1
View File
@@ -300,6 +300,7 @@ export function useSendFlow() {
}
const deleteRecord = (id: number) => {
if (!window.confirm(t('fileRecord.deleteRecordConfirm'))) return
const index = fileDataStore.shareData.findIndex((record) => record.id === id)
if (index !== -1) {
fileDataStore.deleteShareData(index)
+10 -3
View File
@@ -27,6 +27,7 @@ export default {
info: 'Information',
search: 'Search',
copy: 'Copy',
more: 'More',
uploadSuccess: 'Upload successful',
uploadFailed: 'Upload failed',
copySuccess: 'Copy successful',
@@ -311,7 +312,7 @@ export default {
},
submit: 'Secure Send',
submitting: 'Sending...',
needRetrieveFile: 'Need to retrieve? Click here',
needRetrieveFile: 'Retrieve a file',
sendRecords: 'Send Records',
secureEncryption: 'Secure Encryption',
workspace: {
@@ -405,6 +406,7 @@ export default {
download: 'Download',
viewDetails: 'View Details',
deleteRecord: 'Delete Record',
deleteRecordConfirm: 'Delete this local record? The file on the server will not be deleted.',
preview: 'Preview',
copyContent: 'Copy Content',
contentCopied: 'Content copied to clipboard',
@@ -510,6 +512,8 @@ export default {
previewFallback: 'Preview endpoint unavailable; using text from the list',
selectCurrentPage: 'Select current page',
selectedCount: '{count} selected',
mobileBatchActions: 'Mobile batch actions',
policyActionsLabel: 'Policy',
selectFile: 'Select {name}',
clearSelection: 'Clear Selection',
batchDelete: 'Batch Delete',
@@ -774,11 +778,13 @@ export default {
refreshConfig: 'Refresh Config',
refreshing: 'Refreshing',
saving: 'Saving',
unsavedChanges: 'Unsaved configuration changes',
allChangesSaved: 'All configuration changes are saved',
unsavedChanges: 'Unsaved changes',
allChangesSaved: 'Saved',
refreshBlocked: 'Save current changes before refreshing'
,sectionNavigation: 'Settings section navigation'
,reloadSaved: 'Reload saved settings'
,discardChanges: 'Discard changes'
,discardChangesConfirm: 'Discard unsaved changes and reload the saved settings?'
,robotsWarning: 'A Disallow: / rule blocks search engines from crawling the entire site.'
,fileSizeUnit: 'File size unit'
,timeUnit: 'Time unit'
@@ -965,6 +971,7 @@ export default {
// Components
components: {
pagination: {
navigation: 'Pagination',
range: 'Showing {start} to {end} of {total}',
pageSize: 'Items per page',
perPage: '{size}/page',
+10 -3
View File
@@ -27,6 +27,7 @@ export default {
info: '信息',
search: '搜索',
copy: '复制',
more: '更多',
uploadSuccess: '上传成功',
uploadFailed: '上传失败',
copySuccess: '复制成功',
@@ -311,7 +312,7 @@ export default {
},
submit: '安全寄送',
submitting: '发送中...',
needRetrieveFile: '需要取件?点击这里',
needRetrieveFile: '前往取件',
sendRecords: '发件记录',
secureEncryption: '安全加密',
workspace: {
@@ -508,6 +509,8 @@ export default {
previewFallback: '预览接口不可用,已使用列表中的文本内容',
selectCurrentPage: '选择当前页',
selectedCount: '已选择 {count} 项',
mobileBatchActions: '移动端批量操作',
policyActionsLabel: '策略',
selectFile: '选择 {name}',
clearSelection: '清空选择',
batchDelete: '批量删除',
@@ -704,6 +707,7 @@ export default {
download: '下载',
viewDetails: '查看详情',
deleteRecord: '删除记录',
deleteRecordConfirm: '确认删除这条本地记录?此操作不会删除服务器上的文件。',
preview: '预览',
copyContent: '复制内容',
contentCopied: '内容已复制到剪贴板',
@@ -802,11 +806,13 @@ export default {
refreshConfig: '刷新配置',
refreshing: '刷新中',
saving: '保存中',
unsavedChanges: '有未保存的配置变更',
allChangesSaved: '所有配置已保存',
unsavedChanges: '有未保存修改',
allChangesSaved: '已保存',
refreshBlocked: '请先保存当前变更再刷新'
,sectionNavigation: '设置分区导航'
,reloadSaved: '重新加载已保存配置'
,discardChanges: '放弃修改'
,discardChangesConfirm: '确认放弃当前未保存的修改并重新加载已保存配置?'
,robotsWarning: '如果包含 Disallow: /,搜索引擎将无法抓取整个站点。'
,fileSizeUnit: '文件大小单位'
,timeUnit: '时间单位'
@@ -985,6 +991,7 @@ export default {
// 组件相关
components: {
pagination: {
navigation: '分页导航',
range: '显示第 {start} 到 {end} 条,共 {total} 条',
pageSize: '每页显示数量',
perPage: '{size}/页',
+38 -6
View File
@@ -3,8 +3,17 @@
class="flex h-screen overflow-hidden flex-col transition-colors duration-300 lg:flex-row"
:class="[isDarkMode ? 'bg-[#10191e]' : 'bg-[#dfe9eb]']"
>
<button
v-if="isSidebarOpen"
type="button"
class="fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px] lg:hidden"
:aria-label="t('common.closeMenu')"
@click="closeSidebar"
/>
<!-- Sidebar -->
<aside
ref="sidebarRef"
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 shrink-0 transform flex-col border-r lg:relative lg:h-screen lg:translate-x-0"
:class="[
isDarkMode
@@ -38,7 +47,7 @@
{{ t('common.appName') }}
</h1>
</div>
<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">
<button type="button" :aria-label="t('common.closeMenu')" :title="t('common.closeMenu')" @click="closeSidebar" 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']" />
</button>
</div>
@@ -93,7 +102,7 @@
:class="[isDarkMode ? 'bg-[#1a2930] border-[#31454d]' : 'bg-[#f2f6f5] border-[#c8d7da]']"
>
<div class="flex h-14 items-center justify-between px-4">
<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">
<button ref="menuButtonRef" type="button" :aria-label="t('common.openMenu')" :title="t('common.openMenu')" :aria-expanded="isSidebarOpen" @click="openSidebar" 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']" />
</button>
</div>
@@ -111,7 +120,7 @@
</template>
<script setup lang="ts">
import { ref, inject, onMounted, onUnmounted } from 'vue'
import { ref, inject, nextTick, onMounted, onUnmounted, watch } from 'vue'
import {
BoxIcon,
MenuIcon,
@@ -160,16 +169,36 @@ const menuItems: MenuItem[] = [
]
const isSidebarOpen = ref(true)
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value
const sidebarRef = ref<HTMLElement | null>(null)
const menuButtonRef = ref<HTMLButtonElement | null>(null)
const openSidebar = async () => {
isSidebarOpen.value = true
await nextTick()
sidebarRef.value?.querySelector<HTMLElement>('button, a[href]')?.focus()
}
const closeSidebar = async () => {
isSidebarOpen.value = false
await nextTick()
menuButtonRef.value?.focus()
}
const closeSidebarForMobile = () => {
if (window.innerWidth < 1024) {
isSidebarOpen.value = false
void closeSidebar()
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && window.innerWidth < 1024 && isSidebarOpen.value) {
void closeSidebar()
}
}
watch(isSidebarOpen, (open) => {
if (window.innerWidth < 1024) document.body.style.overflow = open ? 'hidden' : ''
})
// 响应式处理
const handleResize = () => {
if (window.innerWidth >= 1024) {
@@ -182,6 +211,7 @@ const handleResize = () => {
onMounted(() => {
handleResize()
window.addEventListener('resize', handleResize)
window.addEventListener('keydown', handleEscape)
void verifySession().then((isValid) => {
if (!isValid) {
void router.push(ROUTES.LOGIN)
@@ -191,6 +221,8 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
})
// 登出处理
+2
View File
@@ -17,6 +17,7 @@ export const useAlertStore = defineStore('alert', {
const id = Date.now()
const startTime = Date.now()
this.alerts.push({ id, message, type, progress: 100, duration, startTime })
this.startProgressTimer()
setTimeout(() => this.removeAlert(id), duration)
},
removeAlert(id: number) {
@@ -24,6 +25,7 @@ export const useAlertStore = defineStore('alert', {
if (index > -1) {
this.alerts.splice(index, 1)
}
if (this.alerts.length === 0) this.stopProgressTimer()
},
updateAlertProgress(id: number) {
const alert = this.alerts.find((a) => a.id === id)
+4 -3
View File
@@ -1,6 +1,3 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const MARKDOWN_ALLOWED_TAGS = [
'p',
'br',
@@ -27,6 +24,10 @@ const MARKDOWN_ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'class']
export async function renderMarkdownPreview(content: string): Promise<string> {
try {
const [{ marked }, { default: DOMPurify }] = await Promise.all([
import('marked'),
import('dompurify')
])
const rawHtml = await marked(content)
return DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: MARKDOWN_ALLOWED_TAGS,
+1 -2
View File
@@ -1,5 +1,3 @@
import JSZip from 'jszip'
const SMALL_FILE_HASH_LIMIT = 10 * 1024 * 1024
const LARGE_FILE_HASH_CHUNK_SIZE = 5 * 1024 * 1024
@@ -47,6 +45,7 @@ export const calculateFileHash = async (file: File): Promise<string> => {
}
export const packFilesAsZip = async (files: File[]): Promise<File> => {
const { default: JSZip } = await import('jszip')
const zip = new JSZip()
for (const file of files) {
zip.file(file.name, file)
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div
class="min-h-screen flex items-center justify-center p-4 overflow-hidden transition-colors duration-300"
class="min-h-[100dvh] flex items-center justify-center overflow-x-hidden overflow-y-auto px-4 pb-6 pt-24 transition-colors duration-300 sm:py-4"
>
<div class="w-full max-w-md relative z-10">
<div
+4 -4
View File
@@ -1,6 +1,6 @@
<template>
<div
class="min-h-screen flex items-center justify-center p-4 overflow-hidden transition-colors duration-300"
class="min-h-[100dvh] flex items-center justify-center overflow-x-hidden overflow-y-auto px-4 pb-6 pt-24 transition-colors duration-300 sm:py-4"
@paste.prevent="handlePaste"
>
<div
@@ -11,7 +11,7 @@
: 'bg-white border-gray-200'
]"
>
<div class="p-8">
<div class="p-6 sm:p-8">
<PageHeader :title="config.name" @title-click="toRetrieve" />
<form @submit.prevent="handleSubmit" class="space-y-8">
<SendTypeSelector
@@ -82,14 +82,14 @@
</button>
</form>
<div class="mt-6 text-center">
<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]">
<router-link to="/" class="inline-flex min-h-11 items-center rounded-lg px-2 text-sm text-[#3f6a77] transition duration-200 hover:bg-[#dcebed] hover:text-[#315c69] dark:text-[#b8d4dc] dark:hover:bg-[#294047] sm:px-3 sm:text-base">
{{ t('send.needRetrieveFile') }}
</router-link>
</div>
</div>
<div
class="px-8 py-4 bg-opacity-50 flex justify-between items-center"
class="flex items-center justify-between gap-3 bg-opacity-50 px-4 py-3 sm:px-8 sm:py-4"
:class="[isDarkMode ? 'bg-gray-800' : 'bg-gray-100']"
>
<span
+18 -13
View File
@@ -1,6 +1,6 @@
<template>
<div class="p-4 sm:p-6">
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div class="mb-6 flex items-end justify-between gap-3">
<div>
<p class="text-sm" :class="[mutedTextClass]">{{ t('admin.dashboard.adminLabel') }}</p>
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
@@ -10,13 +10,18 @@
{{ t('admin.dashboard.lastUpdated', { time: lastUpdatedText }) }}
</p>
</div>
<BaseButton variant="secondary" :loading="isDashboardRefreshing" @click="refreshDashboardPage">
<BaseButton
variant="secondary"
:loading="isDashboardRefreshing"
:title="t('admin.dashboard.refresh')"
:aria-label="t('admin.dashboard.refresh')"
class="shrink-0 max-sm:h-11 max-sm:w-11 max-sm:!px-0"
@click="refreshDashboardPage"
>
<template #icon>
<RefreshCwIcon class="mr-2 h-4 w-4" />
</template>
{{
isDashboardRefreshing ? t('admin.dashboard.refreshing') : t('admin.dashboard.refresh')
}}
<span class="max-sm:sr-only">{{ isDashboardRefreshing ? t('admin.dashboard.refreshing') : t('admin.dashboard.refresh') }}</span>
</BaseButton>
</div>
@@ -98,7 +103,7 @@
/>
</div>
<div class="grid grid-cols-3 gap-2 sm:gap-4">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3 sm:gap-4">
<MetricProgress
:label="t('admin.dashboard.activeFileRatio')"
:value="dashboardData.activeRatio"
@@ -263,11 +268,11 @@
@change="fetchAnalyticsData"
/>
</div>
<div class="hidden grid-cols-3 gap-2 sm:flex sm:justify-end">
<div class="grid w-full grid-cols-3 gap-2 sm:flex sm:w-auto sm:justify-end">
<BaseButton
variant="outline"
size="sm"
class="h-9 min-w-16"
class="min-h-11 min-w-16 lg:!min-h-9"
@click="trendCanvas?.zoom(0.75)"
>
{{ t('admin.dashboard.trend.zoomIn') }}
@@ -275,7 +280,7 @@
<BaseButton
variant="outline"
size="sm"
class="h-9 min-w-16"
class="min-h-11 min-w-16 lg:!min-h-9"
@click="trendCanvas?.zoom(1.33)"
>
{{ t('admin.dashboard.trend.zoomOut') }}
@@ -283,7 +288,7 @@
<BaseButton
variant="outline"
size="sm"
class="h-9 min-w-16"
class="min-h-11 min-w-16 lg:!min-h-9"
@click="trendCanvas?.resetWindow()"
>
{{ t('admin.dashboard.trend.reset') }}
@@ -638,8 +643,8 @@ const MetricProgress = defineComponent({
'theme-2026-card-hover rounded-[18px] border border-[#d4e0e3] bg-[#f6faf9] p-2.5 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-[#8fb2bf] hover:shadow-md sm:rounded-[30px] sm:p-4 sm:shadow-md dark:border-[#40545c] dark:bg-[#17252c]/50 dark:hover:border-[#7199a8] dark:hover:bg-[#263941] dark:hover:shadow-black/25'
},
[
h('div', { class: 'mb-2 flex flex-col gap-0.5 text-xs sm:flex-row sm:items-center sm:justify-between sm:text-sm' }, [
h('span', { class: 'line-clamp-1 text-gray-500 dark:text-gray-400' }, props.label),
h('div', { class: 'mb-2 flex items-center justify-between gap-3 text-sm' }, [
h('span', { class: 'min-w-0 font-medium text-gray-600 dark:text-gray-300' }, props.label),
h('span', { class: 'font-medium tabular-nums text-gray-900 dark:text-white' }, `${props.value}%`)
]),
h('div', {
@@ -655,7 +660,7 @@ const MetricProgress = defineComponent({
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 text-xs text-gray-500 dark:text-gray-400 sm:text-sm' }, props.detail)
]
)
}
+149 -17
View File
@@ -1,5 +1,8 @@
<template>
<div class="p-4 sm:p-6">
<div
class="p-4 sm:p-6"
:class="hasSelectedFiles ? (showMobilePolicyActions ? 'pb-[22rem] md:pb-6' : 'pb-44 md:pb-6') : ''"
>
<div class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
@@ -11,7 +14,7 @@
</div>
</div>
<div class="mb-5 grid grid-cols-2 gap-3 sm:mb-6 sm:gap-4 xl:grid-cols-4">
<div v-if="!hasSelectedFiles" class="mb-5 grid grid-cols-2 gap-3 sm:mb-6 sm:gap-4 xl:grid-cols-4">
<div
v-for="card in summaryCards"
:key="card.label"
@@ -36,6 +39,7 @@
</div>
<section
v-if="!hasSelectedFiles"
class="theme-2026-card theme-2026-card-hover mb-4 rounded-[24px] border p-3 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg sm:mb-6 sm:rounded-[30px] sm:p-4"
:class="[panelClass, cardHoverClass]"
>
@@ -81,7 +85,7 @@
v-model="params.keyword"
type="text"
:placeholder="t('fileManage.searchPlaceholder')"
class="w-full rounded-lg border py-2.5 pl-10 pr-4 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border py-2.5 pl-10 pr-4 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[
isDarkMode
? 'border-gray-600 bg-gray-700 text-white placeholder-gray-400'
@@ -107,7 +111,7 @@
</span>
<select
:value="params.status"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleStatusFilterChange"
>
@@ -128,7 +132,7 @@
</span>
<select
:value="params.health"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleHealthFilterSelect"
>
@@ -149,7 +153,7 @@
</span>
<select
:value="params.type"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleTypeFilterChange"
>
@@ -166,7 +170,7 @@
</span>
<select
v-model="params.sortBy"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleSearch"
>
@@ -183,7 +187,7 @@
</span>
<select
v-model="params.sortOrder"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
@change="handleSearch"
>
@@ -200,7 +204,7 @@
</span>
<select
:value="selectedViewPresetId"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
:disabled="isViewPresetLoading"
@change="handleViewPresetChange"
@@ -277,6 +281,38 @@
</button>
</div>
<div
v-if="tableData.length > 0"
class="flex min-h-14 items-center justify-between gap-3 border-b px-4 py-2 md:hidden"
:class="[isDarkMode ? 'border-gray-700 bg-[#20323a]/70' : 'border-[#d9e4e6] bg-[#f4f8f8]']"
>
<label class="flex min-h-11 min-w-0 cursor-pointer items-center gap-3 text-sm font-medium" :class="[primaryTextClass]">
<span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl">
<input
type="checkbox"
class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
:checked="isAllCurrentPageSelected"
:disabled="isBatchActionRunning"
:indeterminate="isCurrentPagePartiallySelected"
@change="toggleCurrentPageSelection"
/>
</span>
<span class="truncate">
{{ hasSelectedFiles ? t('fileManage.selectedCount', { count: selectedCount }) : t('fileManage.selectCurrentPage') }}
</span>
</label>
<button
v-if="hasSelectedFiles"
type="button"
class="min-h-11 shrink-0 rounded-xl px-3 text-sm font-medium"
:class="detailActionClass"
:disabled="isBatchActionRunning"
@click="clearSelection"
>
{{ t('fileManage.clearSelection') }}
</button>
</div>
<div v-if="isLoading" class="flex items-center justify-center px-4 py-10 text-sm" :class="[mutedTextClass]">
<RefreshCwIcon class="mr-2 h-4 w-4 animate-spin" />
{{ t('common.loading') }}
@@ -286,7 +322,10 @@
<BaseButton class="mt-3" variant="secondary" @click="refreshFileList">{{ t('fileManage.refresh') }}</BaseButton>
</div>
<div v-else-if="tableData.length === 0" class="px-4 py-10 text-center text-sm" :class="[mutedTextClass]">
{{ hasActiveFilters ? t('fileManage.noMatches') : t('common.noData') }}
<p>{{ hasActiveFilters ? t('fileManage.noMatches') : t('common.noData') }}</p>
<BaseButton v-if="hasActiveFilters" class="mt-3" variant="secondary" @click="handleResetFilters">
{{ t('fileManage.resetFilters') }}
</BaseButton>
</div>
<div v-else class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-[#d9e4e6]']">
<article
@@ -331,11 +370,6 @@
</div>
</button>
</div>
<div class="mt-3 pl-14">
<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" aria-hidden="true" />{{ t('fileManage.detail') }}
</button>
</div>
</article>
</div>
@@ -350,6 +384,85 @@
</div>
</section>
<Transition name="fcb-expand-120">
<aside
v-if="hasSelectedFiles"
class="fixed inset-x-0 bottom-0 z-40 border-t px-3 pt-3 shadow-2xl backdrop-blur-xl md:hidden"
:class="isDarkMode ? 'border-[#40545c] bg-[#17252b]/95' : 'border-[#c8d7da] bg-[#f2f6f5]/95'"
:style="{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }"
:aria-label="t('fileManage.mobileBatchActions')"
>
<div class="mx-auto max-w-lg space-y-2">
<div
v-if="showMobilePolicyActions"
class="grid grid-cols-2 gap-2 rounded-2xl border p-2"
:class="isDarkMode ? 'border-[#40545c] bg-[#20323a]' : 'border-[#c8d7da] bg-white'"
>
<button
v-for="option in batchPolicyActionOptions"
:key="option.action"
type="button"
class="min-h-11 rounded-xl border px-2 py-2 text-xs font-medium"
:class="detailActionClass"
:title="option.description"
:disabled="option.disabled || isBatchDeleting || isBatchUpdating || isBatchPolicyActionRunning"
@click="applySelectedPolicyActionAndRefreshHistory(option.action)"
>
{{ option.label }}
</button>
</div>
<div class="flex items-center justify-between gap-3 px-1">
<p class="text-sm font-semibold" :class="primaryTextClass">
{{ t('fileManage.selectedCount', { count: selectedCount }) }}
</p>
<button
type="button"
class="min-h-11 rounded-xl px-3 text-sm font-medium"
:class="detailActionClass"
:disabled="isBatchActionRunning"
@click="clearSelection"
>
{{ t('common.cancel') }}
</button>
</div>
<div class="grid grid-cols-3 gap-2">
<button
type="button"
class="flex min-h-12 items-center justify-center rounded-xl border px-2 text-sm font-medium"
:class="detailActionClass"
:disabled="isBatchDeleting || isBatchPolicyActionRunning"
@click="openBatchEditModal"
>
<ClockIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
<span>{{ t('common.edit') }}</span>
</button>
<button
type="button"
class="flex min-h-12 items-center justify-center rounded-xl border px-2 text-sm font-medium"
:class="detailActionClass"
:aria-expanded="showMobilePolicyActions"
:disabled="isBatchDeleting || isBatchUpdating"
@click="showMobilePolicyActions = !showMobilePolicyActions"
>
<TimerResetIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
{{ t('fileManage.policyActionsLabel') }}
</button>
<button
type="button"
class="flex min-h-12 items-center justify-center rounded-xl bg-red-600 px-2 text-sm font-medium text-white shadow-sm disabled:opacity-50"
:disabled="isBatchUpdating || isBatchPolicyActionRunning"
@click="deleteSelectedFilesAndRefreshHistory"
>
<TrashIcon class="mr-1.5 h-4 w-4" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</div>
</div>
</aside>
</Transition>
<div class="hidden md:block">
<DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders" table-class="min-w-[1120px]">
<template #actions>
@@ -968,6 +1081,14 @@
: t('fileManage.downloadFile')
}}
</button>
<button
type="button"
class="flex min-h-12 items-center justify-center rounded-lg bg-red-600 px-3 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700"
@click="deleteDetailFileAndRefreshHistory"
>
<TrashIcon class="mr-2 h-4 w-4" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</div>
<section
@@ -1178,7 +1299,7 @@
v-model="detailMetadataForm.note"
rows="3"
maxlength="2000"
class="w-full resize-y rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
:placeholder="t('fileManage.metadataNotePlaceholder')"
></textarea>
@@ -1190,7 +1311,7 @@
<input
v-model="detailMetadataForm.tagsText"
type="text"
class="w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
class="min-h-11 w-full rounded-lg border px-3 py-2 text-sm focus:border-transparent focus:ring-2 focus:ring-indigo-500"
:class="[fieldClass]"
:placeholder="t('fileManage.metadataTagsPlaceholder')"
/>
@@ -1688,12 +1809,17 @@ const {
} = useAdminFiles()
const isFilterPanelOpen = ref(false)
const showMobilePolicyActions = ref(false)
const isLifecycleExpanded = ref(false)
const visibleDetailTimeline = computed(() => {
const rows = selectedFileDetail.value?.detailTimeline || []
return isLifecycleExpanded.value ? rows : rows.slice(0, 3)
})
const filterBodyVisible = computed(() => isFilterPanelOpen.value || hasActiveFilters.value)
watch(hasSelectedFiles, (selected) => {
if (!selected) showMobilePolicyActions.value = false
})
const isHistoryLoading = ref(false)
const historyRows = ref<AnalyticsFileRow[]>([])
const historyPage = ref(1)
@@ -1721,6 +1847,12 @@ const handleBatchUpdateAndRefreshHistory = () =>
runFileMutationAndRefreshHistory(handleBatchUpdateRaw)
const deleteFileAndRefreshHistory = (id: number) =>
runFileMutationAndRefreshHistory(() => deleteFileRaw(id))
const deleteDetailFileAndRefreshHistory = async () => {
if (!selectedFileDetail.value) return
const id = selectedFileDetail.value.id
await deleteFileAndRefreshHistory(id)
closeFileDetail()
}
const deleteSelectedFilesAndRefreshHistory = () =>
runFileMutationAndRefreshHistory(deleteSelectedFilesRaw)
const applySelectedPolicyActionAndRefreshHistory = (action: AdminFilePolicyAction) =>
+26 -9
View File
@@ -1,7 +1,7 @@
<template>
<div
:class="[
'min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 transition-colors duration-200 relative overflow-hidden',
'min-h-[100dvh] flex items-center justify-center px-4 pb-6 pt-24 sm:px-6 sm:py-12 lg:px-8 transition-colors duration-200 relative overflow-x-hidden overflow-y-auto',
isDarkMode ? 'bg-[#10191e]' : 'bg-[#dfe9eb]'
]"
>
@@ -36,7 +36,7 @@
isDarkMode ? 'text-white' : 'text-gray-900'
]"
>
登录
{{ t('manage.login.title') }}
</h2>
</div>
<form class="mt-8 space-y-6" @submit.prevent="submitLogin">
@@ -54,22 +54,36 @@
/>
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="password" class="sr-only">密码</label>
<label for="password" class="mb-2 block text-sm font-medium" :class="isDarkMode ? 'text-gray-200' : 'text-[#2d4650]'">
{{ t('manage.login.password') }}
</label>
<div class="relative">
<input
id="password"
name="password"
type="password"
:type="showPassword ? 'text' : 'password'"
autocomplete="current-password"
required
v-model="password"
:class="[
'appearance-none rounded-[30px] relative block w-full px-4 py-3 border transition-all duration-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:border-[#8fb2bf] focus:z-10 sm:text-sm backdrop-blur-sm',
'appearance-none rounded-[30px] relative block w-full py-3 pl-4 pr-14 border transition-all duration-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#8fb2bf] focus:border-[#8fb2bf] focus:z-10 sm:text-sm backdrop-blur-sm',
isDarkMode
? 'bg-[#263941]/60 border-[#40545c] text-white placeholder-gray-400 hover:border-[#7199a8]'
: 'bg-[#edf4f5]/70 border-[#c8d8dc] text-[#2d4650] hover:border-[#9db6bd]'
]"
placeholder="密码"
:placeholder="t('manage.login.passwordPlaceholder')"
/>
<button
type="button"
class="absolute inset-y-0 right-1 z-20 flex w-11 items-center justify-center rounded-full"
:aria-label="showPassword ? t('common.hidePassword') : t('common.showPassword')"
:title="showPassword ? t('common.hidePassword') : t('common.showPassword')"
@click="showPassword = !showPassword"
>
<EyeOffIcon v-if="showPassword" class="h-5 w-5" aria-hidden="true" />
<EyeIcon v-else class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div>
@@ -85,7 +99,7 @@
:disabled="isLoading"
>
<span class="absolute left-0 inset-y-0 flex items-center pl-3"> </span>
{{ isLoading ? '登录中...' : '登录' }}
{{ isLoading ? t('manage.login.loggingIn') : t('manage.login.loginButton') }}
</button>
</div>
</form>
@@ -94,13 +108,16 @@
</template>
<script setup lang="ts">
import { inject } from 'vue'
import { inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { BoxIcon } from 'lucide-vue-next'
import { BoxIcon, EyeIcon, EyeOffIcon } from 'lucide-vue-next'
import { useAdminLogin } from '@/composables'
import { ROUTES } from '@/constants'
const isDarkMode = inject('isDarkMode')
const { t } = useI18n()
const showPassword = ref(false)
const router = useRouter()
const route = useRoute()
const { password, isLoading, handleSubmit } = useAdminLogin()
+36 -18
View File
@@ -10,6 +10,7 @@ import { useSystemConfig } from '@/composables'
const isDarkMode = inject('isDarkMode')
const { t } = useI18n()
const showAdminPassword = ref(false)
const activeSection = ref('general')
const {
config,
isRefreshing,
@@ -25,19 +26,25 @@ const {
} = useSystemConfig()
const scrollToSection = (section: string) => {
activeSection.value = section
document.getElementById(`settings-${section}`)?.scrollIntoView({
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
block: 'start'
})
}
const discardChanges = () => {
if (!window.confirm(t('manage.settings.discardChangesConfirm'))) return
void refreshConfig()
}
onMounted(() => {
void refreshConfig()
})
</script>
<template>
<div class="p-4 pb-28 sm:p-6 sm:pb-28">
<div class="p-4 pb-36 sm:p-6 sm:pb-28">
<div
class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between"
>
@@ -53,7 +60,7 @@ onMounted(() => {
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<div class="hidden flex-wrap items-center gap-2 sm:flex">
<BaseButton
variant="secondary"
:loading="isRefreshing"
@@ -77,7 +84,7 @@ onMounted(() => {
</div>
<nav
class="mb-4 flex gap-2 overflow-x-auto pb-2"
class="mb-4 grid grid-cols-2 gap-2 sm:flex sm:overflow-x-auto sm:pb-2"
:aria-label="t('manage.settings.sectionNavigation')"
>
<button
@@ -89,8 +96,13 @@ onMounted(() => {
]"
:key="section[0]"
type="button"
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]'"
class="inline-flex min-h-11 shrink-0 items-center justify-center rounded-full border px-3 text-center text-sm font-medium sm:px-4"
:class="activeSection === section[0]
? 'border-[#5f8796] bg-[#5f8796] text-white'
: isDarkMode
? 'border-[#40545c] bg-[#263941] text-[#d8e3e5]'
: 'border-[#c8d8dc] bg-[#edf4f5] text-[#415d66]'"
:aria-current="activeSection === section[0] ? 'true' : undefined"
@click="scrollToSection(section[0])"
>
{{ section[1] }}
@@ -773,29 +785,35 @@ onMounted(() => {
</div>
<div
class="fixed inset-x-0 bottom-0 z-40 border-t px-4 py-3 backdrop-blur-xl lg:left-64"
class="fixed inset-x-0 bottom-0 z-40 border-t px-4 pt-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">
<div
class="mx-auto grid max-w-6xl grid-cols-[minmax(0,1fr)_auto] items-center gap-3"
:style="{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }"
>
<div class="min-w-0">
<p class="truncate 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>
<BaseButton
v-if="isDirty"
variant="secondary"
size="sm"
class="mt-1 !min-h-9 !px-2 text-xs"
:loading="isRefreshing"
:disabled="isSaving || isDirty"
@click="refreshConfig"
:disabled="isSaving"
@click="discardChanges"
>
{{ 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') }}
{{ t('manage.settings.discardChanges') }}
</BaseButton>
</div>
<BaseButton class="min-w-28" :loading="isSaving" :disabled="!isDirty || isRefreshing" @click="submitConfig">
<template #icon><SaveIcon class="mr-2 h-4 w-4" /></template>
{{ isSaving ? t('manage.settings.saving') : t('common.save') }}
</BaseButton>
</div>
</div>
</div>