🔀 合并前端测试验证改动
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[]>(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '剩余次数',
|
||||||
|
|||||||
+25
-8
@@ -36,30 +36,47 @@ export const copyToClipboard = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showManualCopyDialog = () => {
|
||||||
|
window.prompt('复制下面内容', text)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
try {
|
try {
|
||||||
// 优先使用 Clipboard API
|
|
||||||
if (document.hasFocus() && navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
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)
|
||||||
|
let success = false
|
||||||
|
try {
|
||||||
|
success = document.execCommand('copy')
|
||||||
|
} finally {
|
||||||
document.body.removeChild(textarea)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div class="min-w-0">
|
||||||
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3>
|
<h3 class="text-lg font-semibold" :class="[primaryTextClass]">下载/上传趋势</h3>
|
||||||
<p class="text-sm" :class="[mutedTextClass]">
|
<p class="break-words text-sm leading-5" :class="[mutedTextClass]">
|
||||||
{{ analyticsSummaryText }}
|
{{ analyticsSummaryText }}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-1 text-xs sm:hidden" :class="[mutedTextClass]">轻触或滑动查看数据;拖动底部日期轴平移图表</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full max-w-full space-y-3 lg:w-auto">
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 xl:flex-nowrap">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<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"
|
||||||
@@ -289,6 +291,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="analyticsError"
|
v-if="analyticsError"
|
||||||
class="mb-4 rounded-[30px] border px-4 py-3 text-sm"
|
class="mb-4 rounded-[30px] border px-4 py-3 text-sm"
|
||||||
@@ -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)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,17 +1085,35 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="selectedFileDetail.detailTimeline.length > 0" class="space-y-3">
|
<section v-if="selectedFileDetail.detailTimeline.length > 0" class="space-y-3">
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
<h4 class="text-sm font-semibold" :class="[primaryTextClass]">
|
||||||
{{ t('fileManage.lifecycle') }}
|
{{ t('fileManage.lifecycle') }}
|
||||||
</h4>
|
</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']"
|
||||||
>
|
>
|
||||||
|
<TransitionGroup name="fcb-expand-120" tag="div">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in selectedFileDetail.detailTimeline"
|
v-for="(item, index) in visibleDetailTimeline"
|
||||||
:key="`${item.key}-${index}`"
|
:key="`${item.key}-${index}`"
|
||||||
class="grid grid-cols-[auto_minmax(0,1fr)] gap-3"
|
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
|
||||||
@@ -1064,7 +1121,7 @@
|
|||||||
: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']"
|
||||||
/>
|
/>
|
||||||
@@ -1088,6 +1145,71 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user