mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
235 lines
6.8 KiB
Vue
235 lines
6.8 KiB
Vue
<script setup lang="ts">
|
|
import type { ClassValue } from 'clsx'
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useAttrs, useSlots } from 'vue'
|
|
import { ArrowDown, ArrowUp, ArrowUpDown, ListFilter } from 'lucide-vue-next'
|
|
|
|
import { cn } from '@/lib/utils'
|
|
import { useI18n } from '@/i18n'
|
|
import TableHead from './table-head.vue'
|
|
|
|
type SortDirection = 'asc' | 'desc'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
class?: ClassValue
|
|
columnKey?: string
|
|
sortable?: boolean
|
|
activeKey?: string | null
|
|
direction?: SortDirection
|
|
defaultDirection?: SortDirection
|
|
align?: 'left' | 'center' | 'right'
|
|
title?: string
|
|
filterActive?: boolean
|
|
filterTitle?: string
|
|
filterContentClass?: string
|
|
}>(), {
|
|
class: undefined,
|
|
columnKey: undefined,
|
|
sortable: true,
|
|
activeKey: null,
|
|
direction: 'asc',
|
|
defaultDirection: 'asc',
|
|
align: 'left',
|
|
title: undefined,
|
|
filterActive: false,
|
|
filterTitle: undefined,
|
|
filterContentClass: undefined,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
sort: [payload: { key: string, direction: SortDirection }]
|
|
}>()
|
|
|
|
defineOptions({
|
|
inheritAttrs: false,
|
|
})
|
|
|
|
const attrs = useAttrs()
|
|
const { t } = useI18n()
|
|
const slots = useSlots()
|
|
const rootRef = ref<HTMLElement | null>(null)
|
|
const filterTriggerRef = ref<HTMLButtonElement | null>(null)
|
|
const filterPanelRef = ref<HTMLElement | null>(null)
|
|
const filterOpen = ref(false)
|
|
const filterPanelStyle = ref<Record<string, string>>({})
|
|
const canSort = computed(() => props.sortable && Boolean(props.columnKey))
|
|
const hasFilter = computed(() => Boolean(slots.filter))
|
|
const isActive = computed(() => props.activeKey === props.columnKey)
|
|
const nextDirection = computed<SortDirection>(() => {
|
|
if (!isActive.value) return props.defaultDirection
|
|
return props.direction === 'asc' ? 'desc' : 'asc'
|
|
})
|
|
const icon = computed(() => {
|
|
if (!isActive.value) return ArrowUpDown
|
|
return props.direction === 'asc' ? ArrowUp : ArrowDown
|
|
})
|
|
const ariaSort = computed(() => {
|
|
if (!canSort.value) return undefined
|
|
if (!isActive.value) return 'none'
|
|
return props.direction === 'asc' ? 'ascending' : 'descending'
|
|
})
|
|
const wrapperClass = computed(() => cn(
|
|
'relative flex min-w-0 w-full items-center gap-1.5',
|
|
props.align === 'center' && 'justify-center',
|
|
props.align === 'right' && 'justify-end',
|
|
))
|
|
const labelClass = computed(() => cn(
|
|
'inline-flex min-w-0 items-center gap-1.5 whitespace-normal break-words text-left text-xs font-semibold leading-4 text-muted-foreground',
|
|
props.align === 'center' && 'justify-center',
|
|
props.align === 'right' && 'justify-end',
|
|
))
|
|
const buttonClass = computed(() => cn(
|
|
labelClass.value,
|
|
'rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
))
|
|
const iconClass = computed(() => cn(
|
|
'h-3.5 w-3.5 shrink-0 transition-colors',
|
|
isActive.value ? 'text-foreground' : 'text-muted-foreground/60',
|
|
))
|
|
const filterButtonClass = computed(() => cn(
|
|
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
props.filterActive
|
|
? 'bg-primary/10 text-primary hover:bg-primary/15'
|
|
: 'text-muted-foreground/60 hover:bg-muted/50 hover:text-foreground',
|
|
))
|
|
const filterPanelClass = computed(() => cn(
|
|
'fixed z-[1000] w-64 max-w-[calc(100vw-1rem)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none',
|
|
props.filterContentClass,
|
|
))
|
|
|
|
function handleSort() {
|
|
if (!canSort.value || !props.columnKey) return
|
|
emit('sort', {
|
|
key: props.columnKey,
|
|
direction: nextDirection.value,
|
|
})
|
|
}
|
|
|
|
function updateFilterPosition() {
|
|
if (!filterOpen.value || !filterTriggerRef.value) return
|
|
|
|
const rect = filterTriggerRef.value.getBoundingClientRect()
|
|
const panelWidth = filterPanelRef.value?.offsetWidth ?? 256
|
|
const viewportPadding = 8
|
|
let left = rect.left
|
|
|
|
if (props.align === 'center') {
|
|
left = rect.left + rect.width / 2 - panelWidth / 2
|
|
} else if (props.align === 'right') {
|
|
left = rect.right - panelWidth
|
|
}
|
|
|
|
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - panelWidth - viewportPadding))
|
|
|
|
filterPanelStyle.value = {
|
|
left: `${Math.round(left)}px`,
|
|
top: `${Math.round(rect.bottom + 8)}px`,
|
|
}
|
|
}
|
|
|
|
async function openFilter() {
|
|
filterOpen.value = true
|
|
await nextTick()
|
|
updateFilterPosition()
|
|
}
|
|
|
|
function toggleFilter() {
|
|
if (filterOpen.value) {
|
|
closeFilter()
|
|
} else {
|
|
void openFilter()
|
|
}
|
|
}
|
|
|
|
function closeFilter() {
|
|
filterOpen.value = false
|
|
}
|
|
|
|
function handleDocumentPointerDown(event: PointerEvent) {
|
|
if (!filterOpen.value) return
|
|
const target = event.target
|
|
if (target instanceof Node && rootRef.value?.contains(target)) return
|
|
if (target instanceof Node && filterPanelRef.value?.contains(target)) return
|
|
closeFilter()
|
|
}
|
|
|
|
function handleDocumentKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'Escape') {
|
|
closeFilter()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
|
document.addEventListener('keydown', handleDocumentKeydown)
|
|
document.addEventListener('scroll', updateFilterPosition, true)
|
|
window.addEventListener('resize', updateFilterPosition)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
|
document.removeEventListener('keydown', handleDocumentKeydown)
|
|
document.removeEventListener('scroll', updateFilterPosition, true)
|
|
window.removeEventListener('resize', updateFilterPosition)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<TableHead
|
|
v-bind="attrs"
|
|
:class="cn(props.class, (canSort || hasFilter) && 'select-none')"
|
|
:aria-sort="ariaSort"
|
|
>
|
|
<div
|
|
ref="rootRef"
|
|
:class="wrapperClass"
|
|
>
|
|
<template v-if="hasFilter">
|
|
<button
|
|
ref="filterTriggerRef"
|
|
type="button"
|
|
:class="filterButtonClass"
|
|
:title="filterTitle ?? t('common.filter')"
|
|
:aria-label="filterTitle ?? t('common.filter')"
|
|
:aria-pressed="filterActive"
|
|
@click.stop="toggleFilter"
|
|
>
|
|
<ListFilter class="h-3.5 w-3.5" />
|
|
</button>
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="filterOpen"
|
|
ref="filterPanelRef"
|
|
:class="filterPanelClass"
|
|
:style="filterPanelStyle"
|
|
@click.stop
|
|
>
|
|
<slot
|
|
name="filter"
|
|
:close="closeFilter"
|
|
/>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
<button
|
|
v-if="canSort"
|
|
type="button"
|
|
:class="buttonClass"
|
|
:title="title || t('common.sort')"
|
|
@click="handleSort"
|
|
>
|
|
<slot />
|
|
<component
|
|
:is="icon"
|
|
:class="iconClass"
|
|
/>
|
|
</button>
|
|
<span
|
|
v-else
|
|
:class="labelClass"
|
|
>
|
|
<slot />
|
|
</span>
|
|
</div>
|
|
</TableHead>
|
|
</template>
|