Fix OAuth token import and table filters

This commit is contained in:
fawney19
2026-05-01 02:14:49 +08:00
parent 9570e5c2c1
commit 4fc7cecf30
54 changed files with 3160 additions and 727 deletions

View File

@@ -51,6 +51,8 @@ export { default as TableHead } from './table-head.vue'
export { default as TableHeader } from './table-header.vue'
export { default as TableRow } from './table-row.vue'
export { default as TableCard } from './table-card.vue'
export { default as SortableTableHead } from './sortable-table-head.vue'
export { default as TableFilterMenu } from './table-filter-menu.vue'
// Avatar 头像系列
export { default as Avatar } from './avatar.vue'

View File

@@ -1,13 +1,28 @@
<script setup lang="ts">
import { PopoverTrigger } from 'radix-vue'
import { useAttrs } from 'vue'
defineProps<{
defineOptions({
inheritAttrs: false,
})
withDefaults(defineProps<{
asChild?: boolean
}>()
as?: string
}>(), {
asChild: false,
as: 'button',
})
const attrs = useAttrs()
</script>
<template>
<PopoverTrigger :as-child="asChild">
<PopoverTrigger
v-bind="attrs"
:as-child="asChild"
:as="as"
>
<slot />
</PopoverTrigger>
</template>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
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 TableHead from './table-head.vue'
type SortDirection = 'asc' | 'desc'
const props = withDefaults(defineProps<{
class?: string
columnKey?: string
sortable?: boolean
activeKey?: string | null
direction?: SortDirection
defaultDirection?: SortDirection
align?: 'left' | 'center' | 'right'
title?: string
filterActive?: boolean
filterTitle?: string
filterContentClass?: string
}>(), {
columnKey: undefined,
sortable: true,
activeKey: null,
direction: 'asc',
defaultDirection: 'asc',
align: 'left',
title: undefined,
filterActive: false,
filterTitle: '筛选',
filterContentClass: undefined,
})
const emit = defineEmits<{
sort: [payload: { key: string, direction: SortDirection }]
}>()
defineOptions({
inheritAttrs: false,
})
const attrs = useAttrs()
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 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 text-xs font-semibold 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 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"
: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 || '排序'"
@click="handleSort"
>
<slot />
<component
:is="icon"
:class="iconClass"
/>
</button>
<span
v-else
:class="labelClass"
>
<slot />
</span>
</div>
</TableHead>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { Check } from 'lucide-vue-next'
export interface TableFilterMenuOption {
value: string
label: string
disabled?: boolean
}
defineProps<{
modelValue: string
options: TableFilterMenuOption[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
select: [value: string]
}>()
function selectOption(value: string) {
emit('update:modelValue', value)
emit('select', value)
}
</script>
<template>
<div>
<button
v-for="option in options"
:key="option.value"
type="button"
class="relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-left text-sm text-foreground outline-none transition-colors hover:bg-accent focus:bg-accent disabled:pointer-events-none disabled:opacity-50"
:disabled="option.disabled"
@click="selectOption(option.value)"
>
<Check
class="absolute left-2 h-4 w-4"
:class="modelValue === option.value ? 'opacity-100' : 'opacity-0'"
/>
<span>{{ option.label }}</span>
</button>
</div>
</template>