Test/custom admin ui #3

Merged
orion merged 6 commits from test/custom-admin-ui into main 2026-07-21 15:14:23 +08:00
12 changed files with 507 additions and 169 deletions
Showing only changes of commit 1225fee430 - Show all commits
+16 -3
View File
@@ -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 { .native-date-input {
@@ -32,7 +45,7 @@
background: background:
linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(234, 243, 245, 0.9)), 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%); 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 { .theme-2026-card::before {
@@ -58,7 +71,7 @@
.theme-2026-card-hover:hover { .theme-2026-card-hover:hover {
border-color: rgba(116, 153, 165, 0.86); 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 { .dark .theme-2026-card {
+3 -3
View File
@@ -45,9 +45,9 @@ const isDarkMode = useInjectedDarkMode()
const sizeClasses = computed(() => { const sizeClasses = computed(() => {
const sizes = { const sizes = {
sm: 'px-3 py-1.5 text-sm', sm: 'min-h-11 px-3 py-2 text-sm sm:min-h-0 sm:py-1.5',
md: 'px-4 py-2 text-sm', md: 'min-h-11 px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base' lg: 'min-h-12 px-6 py-3 text-base'
} }
return sizes[props.size] return sizes[props.size]
}) })
+5 -5
View File
@@ -16,7 +16,7 @@
<div class="flex min-h-full items-center justify-center p-4"> <div class="flex min-h-full items-center justify-center p-4">
<div <div
ref="modalRef" ref="modalRef"
class="relative 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="[ :class="[
sizeClasses, sizeClasses,
isDarkMode ? 'bg-gray-800' : 'bg-white' isDarkMode ? 'bg-gray-800' : 'bg-white'
@@ -26,7 +26,7 @@
<!-- 头部 --> <!-- 头部 -->
<div <div
v-if="$slots.header || title" 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']" :class="[isDarkMode ? 'border-gray-700' : 'border-gray-200']"
> >
<slot name="header"> <slot name="header">
@@ -37,7 +37,7 @@
<button <button
v-if="closable" v-if="closable"
@click="$emit('close')" @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="[ :class="[
isDarkMode isDarkMode
? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700'
@@ -49,14 +49,14 @@
</div> </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> <slot></slot>
</div> </div>
<!-- 底部 --> <!-- 底部 -->
<div <div
v-if="$slots.footer" 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']" :class="[isDarkMode ? 'border-gray-700 bg-gray-900/50' : 'border-gray-200 bg-gray-50']"
> >
<slot name="footer"></slot> <slot name="footer"></slot>
+31 -1
View File
@@ -134,7 +134,7 @@ const updatePart = (part: DatePart, value: string) => {
<style scoped> <style scoped>
.native-date-strip { .native-date-strip {
display: inline-flex; display: inline-flex;
width: max-content; width: var(--native-date-width, max-content);
max-width: 100%; max-width: 100%;
min-height: 2.75rem; min-height: 2.75rem;
align-items: center; align-items: center;
@@ -212,6 +212,36 @@ const updatePart = (part: DatePart, value: string) => {
pointer-events: none; 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 { .dark .native-date-strip {
background: rgba(38, 57, 65, 0.62); background: rgba(38, 57, 65, 0.62);
color: #d8e3e5; color: #d8e3e5;
+106 -13
View File
@@ -2,7 +2,7 @@
<div class="relative overflow-visible rounded-[30px] border" :class="panelClass"> <div class="relative overflow-visible rounded-[30px] border" :class="panelClass">
<canvas <canvas
ref="canvasRef" 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" @wheel.prevent="handleWheel"
@pointerdown="handlePointerDown" @pointerdown="handlePointerDown"
@pointermove="handlePointerMove" @pointermove="handlePointerMove"
@@ -44,8 +44,12 @@ const emit = defineEmits<{
}>() }>()
type DragState = { type DragState = {
startOffset: number
lastX: number lastX: number
time: number time: number
pointerType: string
isTimeline: boolean
moved: boolean
} }
type TooltipState = { type TooltipState = {
@@ -65,6 +69,11 @@ type ChartPoint = {
row: AnalyticsDailyRow row: AnalyticsDailyRow
} }
type ScaleVisual = {
max: number
ticks: number[]
}
const canvasRef = ref<HTMLCanvasElement | null>(null) const canvasRef = ref<HTMLCanvasElement | null>(null)
const offset = ref(0) const offset = ref(0)
const windowSize = ref(30) const windowSize = ref(30)
@@ -77,6 +86,8 @@ const inertiaRafId = ref<number | null>(null)
const lastInertiaTime = ref(0) const lastInertiaTime = ref(0)
const lastHoverTime = ref(0) const lastHoverTime = ref(0)
const tooltip = ref<TooltipState | null>(null) 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_REFERENCE_FRAME_MS = 1000 / 120
const INERTIA_MAX_FRAME_MS = 1000 / 30 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 INERTIA_STOP_VELOCITY = 0.01
const HOVER_EASE_PER_120HZ_FRAME = 0.34 const HOVER_EASE_PER_120HZ_FRAME = 0.34
const HOVER_SNAP_DISTANCE = 0.2 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(() => const panelClass = computed(() =>
props.isDarkMode props.isDarkMode
@@ -159,10 +187,29 @@ const draw = (timestamp = performance.now()) => {
const padBottom = mobile ? 42 : 36 const padBottom = mobile ? 42 : 36
const width = Math.max(1, rect.width - padLeft - padRight) const width = Math.max(1, rect.width - padLeft - padRight)
const height = Math.max(1, rect.height - padTop - padBottom) const height = Math.max(1, rect.height - padTop - padBottom)
const max = Math.max( const rawMax = Math.max(
1, 0,
...visible.flatMap((row) => [Number(row.downloads || 0), Number(row.uploads || 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 muted = props.isDarkMode ? '#9ca3af' : '#6b7280'
const border = props.isDarkMode ? '#374151' : '#e5e7eb' const border = props.isDarkMode ? '#374151' : '#e5e7eb'
const downloadColor = props.isDarkMode ? '#8fb6c5' : '#5f8fa3' const downloadColor = props.isDarkMode ? '#8fb6c5' : '#5f8fa3'
@@ -172,27 +219,35 @@ const draw = (timestamp = performance.now()) => {
ctx.lineWidth = 1 ctx.lineWidth = 1
ctx.fillStyle = muted ctx.fillStyle = muted
ctx.font = '12px system-ui' ctx.font = '12px system-ui'
for (let i = 0; i < 4; i += 1) { const yTickCount = Math.max(1, yTicks.length - 1)
const y = padTop + (height * i) / 3 yTicks.forEach((tick, index) => {
const y = padTop + (height * index) / yTickCount
ctx.beginPath() ctx.beginPath()
ctx.moveTo(padLeft, y) ctx.moveTo(padLeft, y)
ctx.lineTo(padLeft + width, y) ctx.lineTo(padLeft + width, y)
ctx.stroke() 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 denominator = Math.max(1, windowSize.value - 1)
const plotLeft = padLeft
const plotRight = padLeft + width
const buildPoints = (field: 'downloads' | 'uploads') => const buildPoints = (field: 'downloads' | 'uploads') =>
visible visible
.map((row, index) => { .map((row, index) => {
const sourceIndex = start + index const sourceIndex = start + index
return { 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, y: padTop + height - (Number(row[field] || 0) / max) * height,
row 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) => { const drawSeries = (points: ChartPoint[], color: string) => {
ctx.strokeStyle = color 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) 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 handlePointerDown = (event: PointerEvent) => {
const rect = canvasRef.value?.getBoundingClientRect()
canvasRef.value?.setPointerCapture(event.pointerId) canvasRef.value?.setPointerCapture(event.pointerId)
cancelInertia() cancelInertia()
velocity.value = 0 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 handlePointerMove = (event: PointerEvent) => {
const rect = canvasRef.value?.getBoundingClientRect() 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 const latestEvent = pointerEvents[pointerEvents.length - 1] || event
if (rect) hoverX.value = latestEvent.clientX - rect.left if (rect) hoverX.value = latestEvent.clientX - rect.left
if (!drag.value) { if (!drag.value) {
scheduleDraw() scheduleDraw()
return 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) => { pointerEvents.forEach((pointerEvent) => {
const now = pointerEvent.timeStamp || performance.now() const now = pointerEvent.timeStamp || performance.now()
const deltaX = pointerEvent.clientX - drag.value!.lastX const deltaX = pointerEvent.clientX - drag.value!.lastX
const deltaTime = Math.max(1, now - drag.value!.time) const deltaTime = Math.max(1, now - drag.value!.time)
velocity.value = deltaX / deltaTime velocity.value = deltaX / deltaTime
pan(deltaX) pan(deltaX)
drag.value = { lastX: pointerEvent.clientX, time: now } drag.value = { ...drag.value!, lastX: pointerEvent.clientX, time: now, moved: true }
}) })
} }
const handlePointerUp = () => { const handlePointerUp = () => {
const timelineDrag = drag.value?.isTimeline
const shouldInertia = Boolean(drag.value?.moved)
drag.value = null drag.value = null
if (canvasRef.value) canvasRef.value.style.touchAction = ''
cancelInertia() cancelInertia()
inertiaRafId.value = window.requestAnimationFrame(applyInertia) if (shouldInertia && !timelineDrag) inertiaRafId.value = window.requestAnimationFrame(applyInertia)
else scheduleDraw()
} }
const handlePointerCancel = () => { const handlePointerCancel = () => {
drag.value = null drag.value = null
if (canvasRef.value) canvasRef.value.style.touchAction = ''
} }
const handlePointerLeave = () => { const handlePointerLeave = () => {
+8 -8
View File
@@ -1,29 +1,29 @@
<template> <template>
<div <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="[ :class="[
isDarkMode isDarkMode
? 'hover:bg-gray-800 hover:shadow-black/20' ? 'hover:bg-gray-800 hover:shadow-black/20'
: 'hover:shadow-gray-200/80' : 'hover:shadow-gray-200/80'
]" ]"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between gap-3">
<div> <div class="min-w-0">
<p class="text-sm" :class="[isDarkMode ? 'text-gray-400' : 'text-gray-600']"> <p class="truncate text-xs sm:text-sm" :class="[isDarkMode ? 'text-gray-300' : 'text-gray-600']">
{{ title }} {{ title }}
</p> </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 }} {{ value }}
</h3> </h3>
</div> </div>
<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" :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>
</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> <slot name="description"></slot>
</p> </p>
</div> </div>
+3 -1
View File
@@ -439,7 +439,9 @@ export function useAdminFiles() {
] ]
const detailPolicyActionOptions = computed<DetailPolicyActionOption[]>(() => const detailPolicyActionOptions = computed<DetailPolicyActionOption[]>(() =>
createPolicyActionOptions(Boolean(selectedFileDetail.value?.isPermanentFile)) createPolicyActionOptions(Boolean(selectedFileDetail.value?.isPermanentFile)).filter(
(option) => !option.disabled
)
) )
const batchPolicyActionOptions = computed<BatchPolicyActionOption[]>( const batchPolicyActionOptions = computed<BatchPolicyActionOption[]>(
+3
View File
@@ -55,6 +55,7 @@ export default {
admin: { admin: {
logout: 'Logout', logout: 'Logout',
dashboard: { dashboard: {
adminLabel: 'FileCodeBox Admin',
title: 'Dashboard', title: 'Dashboard',
totalFiles: 'Total Files', totalFiles: 'Total Files',
storageSpace: 'Storage Space', storageSpace: 'Storage Space',
@@ -536,6 +537,8 @@ export default {
statusInsight: 'Status Insight', statusInsight: 'Status Insight',
nextAction: 'Next Action', nextAction: 'Next Action',
lifecycle: 'Lifecycle', lifecycle: 'Lifecycle',
expandLifecycle: 'Show all lifecycle records ({count})',
collapseLifecycle: 'Collapse lifecycle records',
permanent: 'Permanent', permanent: 'Permanent',
createdAt: 'Created Time', createdAt: 'Created Time',
remainingDownloads: 'Remaining Downloads', remainingDownloads: 'Remaining Downloads',
+3
View File
@@ -55,6 +55,7 @@ export default {
admin: { admin: {
logout: '退出登录', logout: '退出登录',
dashboard: { dashboard: {
adminLabel: 'FileCodeBox 管理后台',
title: '仪表盘', title: '仪表盘',
totalFiles: '总文件数', totalFiles: '总文件数',
storageSpace: '存储空间', storageSpace: '存储空间',
@@ -533,6 +534,8 @@ export default {
statusInsight: '状态洞察', statusInsight: '状态洞察',
nextAction: '建议动作', nextAction: '建议动作',
lifecycle: '生命周期', lifecycle: '生命周期',
expandLifecycle: '展开全部生命周期记录(共 {count} 条)',
collapseLifecycle: '收起生命周期记录',
permanent: '永久有效', permanent: '永久有效',
createdAt: '创建时间', createdAt: '创建时间',
remainingDownloads: '剩余次数', remainingDownloads: '剩余次数',
+29 -12
View File
@@ -36,30 +36,47 @@ export const copyToClipboard = async (
} }
} }
const showManualCopyDialog = () => {
window.prompt('复制下面内容', text)
}
try { try {
// 优先使用 Clipboard API if (navigator.clipboard && window.isSecureContext) {
if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
showCopyMessage(successMsg, 'success') showCopyMessage(successMsg, 'success')
return true return true
} catch {
// Continue to the DOM fallback.
}
} }
// 后备方案:使用传统的复制方法
const textarea = document.createElement('textarea') const textarea = document.createElement('textarea')
textarea.value = text textarea.value = text
textarea.style.position = 'fixed' textarea.setAttribute('readonly', '')
textarea.style.opacity = '0' textarea.style.cssText =
'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;'
document.body.appendChild(textarea) document.body.appendChild(textarea)
textarea.focus()
textarea.select() textarea.select()
const success = document.execCommand('copy') textarea.setSelectionRange(0, textarea.value.length)
document.body.removeChild(textarea) let success = false
try {
success = document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
if (success) { if (success) {
showCopyMessage(successMsg, 'success') showCopyMessage(successMsg, 'success')
return true return true
} else {
throw new Error('execCommand copy failed')
} }
showManualCopyDialog()
showCopyMessage('已打开手动复制窗口', 'error')
return false
} catch (err) { } catch (err) {
console.error('复制失败:', err) console.error('复制失败:', err)
showManualCopyDialog()
showCopyMessage(errorMsg, 'error') showCopyMessage(errorMsg, 'error')
return false return false
} }
+37 -34
View File
@@ -1,8 +1,8 @@
<template> <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 class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div> <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]"> <h2 class="text-2xl font-bold" :class="[primaryTextClass]">
{{ t('admin.dashboard.title') }} {{ t('admin.dashboard.title') }}
</h2> </h2>
@@ -32,7 +32,7 @@
{{ errorMessage }} {{ errorMessage }}
</div> </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 <StatCard
:title="t('admin.dashboard.totalFiles')" :title="t('admin.dashboard.totalFiles')"
:value="dashboardData.totalFiles" :value="dashboardData.totalFiles"
@@ -78,12 +78,12 @@
</StatCard> </StatCard>
</div> </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 <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]" :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> <div>
<h3 class="text-lg font-semibold" :class="[primaryTextClass]"> <h3 class="text-lg font-semibold" :class="[primaryTextClass]">
{{ t('admin.dashboard.fileHealth') }} {{ t('admin.dashboard.fileHealth') }}
@@ -98,7 +98,7 @@
/> />
</div> </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 <MetricProgress
:label="t('admin.dashboard.activeFileRatio')" :label="t('admin.dashboard.activeFileRatio')"
:value="dashboardData.activeRatio" :value="dashboardData.activeRatio"
@@ -119,24 +119,24 @@
/> />
</div> </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 <button
v-for="action in healthActions" v-for="action in healthActions"
:key="action.key" :key="action.key"
type="button" 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)" :class="getHealthActionClass(action.tone)"
@click="openHealthQueue(action.health)" @click="openHealthQueue(action.health)"
> >
<span class="flex items-start justify-between gap-3"> <span class="flex items-start justify-between gap-3">
<span> <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 class="mt-1 block text-sm font-medium">{{ action.label }}</span>
</span> </span>
<component :is="getHealthActionIcon(action.tone)" class="h-5 w-5 shrink-0" /> <component :is="getHealthActionIcon(action.tone)" class="h-5 w-5 shrink-0" />
</span> </span>
<span class="mt-3 flex items-center justify-between gap-2 text-xs"> <span class="mt-2 flex items-center justify-between gap-2 text-xs sm:mt-3">
<span class="line-clamp-2">{{ action.description }}</span> <span class="hidden line-clamp-2 sm:block">{{ action.description }}</span>
<ArrowRightIcon <ArrowRightIcon
class="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5" class="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-0.5"
/> />
@@ -144,9 +144,9 @@
</button> </button>
</div> </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 <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]" :class="[subtlePanelClass, cardHoverClass]"
> >
<p class="text-sm" :class="[mutedTextClass]"> <p class="text-sm" :class="[mutedTextClass]">
@@ -163,7 +163,7 @@
</div> </div>
<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]" :class="[subtlePanelClass, cardHoverClass]"
> >
<p class="text-sm" :class="[mutedTextClass]"> <p class="text-sm" :class="[mutedTextClass]">
@@ -182,7 +182,7 @@
</section> </section>
<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]" :class="[panelClass, cardHoverClass]"
> >
<div class="mb-5"> <div class="mb-5">
@@ -230,18 +230,20 @@
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-2"> <div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-2">
<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]" :class="[panelClass, cardHoverClass]"
> >
<div class="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> <div class="mb-5 flex flex-col gap-4">
<div> <div class="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3> <div class="min-w-0">
<p class="text-sm" :class="[mutedTextClass]"> <h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3>
{{ analyticsSummaryText }} <p class="break-words text-sm leading-5" :class="[mutedTextClass]">
</p> {{ analyticsSummaryText }}
</div> </p>
<div class="w-full max-w-full space-y-3 lg:w-auto"> <p class="mt-1 text-xs sm:hidden" :class="[mutedTextClass]">轻触或滑动查看数据拖动底部日期轴平移图表</p>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center"> </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 <NativeDateSelect
v-model="analyticsStart" v-model="analyticsStart"
:field-class="fieldClass" :field-class="fieldClass"
@@ -249,7 +251,7 @@
@change="fetchAnalyticsData" @change="fetchAnalyticsData"
/> />
<span <span
class="self-center text-sm font-semibold" class="shrink-0 text-sm font-semibold"
:class="[isDarkMode ? 'text-[#aabdc2]' : 'text-[#789096]']" :class="[isDarkMode ? 'text-[#aabdc2]' : 'text-[#789096]']"
> >
- -
@@ -261,7 +263,7 @@
@change="fetchAnalyticsData" @change="fetchAnalyticsData"
/> />
</div> </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 <BaseButton
variant="outline" variant="outline"
size="sm" size="sm"
@@ -288,6 +290,7 @@
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
</div>
</div> </div>
<div <div
v-if="analyticsError" v-if="analyticsError"
@@ -309,7 +312,7 @@
</section> </section>
<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]" :class="[panelClass, cardHoverClass]"
> >
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
@@ -631,12 +634,12 @@ const MetricProgress = defineComponent({
'div', 'div',
{ {
class: 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('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: 'text-gray-500 dark:text-gray-400' }, props.label), h('span', { class: 'line-clamp-1 text-gray-500 dark:text-gray-400' }, props.label),
h('span', { class: 'font-medium text-gray-900 dark:text-white' }, `${props.value}%`) h('span', { class: 'font-medium tabular-nums text-gray-900 dark:text-white' }, `${props.value}%`)
]), ]),
h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [ h('div', { class: 'h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700' }, [
h('div', { h('div', {
@@ -644,7 +647,7 @@ const MetricProgress = defineComponent({
style: { width: `${props.value}%` } 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)
] ]
) )
} }
+263 -89
View File
@@ -1,5 +1,5 @@
<template> <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 class="mb-6 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h2 class="text-2xl font-bold" :class="[primaryTextClass]"> <h2 class="text-2xl font-bold" :class="[primaryTextClass]">
@@ -11,22 +11,22 @@
</div> </div>
</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 <div
v-for="card in summaryCards" v-for="card in summaryCards"
:key="card.label" :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]" :class="[panelClass, cardHoverClass]"
> >
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div> <div>
<p class="text-sm" :class="[mutedTextClass]">{{ card.label }}</p> <p class="text-xs sm:text-sm" :class="[mutedTextClass]">{{ card.label }}</p>
<p class="mt-2 text-2xl font-semibold" :class="[primaryTextClass]"> <p class="mt-1.5 truncate text-xl font-semibold tabular-nums sm:mt-2 sm:text-2xl" :class="[primaryTextClass]">
{{ card.value }} {{ card.value }}
</p> </p>
</div> </div>
<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]" :class="[card.iconClass]"
> >
<component :is="card.icon" class="h-5 w-5" /> <component :is="card.icon" class="h-5 w-5" />
@@ -36,17 +36,17 @@
</div> </div>
<section <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]" :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> <div>
<h3 class="text-base font-semibold" :class="[primaryTextClass]">条件筛选</h3> <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 ? '已应用筛选条件' : '默认收起,需要时展开' }} {{ hasActiveFilters ? '已应用筛选条件' : '默认收起,需要时展开' }}
</p> </p>
</div> </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 <BaseButton
v-if="hasActiveFilters" v-if="hasActiveFilters"
variant="outline" variant="outline"
@@ -67,7 +67,8 @@
</div> </div>
</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"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center">
<label class="min-w-0 flex-1"> <label class="min-w-0 flex-1">
<span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span> <span class="sr-only">{{ t('fileManage.searchPlaceholder') }}</span>
@@ -253,8 +254,109 @@
</BaseButton> </BaseButton>
</div> </div>
</div> </div>
</Transition>
</section> </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"> <DataTable :title="t('fileManage.allFiles')" :headers="fileTableHeaders">
<template #actions> <template #actions>
<BaseButton variant="secondary" :loading="isLoading" @click="refreshFileList"> <BaseButton variant="secondary" :loading="isLoading" @click="refreshFileList">
@@ -269,10 +371,10 @@
v-if="tableData.length > 0" v-if="tableData.length > 0"
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between" 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 <input
type="checkbox" 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" :checked="isAllCurrentPageSelected"
:disabled="isBatchActionRunning" :disabled="isBatchActionRunning"
:indeterminate="isCurrentPagePartiallySelected" :indeterminate="isCurrentPagePartiallySelected"
@@ -287,7 +389,7 @@
</span> </span>
</label> </label>
<div class="flex flex-wrap items-center gap-2"> <div v-if="hasSelectedFiles" class="flex flex-wrap items-center gap-2">
<BaseButton <BaseButton
v-if="hasSelectedFiles" v-if="hasSelectedFiles"
variant="outline" variant="outline"
@@ -407,7 +509,7 @@
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<input <input
type="checkbox" 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)" :checked="selectedFileIds.has(file.id)"
:disabled="isBatchActionRunning" :disabled="isBatchActionRunning"
:aria-label="t('fileManage.selectFile', { name: file.displayName })" :aria-label="t('fileManage.selectFile', { name: file.displayName })"
@@ -582,6 +684,7 @@
/> />
</template> </template>
</DataTable> </DataTable>
</div>
<section <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" 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> </button>
</div> </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 <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="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)" :class="getInsightPanelClass(selectedFileDetail.statusInsightSeverity)"
@@ -1046,25 +1085,43 @@
</section> </section>
<section v-if="selectedFileDetail.detailTimeline.length > 0" class="space-y-3"> <section v-if="selectedFileDetail.detailTimeline.length > 0" class="space-y-3">
<h4 class="text-sm font-semibold" :class="[primaryTextClass]"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{{ t('fileManage.lifecycle') }} <h4 class="text-sm font-semibold" :class="[primaryTextClass]">
</h4> {{ 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 <div
class="rounded-[30px] border px-4 py-4 shadow-md" class="rounded-[30px] border px-4 py-4 shadow-md"
:class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-white']" :class="[isDarkMode ? 'border-gray-700 bg-gray-700/30' : 'border-gray-200 bg-white']"
> >
<div <TransitionGroup name="fcb-expand-120" tag="div">
v-for="(item, index) in selectedFileDetail.detailTimeline" <div
:key="`${item.key}-${index}`" v-for="(item, index) in visibleDetailTimeline"
class="grid grid-cols-[auto_minmax(0,1fr)] gap-3" :key="`${item.key}-${index}`"
> class="fcb-expand-content grid grid-cols-[auto_minmax(0,1fr)] gap-3"
<div class="flex flex-col items-center"> >
<div class="flex flex-col items-center">
<span <span
class="mt-1 h-3 w-3 rounded-full" class="mt-1 h-3 w-3 rounded-full"
:class="getTimelineDotClass(item.severity)" :class="getTimelineDotClass(item.severity)"
/> />
<span <span
v-if="index < selectedFileDetail.detailTimeline.length - 1" v-if="index < visibleDetailTimeline.length - 1"
class="my-1 w-px flex-1" class="my-1 w-px flex-1"
:class="[isDarkMode ? 'bg-gray-600' : 'bg-gray-200']" :class="[isDarkMode ? 'bg-gray-600' : 'bg-gray-200']"
/> />
@@ -1087,7 +1144,72 @@
{{ item.displayDescription }} {{ item.displayDescription }}
</p> </p>
</div> </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> </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> </div>
</section> </section>
@@ -1572,6 +1694,11 @@ const {
} = useAdminFiles() } = useAdminFiles()
const isFilterPanelOpen = ref(false) 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 filterBodyVisible = computed(() => isFilterPanelOpen.value || hasActiveFilters.value)
const isHistoryLoading = ref(false) const isHistoryLoading = ref(false)
const historyRows = ref<AnalyticsFileRow[]>([]) const historyRows = ref<AnalyticsFileRow[]>([])
@@ -2051,6 +2178,13 @@ const getTimelineDotClass = (severity: AdminFileInsightSeverity) => {
return classes[severity] return classes[severity]
} }
watch(
() => selectedFileDetail.value?.id,
() => {
isLifecycleExpanded.value = false
}
)
watch( watch(
() => route.query.health, () => route.query.health,
async () => { async () => {
@@ -2067,3 +2201,43 @@ onMounted(() => {
void loadHistoryFiles() void loadHistoryFiles()
}) })
</script> </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>