Test/custom admin ui #3
@@ -8,7 +8,20 @@
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'DingTalk', sans-serif !important;
|
||||
font-family: 'DingTalk', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
html {
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.native-date-input {
|
||||
@@ -32,7 +45,7 @@
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(234, 243, 245, 0.9)),
|
||||
radial-gradient(circle at 14% 10%, rgba(122, 159, 171, 0.18), transparent 30%);
|
||||
box-shadow: 0 14px 34px rgba(128, 151, 155, 0.22);
|
||||
box-shadow: 0 10px 26px rgba(128, 151, 155, 0.18);
|
||||
}
|
||||
|
||||
.theme-2026-card::before {
|
||||
@@ -58,7 +71,7 @@
|
||||
|
||||
.theme-2026-card-hover:hover {
|
||||
border-color: rgba(116, 153, 165, 0.86);
|
||||
box-shadow: 0 20px 42px rgba(114, 143, 150, 0.32);
|
||||
box-shadow: 0 16px 34px rgba(114, 143, 150, 0.26);
|
||||
}
|
||||
|
||||
.dark .theme-2026-card {
|
||||
|
||||
@@ -45,9 +45,9 @@ const isDarkMode = useInjectedDarkMode()
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
sm: 'min-h-11 px-3 py-2 text-sm sm:min-h-0 sm:py-1.5',
|
||||
md: 'min-h-11 px-4 py-2 text-sm',
|
||||
lg: 'min-h-12 px-6 py-3 text-base'
|
||||
}
|
||||
return sizes[props.size]
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="relative transform overflow-hidden rounded-[30px] shadow-xl transition-all"
|
||||
class="relative flex max-h-[calc(100vh-2rem)] transform flex-col overflow-hidden rounded-[30px] shadow-xl transition-all"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
isDarkMode ? 'bg-gray-800' : 'bg-white'
|
||||
@@ -26,7 +26,7 @@
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
v-if="$slots.header || title"
|
||||
class="flex items-center justify-between px-6 py-4 border-b"
|
||||
class="flex shrink-0 items-center justify-between border-b px-4 py-3 sm:px-6 sm:py-4"
|
||||
:class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
|
||||
>
|
||||
<slot name="header">
|
||||
@@ -37,7 +37,7 @@
|
||||
<button
|
||||
v-if="closable"
|
||||
@click="$emit('close')"
|
||||
class="rounded-md p-2 transition-colors"
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg transition-colors"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700'
|
||||
@@ -49,14 +49,14 @@
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="px-6 py-4">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex items-center justify-end space-x-3 px-6 py-4 border-t"
|
||||
class="flex shrink-0 items-center justify-end space-x-3 border-t px-4 py-3 sm:px-6 sm:py-4"
|
||||
:class="[isDarkMode ? 'border-gray-700 bg-gray-900/50' : 'border-gray-200 bg-gray-50']"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
|
||||
@@ -134,7 +134,7 @@ const updatePart = (part: DatePart, value: string) => {
|
||||
<style scoped>
|
||||
.native-date-strip {
|
||||
display: inline-flex;
|
||||
width: max-content;
|
||||
width: var(--native-date-width, max-content);
|
||||
max-width: 100%;
|
||||
min-height: 2.75rem;
|
||||
align-items: center;
|
||||
@@ -212,6 +212,36 @@ const updatePart = (part: DatePart, value: string) => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
:global(.analytics-date-range) {
|
||||
--native-date-width: min(34vw, 8.1rem);
|
||||
}
|
||||
|
||||
.native-date-strip {
|
||||
min-height: clamp(2.35rem, 11vw, 2.75rem);
|
||||
padding-inline: clamp(0.32ch, 1vw, 0.55ch);
|
||||
}
|
||||
|
||||
.native-date-part {
|
||||
height: clamp(2.25rem, 10.5vw, 2.65rem);
|
||||
font-size: clamp(0.8rem, 3.65vw, 0.95rem);
|
||||
}
|
||||
|
||||
.native-date-year {
|
||||
width: 4ch;
|
||||
}
|
||||
|
||||
.native-date-month,
|
||||
.native-date-day {
|
||||
width: 2ch;
|
||||
}
|
||||
|
||||
.native-date-separator {
|
||||
margin-inline: clamp(0.12ch, 0.55vw, 0.3ch);
|
||||
font-size: clamp(0.8rem, 3.65vw, 0.95rem);
|
||||
}
|
||||
}
|
||||
|
||||
.dark .native-date-strip {
|
||||
background: rgba(38, 57, 65, 0.62);
|
||||
color: #d8e3e5;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="relative overflow-visible rounded-[30px] border" :class="panelClass">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="block h-80 min-h-80 w-full touch-none select-none md:h-72 md:min-h-72"
|
||||
class="block h-72 min-h-72 w-full touch-pan-y select-none sm:h-80 sm:min-h-80 md:h-72 md:min-h-72"
|
||||
@wheel.prevent="handleWheel"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@@ -44,8 +44,12 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
type DragState = {
|
||||
startOffset: number
|
||||
lastX: number
|
||||
time: number
|
||||
pointerType: string
|
||||
isTimeline: boolean
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
type TooltipState = {
|
||||
@@ -65,6 +69,11 @@ type ChartPoint = {
|
||||
row: AnalyticsDailyRow
|
||||
}
|
||||
|
||||
type ScaleVisual = {
|
||||
max: number
|
||||
ticks: number[]
|
||||
}
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const offset = ref(0)
|
||||
const windowSize = ref(30)
|
||||
@@ -77,6 +86,8 @@ const inertiaRafId = ref<number | null>(null)
|
||||
const lastInertiaTime = ref(0)
|
||||
const lastHoverTime = ref(0)
|
||||
const tooltip = ref<TooltipState | null>(null)
|
||||
const scaleVisual = ref<ScaleVisual | null>(null)
|
||||
const lastScaleTime = ref(0)
|
||||
|
||||
const INERTIA_REFERENCE_FRAME_MS = 1000 / 120
|
||||
const INERTIA_MAX_FRAME_MS = 1000 / 30
|
||||
@@ -84,6 +95,23 @@ const INERTIA_DECAY_PER_120HZ_FRAME = 0.96
|
||||
const INERTIA_STOP_VELOCITY = 0.01
|
||||
const HOVER_EASE_PER_120HZ_FRAME = 0.34
|
||||
const HOVER_SNAP_DISTANCE = 0.2
|
||||
const SCALE_EASE_PER_120HZ_FRAME = 0.2
|
||||
const SCALE_SNAP_DISTANCE = 0.01
|
||||
const getYAxisTicks = (rawMax: number) => {
|
||||
const roundedMax = Math.max(1, Math.ceil(rawMax))
|
||||
if (roundedMax <= 4) {
|
||||
return Array.from({ length: roundedMax + 1 }, (_, index) => roundedMax - index)
|
||||
}
|
||||
|
||||
const step = Math.ceil(roundedMax / 4)
|
||||
const scaleMax = step * Math.ceil(roundedMax / step)
|
||||
return Array.from({ length: scaleMax / step + 1 }, (_, index) => scaleMax - index * step)
|
||||
}
|
||||
|
||||
const formatAxisTick = (value: number) => {
|
||||
const rounded = Math.round(value)
|
||||
return Math.abs(value - rounded) < 0.01 ? String(rounded) : value.toFixed(1)
|
||||
}
|
||||
|
||||
const panelClass = computed(() =>
|
||||
props.isDarkMode
|
||||
@@ -159,10 +187,29 @@ const draw = (timestamp = performance.now()) => {
|
||||
const padBottom = mobile ? 42 : 36
|
||||
const width = Math.max(1, rect.width - padLeft - padRight)
|
||||
const height = Math.max(1, rect.height - padTop - padBottom)
|
||||
const max = Math.max(
|
||||
1,
|
||||
const rawMax = Math.max(
|
||||
0,
|
||||
...visible.flatMap((row) => [Number(row.downloads || 0), Number(row.uploads || 0)])
|
||||
)
|
||||
const targetTicks = getYAxisTicks(rawMax)
|
||||
const targetMax = targetTicks[0]
|
||||
const elapsedScale = lastScaleTime.value
|
||||
? Math.min(timestamp - lastScaleTime.value, INERTIA_MAX_FRAME_MS)
|
||||
: INERTIA_REFERENCE_FRAME_MS
|
||||
lastScaleTime.value = timestamp
|
||||
if (!scaleVisual.value) {
|
||||
scaleVisual.value = { max: targetMax, ticks: targetTicks }
|
||||
} else {
|
||||
const ease = 1 - Math.pow(1 - SCALE_EASE_PER_120HZ_FRAME, elapsedScale / INERTIA_REFERENCE_FRAME_MS)
|
||||
const nextMax = scaleVisual.value.max + (targetMax - scaleVisual.value.max) * ease
|
||||
scaleVisual.value = {
|
||||
max: Math.abs(nextMax - targetMax) < SCALE_SNAP_DISTANCE ? targetMax : nextMax,
|
||||
ticks: targetTicks
|
||||
}
|
||||
}
|
||||
const max = scaleVisual.value.max
|
||||
const yTicks = targetTicks.map((tick) => (tick * max) / targetMax)
|
||||
const isScaleSettling = Math.abs(max - targetMax) >= SCALE_SNAP_DISTANCE
|
||||
const muted = props.isDarkMode ? '#9ca3af' : '#6b7280'
|
||||
const border = props.isDarkMode ? '#374151' : '#e5e7eb'
|
||||
const downloadColor = props.isDarkMode ? '#8fb6c5' : '#5f8fa3'
|
||||
@@ -172,27 +219,35 @@ const draw = (timestamp = performance.now()) => {
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = muted
|
||||
ctx.font = '12px system-ui'
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const y = padTop + (height * i) / 3
|
||||
const yTickCount = Math.max(1, yTicks.length - 1)
|
||||
yTicks.forEach((tick, index) => {
|
||||
const y = padTop + (height * index) / yTickCount
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(padLeft, y)
|
||||
ctx.lineTo(padLeft + width, y)
|
||||
ctx.stroke()
|
||||
ctx.fillText(String(Math.round(max * (1 - i / 3))), 4, y + 4)
|
||||
}
|
||||
ctx.fillText(formatAxisTick(tick), 4, y + 4)
|
||||
})
|
||||
if (isScaleSettling) scheduleDraw()
|
||||
|
||||
const denominator = Math.max(1, windowSize.value - 1)
|
||||
const plotLeft = padLeft
|
||||
const plotRight = padLeft + width
|
||||
const buildPoints = (field: 'downloads' | 'uploads') =>
|
||||
visible
|
||||
.map((row, index) => {
|
||||
const sourceIndex = start + index
|
||||
return {
|
||||
x: padLeft + ((sourceIndex - offset.value) / denominator) * width,
|
||||
x: clamp(
|
||||
padLeft + ((sourceIndex - offset.value) / denominator) * width,
|
||||
plotLeft,
|
||||
plotRight
|
||||
),
|
||||
y: padTop + height - (Number(row[field] || 0) / max) * height,
|
||||
row
|
||||
}
|
||||
})
|
||||
.filter((point) => point.x >= padLeft - 50 && point.x <= padLeft + width + 50)
|
||||
.filter((point) => point.x >= plotLeft && point.x <= plotRight)
|
||||
|
||||
const drawSeries = (points: ChartPoint[], color: string) => {
|
||||
ctx.strokeStyle = color
|
||||
@@ -381,40 +436,78 @@ const handleWheel = (event: WheelEvent) => {
|
||||
zoom(event.deltaY > 0 ? 1.16 : 0.86, rect ? (event.clientX - rect.left) / rect.width : 0.5)
|
||||
}
|
||||
|
||||
const setHoverFromClientX = (clientX: number) => {
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
hoverX.value = clamp(clientX - rect.left, 0, rect.width)
|
||||
scheduleDraw()
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
canvasRef.value?.setPointerCapture(event.pointerId)
|
||||
cancelInertia()
|
||||
velocity.value = 0
|
||||
drag.value = { lastX: event.clientX, time: event.timeStamp || performance.now() }
|
||||
setHoverFromClientX(event.clientX)
|
||||
const isTimeline = Boolean(rect && event.clientY - rect.top >= rect.height - 52)
|
||||
drag.value = {
|
||||
startOffset: offset.value,
|
||||
lastX: event.clientX,
|
||||
time: event.timeStamp || performance.now(),
|
||||
pointerType: event.pointerType,
|
||||
isTimeline,
|
||||
moved: false
|
||||
}
|
||||
if (isTimeline) canvasRef.value!.style.touchAction = 'none'
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const rect = canvasRef.value?.getBoundingClientRect()
|
||||
const pointerEvents = event.getCoalescedEvents?.() || [event]
|
||||
const coalescedEvents = event.getCoalescedEvents?.() || []
|
||||
const pointerEvents = coalescedEvents.length ? coalescedEvents : [event]
|
||||
const latestEvent = pointerEvents[pointerEvents.length - 1] || event
|
||||
if (rect) hoverX.value = latestEvent.clientX - rect.left
|
||||
if (!drag.value) {
|
||||
scheduleDraw()
|
||||
return
|
||||
}
|
||||
|
||||
if (drag.value.pointerType === 'touch' && !drag.value.isTimeline) {
|
||||
scheduleDraw()
|
||||
return
|
||||
}
|
||||
|
||||
const totalDistance = Math.abs(latestEvent.clientX - drag.value.lastX)
|
||||
const threshold = 3
|
||||
if (!drag.value.moved && totalDistance < threshold) {
|
||||
scheduleDraw()
|
||||
return
|
||||
}
|
||||
drag.value.moved = true
|
||||
|
||||
pointerEvents.forEach((pointerEvent) => {
|
||||
const now = pointerEvent.timeStamp || performance.now()
|
||||
const deltaX = pointerEvent.clientX - drag.value!.lastX
|
||||
const deltaTime = Math.max(1, now - drag.value!.time)
|
||||
velocity.value = deltaX / deltaTime
|
||||
pan(deltaX)
|
||||
drag.value = { lastX: pointerEvent.clientX, time: now }
|
||||
drag.value = { ...drag.value!, lastX: pointerEvent.clientX, time: now, moved: true }
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
const timelineDrag = drag.value?.isTimeline
|
||||
const shouldInertia = Boolean(drag.value?.moved)
|
||||
drag.value = null
|
||||
if (canvasRef.value) canvasRef.value.style.touchAction = ''
|
||||
cancelInertia()
|
||||
inertiaRafId.value = window.requestAnimationFrame(applyInertia)
|
||||
if (shouldInertia && !timelineDrag) inertiaRafId.value = window.requestAnimationFrame(applyInertia)
|
||||
else scheduleDraw()
|
||||
}
|
||||
|
||||
const handlePointerCancel = () => {
|
||||
drag.value = null
|
||||
if (canvasRef.value) canvasRef.value.style.touchAction = ''
|
||||
}
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="theme-2026-card theme-2026-card-hover group rounded-[30px] p-6 shadow-md transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card theme-2026-card-hover group rounded-[24px] p-4 shadow-md transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lg sm:rounded-[30px] sm:p-6"
|
||||
:class="[
|
||||
isDarkMode
|
||||
? 'hover:bg-gray-800 hover:shadow-black/20'
|
||||
: 'hover:shadow-gray-200/80'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs sm:text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
|
||||
{{ title }}
|
||||
</p>
|
||||
<h3 class="text-2xl font-bold mt-1" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
<h3 class="mt-1 truncate text-xl font-bold tabular-nums sm:text-2xl" :class="[isDarkMode ? 'text-white' : 'text-gray-800']">
|
||||
{{ value }}
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-full p-3 transition-transform duration-300 group-hover:scale-105"
|
||||
class="shrink-0 rounded-full p-2.5 transition-transform duration-300 group-hover:scale-105 sm:p-3"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<component :is="icon" class="w-6 h-6" :class="iconClass" />
|
||||
<component :is="icon" class="h-5 w-5 sm:h-6 sm:w-6" :class="iconClass" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm mt-2" :class="descriptionClass">
|
||||
<p class="mt-2 line-clamp-1 text-xs sm:text-sm" :class="descriptionClass">
|
||||
<slot name="description"></slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -439,7 +439,9 @@ export function useAdminFiles() {
|
||||
]
|
||||
|
||||
const detailPolicyActionOptions = computed<DetailPolicyActionOption[]>(() =>
|
||||
createPolicyActionOptions(Boolean(selectedFileDetail.value?.isPermanentFile))
|
||||
createPolicyActionOptions(Boolean(selectedFileDetail.value?.isPermanentFile)).filter(
|
||||
(option) => !option.disabled
|
||||
)
|
||||
)
|
||||
|
||||
const batchPolicyActionOptions = computed<BatchPolicyActionOption[]>(
|
||||
|
||||
@@ -55,6 +55,7 @@ export default {
|
||||
admin: {
|
||||
logout: 'Logout',
|
||||
dashboard: {
|
||||
adminLabel: 'FileCodeBox Admin',
|
||||
title: 'Dashboard',
|
||||
totalFiles: 'Total Files',
|
||||
storageSpace: 'Storage Space',
|
||||
@@ -536,6 +537,8 @@ export default {
|
||||
statusInsight: 'Status Insight',
|
||||
nextAction: 'Next Action',
|
||||
lifecycle: 'Lifecycle',
|
||||
expandLifecycle: 'Show all lifecycle records ({count})',
|
||||
collapseLifecycle: 'Collapse lifecycle records',
|
||||
permanent: 'Permanent',
|
||||
createdAt: 'Created Time',
|
||||
remainingDownloads: 'Remaining Downloads',
|
||||
|
||||
@@ -55,6 +55,7 @@ export default {
|
||||
admin: {
|
||||
logout: '退出登录',
|
||||
dashboard: {
|
||||
adminLabel: 'FileCodeBox 管理后台',
|
||||
title: '仪表盘',
|
||||
totalFiles: '总文件数',
|
||||
storageSpace: '存储空间',
|
||||
@@ -533,6 +534,8 @@ export default {
|
||||
statusInsight: '状态洞察',
|
||||
nextAction: '建议动作',
|
||||
lifecycle: '生命周期',
|
||||
expandLifecycle: '展开全部生命周期记录(共 {count} 条)',
|
||||
collapseLifecycle: '收起生命周期记录',
|
||||
permanent: '永久有效',
|
||||
createdAt: '创建时间',
|
||||
remainingDownloads: '剩余次数',
|
||||
|
||||
+29
-12
@@ -36,30 +36,47 @@ export const copyToClipboard = async (
|
||||
}
|
||||
}
|
||||
|
||||
const showManualCopyDialog = () => {
|
||||
window.prompt('复制下面内容', text)
|
||||
}
|
||||
|
||||
try {
|
||||
// 优先使用 Clipboard API
|
||||
if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showCopyMessage(successMsg, 'success')
|
||||
return true
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showCopyMessage(successMsg, 'success')
|
||||
return true
|
||||
} catch {
|
||||
// Continue to the DOM fallback.
|
||||
}
|
||||
}
|
||||
// 后备方案:使用传统的复制方法
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.cssText =
|
||||
'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
const success = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
let success = false
|
||||
try {
|
||||
success = document.execCommand('copy')
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
if (success) {
|
||||
showCopyMessage(successMsg, 'success')
|
||||
return true
|
||||
} else {
|
||||
throw new Error('execCommand copy failed')
|
||||
}
|
||||
|
||||
showManualCopyDialog()
|
||||
showCopyMessage('已打开手动复制窗口', 'error')
|
||||
return false
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
showManualCopyDialog()
|
||||
showCopyMessage(errorMsg, 'error')
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<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>
|
||||
<p class="text-sm" :class="[mutedTextClass]">FileCodeBox Admin</p>
|
||||
<p class="text-sm" :class="[mutedTextClass]">{{ t('admin.dashboard.adminLabel') }}</p>
|
||||
<h2 class="text-2xl font-bold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.title') }}
|
||||
</h2>
|
||||
@@ -32,7 +32,7 @@
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:gap-4 xl:grid-cols-4">
|
||||
<StatCard
|
||||
:title="t('admin.dashboard.totalFiles')"
|
||||
:value="dashboardData.totalFiles"
|
||||
@@ -78,12 +78,12 @@
|
||||
</StatCard>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-3">
|
||||
<div v-if="dashboardData.hasExtendedStats" class="mt-6 grid grid-cols-1 gap-6 2xl:grid-cols-3">
|
||||
<section
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[30px] p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg xl:col-span-2"
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[24px] p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg sm:rounded-[30px] sm:p-5 2xl:col-span-2"
|
||||
:class="[panelClass, cardHoverClass]"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-5">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('admin.dashboard.fileHealth') }}
|
||||
@@ -98,7 +98,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="grid grid-cols-3 gap-2 sm:gap-4">
|
||||
<MetricProgress
|
||||
:label="t('admin.dashboard.activeFileRatio')"
|
||||
:value="dashboardData.activeRatio"
|
||||
@@ -119,24 +119,24 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
<div class="mt-4 grid grid-cols-2 gap-2 sm:mt-6 sm:gap-3 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
<button
|
||||
v-for="action in healthActions"
|
||||
:key="action.key"
|
||||
type="button"
|
||||
class="theme-2026-card-hover group flex min-h-28 flex-col justify-between rounded-[30px] border p-4 text-left shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card-hover group flex min-h-20 flex-col justify-between rounded-[20px] border p-3 text-left shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md sm:min-h-24 sm:rounded-[30px] sm:p-4"
|
||||
:class="getHealthActionClass(action.tone)"
|
||||
@click="openHealthQueue(action.health)"
|
||||
>
|
||||
<span class="flex items-start justify-between gap-3">
|
||||
<span>
|
||||
<span class="block text-2xl font-semibold">{{ action.count }}</span>
|
||||
<span class="block text-xl font-semibold sm:text-2xl">{{ action.count }}</span>
|
||||
<span class="mt-1 block text-sm font-medium">{{ action.label }}</span>
|
||||
</span>
|
||||
<component :is="getHealthActionIcon(action.tone)" class="h-5 w-5 shrink-0" />
|
||||
</span>
|
||||
<span class="mt-3 flex items-center justify-between gap-2 text-xs">
|
||||
<span class="line-clamp-2">{{ action.description }}</span>
|
||||
<span class="mt-2 flex items-center justify-between gap-2 text-xs sm:mt-3">
|
||||
<span class="hidden line-clamp-2 sm:block">{{ action.description }}</span>
|
||||
<ArrowRightIcon
|
||||
class="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
@@ -144,9 +144,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="mt-4 grid grid-cols-2 gap-2 sm:mt-6 sm:gap-4">
|
||||
<div
|
||||
class="theme-2026-card-hover rounded-[30px] border p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card-hover rounded-[20px] border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md sm:rounded-[30px] sm:p-4 sm:shadow-md"
|
||||
:class="[subtlePanelClass, cardHoverClass]"
|
||||
>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
@@ -163,7 +163,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="theme-2026-card-hover rounded-[30px] border p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card-hover rounded-[20px] border p-3 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md sm:rounded-[30px] sm:p-4 sm:shadow-md"
|
||||
:class="[subtlePanelClass, cardHoverClass]"
|
||||
>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
@@ -182,7 +182,7 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[30px] p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[24px] p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg sm:rounded-[30px] sm:p-5"
|
||||
:class="[panelClass, cardHoverClass]"
|
||||
>
|
||||
<div class="mb-5">
|
||||
@@ -230,18 +230,20 @@
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<section
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[30px] p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[24px] p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg sm:rounded-[30px] sm:p-5"
|
||||
:class="[panelClass, cardHoverClass]"
|
||||
>
|
||||
<div class="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3>
|
||||
<p class="text-sm" :class="[mutedTextClass]">
|
||||
{{ analyticsSummaryText }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full max-w-full space-y-3 lg:w-auto">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="mb-5 flex flex-col gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3>
|
||||
<p class="break-words text-sm leading-5" :class="[mutedTextClass]">
|
||||
{{ analyticsSummaryText }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs sm:hidden" :class="[mutedTextClass]">轻触或滑动查看数据;拖动底部日期轴平移图表</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 xl:flex-nowrap">
|
||||
<div class="analytics-date-range flex min-w-0 items-center justify-center gap-1.5 sm:gap-2">
|
||||
<NativeDateSelect
|
||||
v-model="analyticsStart"
|
||||
:field-class="fieldClass"
|
||||
@@ -249,7 +251,7 @@
|
||||
@change="fetchAnalyticsData"
|
||||
/>
|
||||
<span
|
||||
class="self-center text-sm font-semibold"
|
||||
class="shrink-0 text-sm font-semibold"
|
||||
:class="[isDarkMode ? 'text-[#aabdc2]' : 'text-[#789096]']"
|
||||
>
|
||||
-
|
||||
@@ -261,7 +263,7 @@
|
||||
@change="fetchAnalyticsData"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 sm:flex sm:justify-end">
|
||||
<div class="hidden grid-cols-3 gap-2 sm:flex sm:justify-end">
|
||||
<BaseButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -288,6 +290,7 @@
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="analyticsError"
|
||||
@@ -309,7 +312,7 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[30px] p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card theme-2026-card-hover rounded-[24px] p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg sm:rounded-[30px] sm:p-5"
|
||||
:class="[panelClass, cardHoverClass]"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
@@ -631,12 +634,12 @@ const MetricProgress = defineComponent({
|
||||
'div',
|
||||
{
|
||||
class:
|
||||
'theme-2026-card-hover rounded-[30px] border border-[#d4e0e3] bg-[#f6faf9] p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:border-[#8fb2bf] hover:shadow-lg hover:shadow-[#a9bdc2]/60 dark:border-[#40545c] dark:bg-[#17252c]/50 dark:hover:border-[#7199a8] dark:hover:bg-[#263941] dark:hover:shadow-black/25'
|
||||
'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 items-center justify-between text-sm' }, [
|
||||
h('span', { class: 'text-gray-500 dark:text-gray-400' }, props.label),
|
||||
h('span', { class: 'font-medium text-gray-900 dark:text-white' }, `${props.value}%`)
|
||||
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('span', { class: 'font-medium tabular-nums text-gray-900 dark:text-white' }, `${props.value}%`)
|
||||
]),
|
||||
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [
|
||||
h('div', {
|
||||
@@ -644,7 +647,7 @@ const MetricProgress = defineComponent({
|
||||
style: { width: `${props.value}%` }
|
||||
})
|
||||
]),
|
||||
h('p', { class: 'mt-2 text-sm text-gray-500 dark:text-gray-400' }, props.detail)
|
||||
h('p', { class: 'mt-2 hidden text-sm text-gray-500 sm:block dark:text-gray-400' }, props.detail)
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-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,22 +11,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div 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"
|
||||
class="theme-2026-card theme-2026-card-hover group rounded-[30px] border p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
class="theme-2026-card theme-2026-card-hover group rounded-[24px] border p-3.5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg sm:rounded-[30px] sm:p-4"
|
||||
:class="[panelClass, cardHoverClass]"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm" :class="[mutedTextClass]">{{ card.label }}</p>
|
||||
<p class="mt-2 text-2xl font-semibold" :class="[primaryTextClass]">
|
||||
<p class="text-xs sm:text-sm" :class="[mutedTextClass]">{{ card.label }}</p>
|
||||
<p class="mt-1.5 truncate text-xl font-semibold tabular-nums sm:mt-2 sm:text-2xl" :class="[primaryTextClass]">
|
||||
{{ card.value }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg p-2 transition-transform duration-200 group-hover:scale-105"
|
||||
class="rounded-lg p-2 transition-transform duration-200 group-hover:scale-105 max-sm:p-1.5"
|
||||
:class="[card.iconClass]"
|
||||
>
|
||||
<component :is="card.icon" class="h-5 w-5" />
|
||||
@@ -36,17 +36,17 @@
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="theme-2026-card theme-2026-card-hover mb-6 rounded-[30px] border p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
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]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center justify-between gap-3 sm:flex-row">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold" :class="[primaryTextClass]">条件筛选</h3>
|
||||
<p class="mt-1 text-xs" :class="[mutedTextClass]">
|
||||
<p class="mt-1 hidden text-xs min-[360px]:block" :class="[mutedTextClass]">
|
||||
{{ hasActiveFilters ? '已应用筛选条件' : '默认收起,需要时展开' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
<BaseButton
|
||||
v-if="hasActiveFilters"
|
||||
variant="outline"
|
||||
@@ -67,7 +67,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="filterBodyVisible" class="mt-4 grid gap-4">
|
||||
<Transition name="fcb-expand-120">
|
||||
<div v-if="filterBodyVisible" class="fcb-expand-content mt-4 grid gap-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<label class="min-w-0 flex-1">
|
||||
<span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span>
|
||||
@@ -253,8 +254,109 @@
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="theme-2026-card mb-5 overflow-hidden rounded-[24px] shadow-md md:hidden"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 border-b px-4 py-3" :class="[isDarkMode ? 'border-gray-700' : 'border-[#d9e4e6]']">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold" :class="[primaryTextClass]">{{ t('fileManage.allFiles') }}</h3>
|
||||
<p class="mt-0.5 text-xs" :class="[mutedTextClass]">{{ t('fileManage.subtitle', { count: summary.totalFiles }) }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border"
|
||||
:class="detailActionClass"
|
||||
:disabled="isLoading"
|
||||
:aria-label="t('fileManage.refresh')"
|
||||
@click="refreshFileList"
|
||||
>
|
||||
<RefreshCwIcon class="h-5 w-5" :class="isLoading ? 'animate-spin' : ''" />
|
||||
</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') }}
|
||||
</div>
|
||||
<div v-else-if="hasLoadError" class="px-4 py-10 text-center">
|
||||
<p class="text-sm font-medium" :class="[primaryTextClass]">{{ t('fileManage.loadError') }}</p>
|
||||
<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') }}
|
||||
</div>
|
||||
<div v-else class="divide-y" :class="[isDarkMode ? 'divide-gray-700' : 'divide-[#d9e4e6]']">
|
||||
<article
|
||||
v-for="file in tableData"
|
||||
:key="file.id"
|
||||
class="px-4 py-4"
|
||||
:class="selectedFileIds.has(file.id) ? (isDarkMode ? 'bg-[#294047]/50' : 'bg-[#e5eeee]') : ''"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<label class="flex h-11 w-11 shrink-0 cursor-pointer items-start justify-center pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
:checked="selectedFileIds.has(file.id)"
|
||||
:disabled="isBatchActionRunning"
|
||||
:aria-label="t('fileManage.selectFile', { name: file.displayName })"
|
||||
@change="toggleFileSelection(file.id)"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="min-w-0 flex-1 text-left" @click="openFileDetail(file)">
|
||||
<p class="line-clamp-2 break-all text-sm font-semibold leading-5" :class="[primaryTextClass]">
|
||||
{{ file.displayName }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
|
||||
:class="getStatusBadgeClass(file)"
|
||||
>
|
||||
{{ file.isExpiredFile ? t('fileManage.expired') : t('fileManage.active') }}
|
||||
</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="getTypeBadgeClass(file)">
|
||||
{{ getTypeLabel(file) }}
|
||||
</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="getInsightBadgeClass(file.statusInsightSeverity)">
|
||||
{{ file.displayHealthState }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 gap-x-3 gap-y-1 text-xs" :class="[mutedTextClass]">
|
||||
<span>{{ file.code }} · {{ file.displaySize }}</span>
|
||||
<span class="text-right">{{ file.displayUsage }}</span>
|
||||
<span class="col-span-2">{{ file.displayExpiredAt }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-3 gap-2 pl-14">
|
||||
<button type="button" class="flex min-h-11 items-center justify-center rounded-lg border text-sm font-medium" :class="detailActionClass" @click="openFileDetail(file)">
|
||||
<FileTextIcon class="mr-1.5 h-4 w-4" />{{ t('fileManage.detail') }}
|
||||
</button>
|
||||
<button type="button" class="flex min-h-11 items-center justify-center rounded-lg border text-sm font-medium" :class="detailActionClass" :disabled="Boolean(downloadingFileId)" @click="downloadFile(file)">
|
||||
<DownloadIcon class="mr-1.5 h-4 w-4" />{{ file.isTextFile ? t('fileManage.exportText') : t('fileManage.downloadFile') }}
|
||||
</button>
|
||||
<button type="button" class="flex min-h-11 items-center justify-center rounded-lg border text-sm font-medium" :class="detailActionClass" @click="openEditModal(file)">
|
||||
<PencilIcon class="mr-1.5 h-4 w-4" />{{ t('common.edit') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="params.total > 0" class="border-t" :class="[isDarkMode ? 'border-gray-700' : 'border-[#d9e4e6]']">
|
||||
<DataPagination
|
||||
:current-page="params.page"
|
||||
:page-size="params.size"
|
||||
:total="params.total"
|
||||
@page-change="handlePageChange"
|
||||
@page-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="hidden md:block">
|
||||
<DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders">
|
||||
<template #actions>
|
||||
<BaseButton variant="secondary" :loading="isLoading" @click="refreshFileList">
|
||||
@@ -269,10 +371,10 @@
|
||||
v-if="tableData.length > 0"
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<label class="inline-flex items-center gap-2 text-sm" :class="[primaryTextClass]">
|
||||
<label class="inline-flex min-h-11 cursor-pointer items-center gap-2 text-sm sm:min-h-0" :class="[primaryTextClass]">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
:checked="isAllCurrentPageSelected"
|
||||
:disabled="isBatchActionRunning"
|
||||
:indeterminate="isCurrentPagePartiallySelected"
|
||||
@@ -287,7 +389,7 @@
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="hasSelectedFiles" class="flex flex-wrap items-center gap-2">
|
||||
<BaseButton
|
||||
v-if="hasSelectedFiles"
|
||||
variant="outline"
|
||||
@@ -407,7 +509,7 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
class="h-5 w-5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
:checked="selectedFileIds.has(file.id)"
|
||||
:disabled="isBatchActionRunning"
|
||||
:aria-label="t('fileManage.selectFile', { name: file.displayName })"
|
||||
@@ -582,6 +684,7 @@
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="theme-2026-card theme-2026-card-hover mt-6 rounded-[30px] border p-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
@@ -873,70 +976,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="theme-2026-card-hover space-y-3 rounded-[30px] border px-4 py-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
:class="[subtleSectionClass, cardHoverClass]"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.metadataInfo') }}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs" :class="[mutedTextClass]">
|
||||
{{ t('fileManage.metadataHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
:loading="isDetailMetadataSaving"
|
||||
:disabled="isDetailLoading"
|
||||
@click="updateDetailMetadata"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="mr-2 h-4 w-4" />
|
||||
</template>
|
||||
{{ t('fileManage.saveMetadata') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,0.55fr)]">
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-medium" :class="[mutedTextClass]">
|
||||
{{ t('fileManage.metadataNote') }}
|
||||
</span>
|
||||
<textarea
|
||||
v-model="detailMetadataForm.note"
|
||||
rows="4"
|
||||
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="[fieldClass]"
|
||||
:placeholder="t('fileManage.metadataNotePlaceholder')"
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-medium" :class="[mutedTextClass]">
|
||||
{{ t('fileManage.metadataTags') }}
|
||||
</span>
|
||||
<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="[fieldClass]"
|
||||
:placeholder="t('fileManage.metadataTagsPlaceholder')"
|
||||
/>
|
||||
<span class="block text-xs" :class="[mutedTextClass]">
|
||||
{{
|
||||
selectedFileDetail.metadataUpdatedAt
|
||||
? t('fileManage.metadataUpdatedAt', {
|
||||
time: formatMetadataUpdatedAt(selectedFileDetail.metadataUpdatedAt)
|
||||
})
|
||||
: t('fileManage.metadataNeverUpdated')
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="theme-2026-card-hover rounded-[30px] border px-4 py-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
:class="getInsightPanelClass(selectedFileDetail.statusInsightSeverity)"
|
||||
@@ -1046,25 +1085,43 @@
|
||||
</section>
|
||||
|
||||
<section v-if="selectedFileDetail.detailTimeline.length > 0" class="space-y-3">
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.lifecycle') }}
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.lifecycle') }}
|
||||
</h4>
|
||||
<button
|
||||
v-if="selectedFileDetail.detailTimeline.length > 3"
|
||||
type="button"
|
||||
class="inline-flex min-h-11 items-center self-start rounded-lg border px-3 py-2 text-sm font-medium transition-colors sm:min-h-0 sm:self-auto"
|
||||
:class="detailActionClass"
|
||||
@click="isLifecycleExpanded = !isLifecycleExpanded"
|
||||
>
|
||||
{{
|
||||
isLifecycleExpanded
|
||||
? t('fileManage.collapseLifecycle')
|
||||
: t('fileManage.expandLifecycle', {
|
||||
count: selectedFileDetail.detailTimeline.length
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-[30px] border px-4 py-4 shadow-md"
|
||||
:class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-white']"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in selectedFileDetail.detailTimeline"
|
||||
:key="`${item.key}-${index}`"
|
||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<TransitionGroup name="fcb-expand-120" tag="div">
|
||||
<div
|
||||
v-for="(item, index) in visibleDetailTimeline"
|
||||
:key="`${item.key}-${index}`"
|
||||
class="fcb-expand-content grid grid-cols-[auto_minmax(0,1fr)] gap-3"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class="mt-1 h-3 w-3 rounded-full"
|
||||
:class="getTimelineDotClass(item.severity)"
|
||||
/>
|
||||
<span
|
||||
v-if="index < selectedFileDetail.detailTimeline.length - 1"
|
||||
v-if="index < visibleDetailTimeline.length - 1"
|
||||
class="my-1 w-px flex-1"
|
||||
:class="[isDarkMode ? 'bg-gray-600' : 'bg-gray-200']"
|
||||
/>
|
||||
@@ -1087,7 +1144,72 @@
|
||||
{{ item.displayDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="theme-2026-card-hover space-y-3 rounded-[30px] border px-4 py-4 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
|
||||
:class="[subtleSectionClass, cardHoverClass]"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||
{{ t('fileManage.metadataInfo') }}
|
||||
</h4>
|
||||
<p class="mt-1 text-xs" :class="[mutedTextClass]">
|
||||
{{ t('fileManage.metadataHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
:loading="isDetailMetadataSaving"
|
||||
:disabled="isDetailLoading"
|
||||
@click="updateDetailMetadata"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon class="mr-2 h-4 w-4" />
|
||||
</template>
|
||||
{{ t('fileManage.saveMetadata') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(220px,0.55fr)]">
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-medium" :class="[mutedTextClass]">
|
||||
{{ t('fileManage.metadataNote') }}
|
||||
</span>
|
||||
<textarea
|
||||
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="[fieldClass]"
|
||||
:placeholder="t('fileManage.metadataNotePlaceholder')"
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="space-y-1">
|
||||
<span class="text-xs font-medium" :class="[mutedTextClass]">
|
||||
{{ t('fileManage.metadataTags') }}
|
||||
</span>
|
||||
<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="[fieldClass]"
|
||||
:placeholder="t('fileManage.metadataTagsPlaceholder')"
|
||||
/>
|
||||
<span class="block text-xs" :class="[mutedTextClass]">
|
||||
{{
|
||||
selectedFileDetail.metadataUpdatedAt
|
||||
? t('fileManage.metadataUpdatedAt', {
|
||||
time: formatMetadataUpdatedAt(selectedFileDetail.metadataUpdatedAt)
|
||||
})
|
||||
: t('fileManage.metadataNeverUpdated')
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1572,6 +1694,11 @@ const {
|
||||
} = useAdminFiles()
|
||||
|
||||
const isFilterPanelOpen = 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)
|
||||
const isHistoryLoading = ref(false)
|
||||
const historyRows = ref<AnalyticsFileRow[]>([])
|
||||
@@ -2051,6 +2178,13 @@ const getTimelineDotClass = (severity: AdminFileInsightSeverity) => {
|
||||
return classes[severity]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedFileDetail.value?.id,
|
||||
() => {
|
||||
isLifecycleExpanded.value = false
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.health,
|
||||
async () => {
|
||||
@@ -2067,3 +2201,43 @@ onMounted(() => {
|
||||
void loadHistoryFiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fcb-expand-content {
|
||||
overflow: clip;
|
||||
will-change: grid-template-rows, opacity, transform;
|
||||
}
|
||||
|
||||
.fcb-expand-120-enter-active,
|
||||
.fcb-expand-120-leave-active {
|
||||
overflow: clip;
|
||||
transition:
|
||||
opacity 300ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 300ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
max-height 300ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
margin 300ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.fcb-expand-120-enter-from,
|
||||
.fcb-expand-120-leave-to {
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-0.35rem);
|
||||
}
|
||||
|
||||
.fcb-expand-120-enter-to,
|
||||
.fcb-expand-120-leave-from {
|
||||
max-height: 96rem;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fcb-expand-120-enter-active,
|
||||
.fcb-expand-120-leave-active {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user