first commit

This commit is contained in:
root
2025-11-30 19:49:01 +08:00
commit 8dcb2a5898
46 changed files with 14428 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval, format } from 'date-fns'
import { VisXYContainer, VisLine, VisAxis, VisArea, VisCrosshair, VisTooltip } from '@unovis/vue'
import type { Period, Range } from '~/types'
const cardRef = useTemplateRef<HTMLElement | null>('cardRef')
const props = defineProps<{
period: Period
range: Range
}>()
type DataRecord = {
date: Date
amount: number
}
const { width } = useElementSize(cardRef)
const data = ref<DataRecord[]>([])
watch([() => props.period, () => props.range], () => {
const dates = ({
daily: eachDayOfInterval,
weekly: eachWeekOfInterval,
monthly: eachMonthOfInterval
} as Record<Period, typeof eachDayOfInterval>)[props.period](props.range)
const min = 1000
const max = 10000
data.value = dates.map(date => ({ date, amount: Math.floor(Math.random() * (max - min + 1)) + min }))
}, { immediate: true })
const x = (_: DataRecord, i: number) => i
const y = (d: DataRecord) => d.amount
const total = computed(() => data.value.reduce((acc: number, { amount }) => acc + amount, 0))
const formatNumber = new Intl.NumberFormat('en', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format
const formatDate = (date: Date): string => {
return ({
daily: format(date, 'd MMM'),
weekly: format(date, 'd MMM'),
monthly: format(date, 'MMM yyy')
})[props.period]
}
const xTicks = (i: number) => {
if (i === 0 || i === data.value.length - 1 || !data.value[i]) {
return ''
}
return formatDate(data.value[i].date)
}
const template = (d: DataRecord) => `${formatDate(d.date)}: ${formatNumber(d.amount)}`
</script>
<template>
<UCard ref="cardRef" :ui="{ root: 'overflow-visible', body: '!px-0 !pt-0 !pb-3' }">
<template #header>
<div>
<p class="text-xs text-muted uppercase mb-1.5">
Revenue
</p>
<p class="text-3xl text-highlighted font-semibold">
{{ formatNumber(total) }}
</p>
</div>
</template>
<VisXYContainer
:data="data"
:padding="{ top: 40 }"
class="h-96"
:width="width"
>
<VisLine
:x="x"
:y="y"
color="var(--ui-primary)"
/>
<VisArea
:x="x"
:y="y"
color="var(--ui-primary)"
:opacity="0.1"
/>
<VisAxis
type="x"
:x="x"
:tick-format="xTicks"
/>
<VisCrosshair
color="var(--ui-primary)"
:template="template"
/>
<VisTooltip />
</VisXYContainer>
</UCard>
</template>
<style scoped>
.unovis-xy-container {
--vis-crosshair-line-stroke-color: var(--ui-primary);
--vis-crosshair-circle-stroke-color: var(--ui-bg);
--vis-axis-grid-color: var(--ui-border);
--vis-axis-tick-color: var(--ui-border);
--vis-axis-tick-label-color: var(--ui-text-dimmed);
--vis-tooltip-background-color: var(--ui-bg);
--vis-tooltip-border-color: var(--ui-border);
--vis-tooltip-text-color: var(--ui-text-highlighted);
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<UCard class="shrink-0" :ui="{ body: '!px-0 !pt-0 !pb-3' }">
<template #header>
<div>
<p class="text-xs text-muted uppercase mb-1.5">
Revenue
</p>
<p class="text-3xl text-highlighted font-semibold">
---
</p>
</div>
</template>
<div class="h-96" />
</UCard>
</template>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { DateFormatter, getLocalTimeZone, CalendarDate, today } from '@internationalized/date'
import type { Range } from '~/types'
const df = new DateFormatter('en-US', {
dateStyle: 'medium'
})
const selected = defineModel<Range>({ required: true })
const ranges = [
{ label: 'Last 7 days', days: 7 },
{ label: 'Last 14 days', days: 14 },
{ label: 'Last 30 days', days: 30 },
{ label: 'Last 3 months', months: 3 },
{ label: 'Last 6 months', months: 6 },
{ label: 'Last year', years: 1 }
]
const toCalendarDate = (date: Date) => {
return new CalendarDate(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
)
}
const calendarRange = computed({
get: () => ({
start: selected.value.start ? toCalendarDate(selected.value.start) : undefined,
end: selected.value.end ? toCalendarDate(selected.value.end) : undefined
}),
set: (newValue: { start: CalendarDate | null, end: CalendarDate | null }) => {
selected.value = {
start: newValue.start ? newValue.start.toDate(getLocalTimeZone()) : new Date(),
end: newValue.end ? newValue.end.toDate(getLocalTimeZone()) : new Date()
}
}
})
const isRangeSelected = (range: { days?: number, months?: number, years?: number }) => {
if (!selected.value.start || !selected.value.end) return false
const currentDate = today(getLocalTimeZone())
let startDate = currentDate.copy()
if (range.days) {
startDate = startDate.subtract({ days: range.days })
} else if (range.months) {
startDate = startDate.subtract({ months: range.months })
} else if (range.years) {
startDate = startDate.subtract({ years: range.years })
}
const selectedStart = toCalendarDate(selected.value.start)
const selectedEnd = toCalendarDate(selected.value.end)
return selectedStart.compare(startDate) === 0 && selectedEnd.compare(currentDate) === 0
}
const selectRange = (range: { days?: number, months?: number, years?: number }) => {
const endDate = today(getLocalTimeZone())
let startDate = endDate.copy()
if (range.days) {
startDate = startDate.subtract({ days: range.days })
} else if (range.months) {
startDate = startDate.subtract({ months: range.months })
} else if (range.years) {
startDate = startDate.subtract({ years: range.years })
}
selected.value = {
start: startDate.toDate(getLocalTimeZone()),
end: endDate.toDate(getLocalTimeZone())
}
}
</script>
<template>
<UPopover :content="{ align: 'start' }" :modal="true">
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-calendar"
class="data-[state=open]:bg-elevated group"
>
<span class="truncate">
<template v-if="selected.start">
<template v-if="selected.end">
{{ df.format(selected.start) }} - {{ df.format(selected.end) }}
</template>
<template v-else>
{{ df.format(selected.start) }}
</template>
</template>
<template v-else>
Pick a date
</template>
</span>
<template #trailing>
<UIcon name="i-lucide-chevron-down" class="shrink-0 text-dimmed size-5 group-data-[state=open]:rotate-180 transition-transform duration-200" />
</template>
</UButton>
<template #content>
<div class="flex items-stretch sm:divide-x divide-default">
<div class="hidden sm:flex flex-col justify-center">
<UButton
v-for="(range, index) in ranges"
:key="index"
:label="range.label"
color="neutral"
variant="ghost"
class="rounded-none px-4"
:class="[isRangeSelected(range) ? 'bg-elevated' : 'hover:bg-elevated/50']"
truncate
@click="selectRange(range)"
/>
</div>
<UCalendar
v-model="calendarRange"
class="p-2"
:number-of-months="2"
range
/>
</div>
</template>
</UPopover>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { eachDayOfInterval } from 'date-fns'
import type { Period, Range } from '~/types'
const model = defineModel<Period>({ required: true })
const props = defineProps<{
range: Range
}>()
const days = computed(() => eachDayOfInterval(props.range))
const periods = computed<Period[]>(() => {
if (days.value.length <= 8) {
return [
'daily'
]
}
if (days.value.length <= 31) {
return [
'daily',
'weekly'
]
}
return [
'weekly',
'monthly'
]
})
// Ensure the model value is always a valid period
watch(periods, () => {
if (!periods.value.includes(model.value)) {
model.value = periods.value[0]!
}
})
</script>
<template>
<USelect
v-model="model"
:items="periods"
variant="ghost"
class="data-[state=open]:bg-elevated"
:ui="{ value: 'capitalize', itemLabel: 'capitalize', trailingIcon: 'group-data-[state=open]:rotate-180 transition-transform duration-200' }"
/>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import type { TableColumn } from '@nuxt/ui'
import type { Period, Range, Sale } from '~/types'
const props = defineProps<{
period: Period
range: Range
}>()
const UBadge = resolveComponent('UBadge')
const sampleEmails = [
'james.anderson@example.com',
'mia.white@example.com',
'william.brown@example.com',
'emma.davis@example.com',
'ethan.harris@example.com'
]
const { data } = await useAsyncData('sales', async () => {
const sales: Sale[] = []
const currentDate = new Date()
for (let i = 0; i < 5; i++) {
const hoursAgo = randomInt(0, 48)
const date = new Date(currentDate.getTime() - hoursAgo * 3600000)
sales.push({
id: (4600 - i).toString(),
date: date.toISOString(),
status: randomFrom(['paid', 'failed', 'refunded']),
email: randomFrom(sampleEmails),
amount: randomInt(100, 1000)
})
}
return sales.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
}, {
watch: [() => props.period, () => props.range],
default: () => []
})
const columns: TableColumn<Sale>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => `#${row.getValue('id')}`
},
{
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => {
return new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = {
paid: 'success' as const,
failed: 'error' as const,
refunded: 'neutral' as const
}[row.getValue('status') as string]
return h(UBadge, { class: 'capitalize', variant: 'subtle', color }, () =>
row.getValue('status')
)
}
},
{
accessorKey: 'email',
header: 'Email'
},
{
accessorKey: 'amount',
header: () => h('div', { class: 'text-right' }, 'Amount'),
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue('amount'))
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(amount)
return h('div', { class: 'text-right font-medium' }, formatted)
}
}
]
</script>
<template>
<UTable
:data="data"
:columns="columns"
class="shrink-0"
:ui="{
base: 'table-fixed border-separate border-spacing-0',
thead: '[&>tr]:bg-elevated/50 [&>tr]:after:content-none',
tbody: '[&>tr]:last:[&>td]:border-b-0',
th: 'first:rounded-l-lg last:rounded-r-lg border-y border-default first:border-l last:border-r',
td: 'border-b border-default'
}"
/>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import type { Period, Range, Stat } from '~/types'
const props = defineProps<{
period: Period
range: Range
}>()
function formatCurrency(value: number): string {
return value.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
}
const baseStats = [{
title: 'Customers',
icon: 'i-lucide-users',
minValue: 400,
maxValue: 1000,
minVariation: -15,
maxVariation: 25
}, {
title: 'Conversions',
icon: 'i-lucide-chart-pie',
minValue: 1000,
maxValue: 2000,
minVariation: -10,
maxVariation: 20
}, {
title: 'Revenue',
icon: 'i-lucide-circle-dollar-sign',
minValue: 200000,
maxValue: 500000,
minVariation: -20,
maxVariation: 30,
formatter: formatCurrency
}, {
title: 'Orders',
icon: 'i-lucide-shopping-cart',
minValue: 100,
maxValue: 300,
minVariation: -5,
maxVariation: 15
}]
const { data: stats } = await useAsyncData<Stat[]>('stats', async () => {
return baseStats.map((stat) => {
const value = randomInt(stat.minValue, stat.maxValue)
const variation = randomInt(stat.minVariation, stat.maxVariation)
return {
title: stat.title,
icon: stat.icon,
value: stat.formatter ? stat.formatter(value) : value,
variation
}
})
}, {
watch: [() => props.period, () => props.range],
default: () => []
})
</script>
<template>
<UPageGrid class="lg:grid-cols-4 gap-4 sm:gap-6 lg:gap-px">
<UPageCard
v-for="(stat, index) in stats"
:key="index"
:icon="stat.icon"
:title="stat.title"
to="/customers"
variant="subtle"
:ui="{
container: 'gap-y-1.5',
wrapper: 'items-start',
leading: 'p-2.5 rounded-full bg-primary/10 ring ring-inset ring-primary/25 flex-col',
title: 'font-normal text-muted text-xs uppercase'
}"
class="lg:rounded-none first:rounded-l-lg last:rounded-r-lg hover:z-1"
>
<div class="flex items-center gap-2">
<span class="text-2xl font-semibold text-highlighted">
{{ stat.value }}
</span>
<UBadge
:color="stat.variation > 0 ? 'success' : 'error'"
variant="subtle"
class="text-xs"
>
{{ stat.variation > 0 ? '+' : '' }}{{ stat.variation }}%
</UBadge>
</div>
</UPageCard>
</UPageGrid>
</template>