feat: consolidate time filtering and add advanced filters

Refactors time-based filtering by combining startAt/endAt into a single time object
Adds support for additional filter types in stats API queries
Implements deep watching for both time and filter changes

Improves code organization and maintainability while enabling more flexible data filtering
This commit is contained in:
ccbikai 2025-03-02 13:40:43 +08:00
parent ab4fc66da9
commit 2a6b875bdb
11 changed files with 160 additions and 66 deletions

View file

@ -11,36 +11,38 @@ const defaultData = Object.freeze({
const counters = ref(defaultData)
const id = inject('id')
const startAt = inject('startAt')
const endAt = inject('endAt')
const time = inject('time')
const filters = inject('filters')
async function getLinkCounters() {
counters.value = defaultData
const { data } = await useAPI('/api/stats/counters', {
query: {
id: id.value,
startAt: startAt.value,
endAt: endAt.value,
startAt: time.value.startAt,
endAt: time.value.endAt,
...filters.value,
},
})
counters.value = data?.[0]
}
const stopWatchTime = watch([startAt, endAt], getLinkCounters)
const stopWatchQueryChange = watch([time, filters], getLinkCounters, {
deep: true,
})
onMounted(async () => {
getLinkCounters()
})
onBeforeUnmount(() => {
stopWatchTime()
stopWatchQueryChange()
})
</script>
<template>
<div class="grid gap-4 sm:gap-3 lg:gap-4 sm:grid-cols-3">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0">
<CardTitle class="text-sm font-medium">
Visits
</CardTitle>
@ -51,7 +53,7 @@ onBeforeUnmount(() => {
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0">
<CardTitle class="text-sm font-medium">
Visitors
</CardTitle>
@ -62,7 +64,7 @@ onBeforeUnmount(() => {
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<CardHeader class="flex flex-row justify-between items-center pb-2 space-y-0">
<CardTitle class="text-sm font-medium">
Referers
</CardTitle>

View file

@ -3,8 +3,7 @@ import { now, startOfMonth, startOfWeek } from '@internationalized/date'
const emit = defineEmits(['update:dateRange'])
const startAt = inject('startAt')
const endAt = inject('endAt')
const time = inject('time')
const dateRange = ref('last-7d')
const openCustomDateRange = ref(false)
@ -69,7 +68,7 @@ watch(dateRange, (newValue) => {
<SelectTrigger>
<SelectValue v-if="dateRange" />
<div v-else>
{{ shortDate(startAt) }} - {{ shortDate(endAt) }}
{{ shortDate(time.startAt) }} - {{ shortDate(time.endAt) }}
</div>
</SelectTrigger>
<SelectContent>

View file

@ -0,0 +1,63 @@
<script setup>
import { Check, ChevronsUpDown } from 'lucide-vue-next'
const emit = defineEmits(['change'])
const links = ref([])
const isOpen = ref(false)
const selectedLinks = ref([])
async function getLinks() {
links.value = await useAPI('/api/link/search')
}
onMounted(() => {
getLinks()
})
watch(selectedLinks, (value) => {
emit('change', 'slug', value.join(','))
})
</script>
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="isOpen"
class="flex justify-between w-full sm:w-48"
>
<div class="flex-1 font-normal text-left truncate" :class="selectedLinks.length ? 'text-foreground' : 'text-muted-foreground'">
{{ selectedLinks.length ? selectedLinks.join(', ') : 'Filter Links...' }}
</div>
<ChevronsUpDown class="ml-2 w-4 h-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent class="p-0 w-full sm:w-48">
<Command v-model="selectedLinks" multiple>
<CommandInput placeholder="Filter Links..." />
<CommandEmpty>No link found.</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="link in links"
:key="link.slug"
:value="link.slug"
@select="isOpen = false"
>
<Check
:class="cn(
'mr-2 h-4 w-4',
selectedLinks.includes(link.slug) ? 'opacity-100' : 'opacity-0',
)"
/>
{{ link.slug }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>

View file

@ -8,32 +8,46 @@ defineProps({
},
})
const startAt = ref(date2unix(now().subtract({ days: 7 })))
const endAt = ref(date2unix(now()))
const time = ref({
startAt: date2unix(now().subtract({ days: 7 })),
endAt: date2unix(now()),
})
provide('startAt', startAt)
provide('endAt', endAt)
provide('time', time)
function changeDate(time) {
function changeDate(dateRange) {
console.log('changeDate', dateRange)
// console.log('dashboard date', new Date(time[0] * 1000), new Date(time[1] * 1000))
startAt.value = time[0]
endAt.value = time[1]
time.value.startAt = dateRange[0]
time.value.endAt = dateRange[1]
}
const filters = ref({})
provide('filters', filters)
function changeFilter(type, value) {
console.log('changeFilter', type, value)
filters.value[type] = value
}
</script>
<template>
<main class="space-y-6">
<DashboardNav>
<template
v-if="link"
#left
>
<h3 class="text-xl font-bold leading-10">
{{ link.slug }}'s Stats
</h3>
</template>
<DashboardDatePicker @update:date-range="changeDate" />
</DashboardNav>
<div class="flex flex-col gap-6 sm:gap-2 sm:flex-row sm:justify-between">
<DashboardNav class="flex-1">
<template
v-if="link"
#left
>
<h3 class="text-xl font-bold leading-10">
{{ link.slug }}'s Stats
</h3>
</template>
<DashboardDatePicker @update:date-range="changeDate" />
</DashboardNav>
<DashboardFilter v-if="!link" @change="changeFilter" />
</div>
<DashboardCounters />
<DashboardViews />
<DashboardMetrics />

View file

@ -6,8 +6,8 @@ const views = ref([])
const chart = computed(() => views.value.length > 1 ? AreaChart : BarChart)
const id = inject('id')
const startAt = inject('startAt')
const endAt = inject('endAt')
const time = inject('time')
const filters = inject('filters')
const OneDay = 24 * 60 * 60 // 1 day in seconds
function getUnit(startAt, endAt) {
@ -22,10 +22,11 @@ async function getLinkViews() {
const { data } = await useAPI('/api/stats/views', {
query: {
id: id.value,
unit: getUnit(startAt.value, endAt.value),
unit: getUnit(time.value.startAt, time.value.endAt),
clientTimezone: getTimeZone(),
startAt: startAt.value,
endAt: endAt.value,
startAt: time.value.startAt,
endAt: time.value.endAt,
...filters.value,
},
})
views.value = (data || []).map((item) => {
@ -35,19 +36,21 @@ async function getLinkViews() {
})
}
const stopWatchTime = watch([startAt, endAt], getLinkViews)
const stopWatchQueryChange = watch([time, filters], getLinkViews, {
deep: true,
})
onMounted(async () => {
getLinkViews()
})
onBeforeUnmount(() => {
stopWatchTime()
stopWatchQueryChange()
})
function formatTime(tick) {
if (Number.isInteger(tick) && views.value[tick]) {
if (getUnit(startAt.value, endAt.value) === 'hour')
if (getUnit(time.value.startAt, time.value.endAt) === 'hour')
return views.value[tick].time.split(' ')[1] || ''
return views.value[tick].time

View file

@ -3,8 +3,8 @@ import { ChartTooltip } from '@/components/ui/chart'
import { VisSingleContainer, VisTopoJSONMap, VisTopoJSONMapSelectors } from '@unovis/vue'
const id = inject('id')
const startAt = inject('startAt')
const endAt = inject('endAt')
const time = inject('time')
const filters = inject('filters')
const worldMapTopoJSON = ref({})
const areaData = ref([])
@ -20,8 +20,9 @@ async function getMapData() {
query: {
type: 'country',
id: id.value,
startAt: startAt.value,
endAt: endAt.value,
startAt: time.value.startAt,
endAt: time.value.endAt,
...filters.value,
},
})
if (Array.isArray(data)) {
@ -32,7 +33,9 @@ async function getMapData() {
}
}
const stopWatchTime = watch([startAt, endAt], getMapData)
const stopWatchQueryChange = watch([time, filters], getMapData, {
deep: true,
})
onMounted(() => {
getWorldMapJSON()
@ -40,7 +43,7 @@ onMounted(() => {
})
onBeforeUnmount(() => {
stopWatchTime()
stopWatchQueryChange()
})
const valueFormatter = v => v

View file

@ -13,8 +13,8 @@ const props = defineProps({
})
const id = inject('id')
const startAt = inject('startAt')
const endAt = inject('endAt')
const time = inject('time')
const filters = inject('filters')
const total = ref(0)
const metrics = ref([])
@ -28,8 +28,9 @@ async function getLinkMetrics() {
query: {
type: props.type,
id: id.value,
startAt: startAt.value,
endAt: endAt.value,
startAt: time.value.startAt,
endAt: time.value.endAt,
...filters.value,
},
})
if (Array.isArray(data)) {
@ -44,14 +45,16 @@ async function getLinkMetrics() {
}
}
const stopWatchTime = watch([startAt, endAt], getLinkMetrics)
const stopWatchQueryChange = watch([time, filters], getLinkMetrics, {
deep: true,
})
onMounted(() => {
getLinkMetrics()
})
onBeforeUnmount(() => {
stopWatchTime()
stopWatchQueryChange()
})
</script>
@ -73,7 +76,7 @@ onBeforeUnmount(() => {
variant="link"
>
<Maximize
class="w-4 h-4 mr-2"
class="mr-2 w-4 h-4"
/> DETAILS
</Button>
</DialogTrigger>
@ -91,7 +94,7 @@ onBeforeUnmount(() => {
</CardFooter>
</template>
<template v-else>
<div class="flex items-center justify-between h-12 px-4">
<div class="flex justify-between items-center px-4 h-12">
<Skeleton
class="w-32 h-4 rounded-full"
/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

View file

@ -22,6 +22,6 @@ export const QuerySchema = z.object({
limit: z.coerce.number().int().safe().default(listQueryLimit),
})
export const FilterSchema = QuerySchema.omit({ id: true, startAt: true, endAt: true, limit: true }).extend({
index1: z.string().optional(),
})
// export const FilterSchema = QuerySchema.omit({ id: true, startAt: true, endAt: true, limit: true }).extend({
// index1: z.string().optional(),
// })

View file

@ -1,23 +1,25 @@
import type { FilterSchema, QuerySchema } from '@/schemas/query'
import type { QuerySchema } from '@/schemas/query'
import type { SelectStatement } from 'sql-bricks'
import type { z } from 'zod'
export type Query = z.infer<typeof QuerySchema>
export type Filter = z.infer<typeof FilterSchema>
const { in: $in, and, eq } = SqlBricks
export function query2filter(query: Query): Filter {
const filter: Filter = {}
export type Query = z.infer<typeof QuerySchema>
export function query2filter(query: Query) {
const filter = []
if (query.id)
filter.index1 = query.id
filter.push(eq('index1', query.id))
Object.keys(logsMap).forEach((key) => {
// @ts-expect-error todo
if (query[key]) {
// @ts-expect-error todo
filter[logsMap[key]] = query[key]
filter.push($in(logsMap[key], query[key]))
}
})
return filter
console.log('query2filter', query, filter)
return filter.length ? and(...filter) : []
}
export function appendTimeFilter(sql: SelectStatement, query: Query): unknown {

View file

@ -1,2 +1,7 @@
// @ts-expect-error todo
export { default as SqlBricks } from 'mysql-bricks'
import type SqlBricks from 'sql-bricks'
// @ts-expect-error use SqlBricks as a type
import MySqlBricks from 'mysql-bricks'
const Bricks = MySqlBricks as unknown as typeof SqlBricks
export { Bricks as SqlBricks }