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 counters = ref(defaultData)
const id = inject('id') const id = inject('id')
const startAt = inject('startAt') const time = inject('time')
const endAt = inject('endAt') const filters = inject('filters')
async function getLinkCounters() { async function getLinkCounters() {
counters.value = defaultData counters.value = defaultData
const { data } = await useAPI('/api/stats/counters', { const { data } = await useAPI('/api/stats/counters', {
query: { query: {
id: id.value, id: id.value,
startAt: startAt.value, startAt: time.value.startAt,
endAt: endAt.value, endAt: time.value.endAt,
...filters.value,
}, },
}) })
counters.value = data?.[0] counters.value = data?.[0]
} }
const stopWatchTime = watch([startAt, endAt], getLinkCounters) const stopWatchQueryChange = watch([time, filters], getLinkCounters, {
deep: true,
})
onMounted(async () => { onMounted(async () => {
getLinkCounters() getLinkCounters()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopWatchTime() stopWatchQueryChange()
}) })
</script> </script>
<template> <template>
<div class="grid gap-4 sm:gap-3 lg:gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:gap-3 lg:gap-4 sm:grid-cols-3">
<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"> <CardTitle class="text-sm font-medium">
Visits Visits
</CardTitle> </CardTitle>
@ -51,7 +53,7 @@ onBeforeUnmount(() => {
</CardContent> </CardContent>
</Card> </Card>
<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"> <CardTitle class="text-sm font-medium">
Visitors Visitors
</CardTitle> </CardTitle>
@ -62,7 +64,7 @@ onBeforeUnmount(() => {
</CardContent> </CardContent>
</Card> </Card>
<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"> <CardTitle class="text-sm font-medium">
Referers Referers
</CardTitle> </CardTitle>

View file

@ -3,8 +3,7 @@ import { now, startOfMonth, startOfWeek } from '@internationalized/date'
const emit = defineEmits(['update:dateRange']) const emit = defineEmits(['update:dateRange'])
const startAt = inject('startAt') const time = inject('time')
const endAt = inject('endAt')
const dateRange = ref('last-7d') const dateRange = ref('last-7d')
const openCustomDateRange = ref(false) const openCustomDateRange = ref(false)
@ -69,7 +68,7 @@ watch(dateRange, (newValue) => {
<SelectTrigger> <SelectTrigger>
<SelectValue v-if="dateRange" /> <SelectValue v-if="dateRange" />
<div v-else> <div v-else>
{{ shortDate(startAt) }} - {{ shortDate(endAt) }} {{ shortDate(time.startAt) }} - {{ shortDate(time.endAt) }}
</div> </div>
</SelectTrigger> </SelectTrigger>
<SelectContent> <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 time = ref({
const endAt = ref(date2unix(now())) startAt: date2unix(now().subtract({ days: 7 })),
endAt: date2unix(now()),
})
provide('startAt', startAt) provide('time', time)
provide('endAt', endAt)
function changeDate(time) { function changeDate(dateRange) {
console.log('changeDate', dateRange)
// console.log('dashboard date', new Date(time[0] * 1000), new Date(time[1] * 1000)) // console.log('dashboard date', new Date(time[0] * 1000), new Date(time[1] * 1000))
startAt.value = time[0] time.value.startAt = dateRange[0]
endAt.value = time[1] 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> </script>
<template> <template>
<main class="space-y-6"> <main class="space-y-6">
<DashboardNav> <div class="flex flex-col gap-6 sm:gap-2 sm:flex-row sm:justify-between">
<template <DashboardNav class="flex-1">
v-if="link" <template
#left v-if="link"
> #left
<h3 class="text-xl font-bold leading-10"> >
{{ link.slug }}'s Stats <h3 class="text-xl font-bold leading-10">
</h3> {{ link.slug }}'s Stats
</template> </h3>
<DashboardDatePicker @update:date-range="changeDate" /> </template>
</DashboardNav> <DashboardDatePicker @update:date-range="changeDate" />
</DashboardNav>
<DashboardFilter v-if="!link" @change="changeFilter" />
</div>
<DashboardCounters /> <DashboardCounters />
<DashboardViews /> <DashboardViews />
<DashboardMetrics /> <DashboardMetrics />

View file

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

View file

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

View file

@ -13,8 +13,8 @@ const props = defineProps({
}) })
const id = inject('id') const id = inject('id')
const startAt = inject('startAt') const time = inject('time')
const endAt = inject('endAt') const filters = inject('filters')
const total = ref(0) const total = ref(0)
const metrics = ref([]) const metrics = ref([])
@ -28,8 +28,9 @@ async function getLinkMetrics() {
query: { query: {
type: props.type, type: props.type,
id: id.value, id: id.value,
startAt: startAt.value, startAt: time.value.startAt,
endAt: endAt.value, endAt: time.value.endAt,
...filters.value,
}, },
}) })
if (Array.isArray(data)) { 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(() => { onMounted(() => {
getLinkMetrics() getLinkMetrics()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopWatchTime() stopWatchQueryChange()
}) })
</script> </script>
@ -73,7 +76,7 @@ onBeforeUnmount(() => {
variant="link" variant="link"
> >
<Maximize <Maximize
class="w-4 h-4 mr-2" class="mr-2 w-4 h-4"
/> DETAILS /> DETAILS
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@ -91,7 +94,7 @@ onBeforeUnmount(() => {
</CardFooter> </CardFooter>
</template> </template>
<template v-else> <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 <Skeleton
class="w-32 h-4 rounded-full" 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), limit: z.coerce.number().int().safe().default(listQueryLimit),
}) })
export const FilterSchema = QuerySchema.omit({ id: true, startAt: true, endAt: true, limit: true }).extend({ // export const FilterSchema = QuerySchema.omit({ id: true, startAt: true, endAt: true, limit: true }).extend({
index1: z.string().optional(), // 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 { SelectStatement } from 'sql-bricks'
import type { z } from 'zod' import type { z } from 'zod'
export type Query = z.infer<typeof QuerySchema> const { in: $in, and, eq } = SqlBricks
export type Filter = z.infer<typeof FilterSchema>
export function query2filter(query: Query): Filter { export type Query = z.infer<typeof QuerySchema>
const filter: Filter = {}
export function query2filter(query: Query) {
const filter = []
if (query.id) if (query.id)
filter.index1 = query.id filter.push(eq('index1', query.id))
Object.keys(logsMap).forEach((key) => { Object.keys(logsMap).forEach((key) => {
// @ts-expect-error todo // @ts-expect-error todo
if (query[key]) { if (query[key]) {
// @ts-expect-error todo // @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 { export function appendTimeFilter(sql: SelectStatement, query: Query): unknown {

View file

@ -1,2 +1,7 @@
// @ts-expect-error todo import type SqlBricks from 'sql-bricks'
export { default as SqlBricks } from 'mysql-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 }