⚡ 全面优化 2026 前端响应式体验与加载性能
完成 FileCodeBox 2026 全站桌面端、平板和移动端的视觉交互复核,补齐文件选择后的批量操作闭环,并降低公共首屏的字体与低频依赖成本。 - 为移动文件列表增加全选、已选数量、批量编辑、策略与删除固定栏,并补充详情删除和空筛选重置 - 优化设置保存栏、分区导航、发送过期控件、仪表盘日期与图表操作、分页和记录抽屉 - 为后台侧栏增加遮罩、滚动锁定、Escape 关闭和焦点返回,完善登录密码可见性与国际化 - 移除全站强制 DingTalk 字体,按需加载 JSZip、Marked 和 DOMPurify,并停止空闲提示定时器 - 已通过架构检查、Vue 类型检查、ESLint、生产构建、48 组多视口明暗模式矩阵及核心流程回归
This commit is contained in:
+8
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}/页',
|
||||
|
||||
@@ -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 = ''
|
||||
})
|
||||
|
||||
// 登出处理
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user