mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(analytics): implement dashboard analytics components and API integration
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -12,4 +12,9 @@ export class DashboardController {
|
||||
async getOverview() {
|
||||
return await this.dashboardService.getOverview()
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
async getAnalytics() {
|
||||
return await this.dashboardService.getAnalytics()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { requireTenantContext } from '../tenant/tenant.context'
|
||||
import type { DashboardOverview, DashboardRecentActivityItem } from './dashboard.types'
|
||||
import type {
|
||||
DashboardAnalytics,
|
||||
DashboardOverview,
|
||||
DashboardRecentActivityItem,
|
||||
DashboardStorageProviderUsage,
|
||||
} from './dashboard.types'
|
||||
|
||||
const ZERO_STATS = {
|
||||
totalPhotos: 0,
|
||||
@@ -93,4 +98,140 @@ export class DashboardService {
|
||||
recentActivity,
|
||||
}
|
||||
}
|
||||
|
||||
async getAnalytics(): Promise<DashboardAnalytics> {
|
||||
const tenant = requireTenantContext()
|
||||
const db = this.dbAccessor.get()
|
||||
|
||||
const uploadTrendResult = await db.execute<{ month: string | null; uploads: number | null }>(sql`
|
||||
with months as (
|
||||
select generate_series(
|
||||
date_trunc('month', now()) - interval '11 months',
|
||||
date_trunc('month', now()),
|
||||
interval '1 month'
|
||||
) as month_start
|
||||
)
|
||||
select to_char(months.month_start, 'YYYY-MM') as month,
|
||||
coalesce(upload_counts.uploads, 0)::int as uploads
|
||||
from months
|
||||
left join (
|
||||
select date_trunc('month', ${photoAssets.createdAt}) as month_start,
|
||||
count(*)::int as uploads
|
||||
from ${photoAssets}
|
||||
where ${photoAssets.tenantId} = ${tenant.tenant.id}
|
||||
and ${photoAssets.createdAt} >= date_trunc('month', now()) - interval '11 months'
|
||||
group by month_start
|
||||
) as upload_counts on upload_counts.month_start = months.month_start
|
||||
order by months.month_start
|
||||
`)
|
||||
|
||||
const uploadTrends = uploadTrendResult.rows.map((row) => ({
|
||||
month: row.month ?? '',
|
||||
uploads: Number(row.uploads ?? 0),
|
||||
}))
|
||||
|
||||
const [storageAggregate] = await db
|
||||
.select({
|
||||
totalBytes: sql<number>`coalesce(sum(${photoAssets.size}), 0)`,
|
||||
totalPhotos: sql<number>`count(*)`,
|
||||
currentMonthBytes: sql<number>`coalesce(sum(${photoAssets.size}) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now())), 0)`,
|
||||
previousMonthBytes: sql<number>`coalesce(sum(${photoAssets.size}) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now() - interval '1 month')), 0)`,
|
||||
})
|
||||
.from(photoAssets)
|
||||
.where(eq(photoAssets.tenantId, tenant.tenant.id))
|
||||
|
||||
const providerUsageRaw = await db
|
||||
.select({
|
||||
provider: photoAssets.storageProvider,
|
||||
bytes: sql<number>`coalesce(sum(${photoAssets.size}), 0)`,
|
||||
photoCount: sql<number>`count(*)`,
|
||||
})
|
||||
.from(photoAssets)
|
||||
.where(eq(photoAssets.tenantId, tenant.tenant.id))
|
||||
.groupBy(photoAssets.storageProvider)
|
||||
|
||||
const providers: DashboardStorageProviderUsage[] = providerUsageRaw
|
||||
.map((entry) => ({
|
||||
provider: (entry.provider ?? 'unknown').trim() || 'unknown',
|
||||
bytes: Number(entry.bytes ?? 0),
|
||||
photoCount: Number(entry.photoCount ?? 0),
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
|
||||
const popularTagsResult = await db.execute<{ tag: string | null; count: number | null }>(sql`
|
||||
select tag, count(*)::int as count
|
||||
from (
|
||||
select nullif(trim(jsonb_array_elements_text(${photoAssets.manifest}->'data'->'tags')), '') as tag
|
||||
from ${photoAssets}
|
||||
where ${photoAssets.tenantId} = ${tenant.tenant.id}
|
||||
) as tag_items
|
||||
where tag is not null
|
||||
group by tag
|
||||
order by count desc
|
||||
limit 8
|
||||
`)
|
||||
|
||||
const popularTags = popularTagsResult.rows
|
||||
.map((row) => {
|
||||
const tag = row.tag?.trim()
|
||||
if (!tag) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
tag,
|
||||
count: Number(row.count ?? 0),
|
||||
}
|
||||
})
|
||||
.filter((value): value is { tag: string; count: number } => value !== null)
|
||||
|
||||
const topDevicesRaw = await db.execute<{ make: string | null; model: string | null; count: number | null }>(sql`
|
||||
select
|
||||
nullif(trim(${photoAssets.manifest}::jsonb #>> '{data,exif,Make}'), '') as make,
|
||||
nullif(trim(${photoAssets.manifest}::jsonb #>> '{data,exif,Model}'), '') as model,
|
||||
count(*)::int as count
|
||||
from ${photoAssets}
|
||||
where ${photoAssets.tenantId} = ${tenant.tenant.id}
|
||||
group by make, model
|
||||
order by count desc
|
||||
limit 20
|
||||
`)
|
||||
|
||||
const deviceCounter = new Map<string, number>()
|
||||
for (const row of topDevicesRaw.rows) {
|
||||
const make = row.make ?? ''
|
||||
const model = row.model ?? ''
|
||||
let name = model || make
|
||||
|
||||
if (make && model) {
|
||||
const lowerMake = make.toLowerCase()
|
||||
const lowerModel = model.toLowerCase()
|
||||
name = lowerModel.includes(lowerMake) ? model : `${make} ${model}`
|
||||
}
|
||||
|
||||
name = name.trim()
|
||||
if (!name) {
|
||||
continue
|
||||
}
|
||||
const existing = deviceCounter.get(name) ?? 0
|
||||
deviceCounter.set(name, existing + Number(row.count ?? 0))
|
||||
}
|
||||
|
||||
const topDevices = Array.from(deviceCounter.entries())
|
||||
.map(([device, count]) => ({ device, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8)
|
||||
|
||||
return {
|
||||
uploadTrends,
|
||||
storageUsage: {
|
||||
totalBytes: Number(storageAggregate?.totalBytes ?? 0),
|
||||
totalPhotos: Number(storageAggregate?.totalPhotos ?? 0),
|
||||
currentMonthBytes: Number(storageAggregate?.currentMonthBytes ?? 0),
|
||||
previousMonthBytes: Number(storageAggregate?.previousMonthBytes ?? 0),
|
||||
providers,
|
||||
},
|
||||
popularTags,
|
||||
topDevices,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,39 @@ export interface DashboardOverview {
|
||||
stats: DashboardStats
|
||||
recentActivity: DashboardRecentActivityItem[]
|
||||
}
|
||||
|
||||
export interface DashboardUploadTrendPoint {
|
||||
month: string
|
||||
uploads: number
|
||||
}
|
||||
|
||||
export interface DashboardStorageProviderUsage {
|
||||
provider: string
|
||||
bytes: number
|
||||
photoCount: number
|
||||
}
|
||||
|
||||
export interface DashboardStorageUsage {
|
||||
totalBytes: number
|
||||
totalPhotos: number
|
||||
currentMonthBytes: number
|
||||
previousMonthBytes: number
|
||||
providers: DashboardStorageProviderUsage[]
|
||||
}
|
||||
|
||||
export interface DashboardTagStat {
|
||||
tag: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DashboardDeviceStat {
|
||||
device: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DashboardAnalytics {
|
||||
uploadTrends: DashboardUploadTrendPoint[]
|
||||
storageUsage: DashboardStorageUsage
|
||||
popularTags: DashboardTagStat[]
|
||||
topDevices: DashboardDeviceStat[]
|
||||
}
|
||||
|
||||
14
be/apps/dashboard/src/modules/analytics/api.ts
Normal file
14
be/apps/dashboard/src/modules/analytics/api.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
import { camelCaseKeys } from '~/lib/case'
|
||||
|
||||
import type { DashboardAnalyticsResponse } from './types'
|
||||
|
||||
const DASHBOARD_ANALYTICS_ENDPOINT = '/dashboard/analytics'
|
||||
|
||||
export async function fetchDashboardAnalytics() {
|
||||
return camelCaseKeys<DashboardAnalyticsResponse>(
|
||||
await coreApi<DashboardAnalyticsResponse>(DASHBOARD_ANALYTICS_ENDPOINT, {
|
||||
method: 'GET',
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import { clsxm, Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
|
||||
import { useDashboardAnalyticsQuery } from '../hooks'
|
||||
import type { StorageProviderUsage, UploadTrendPoint } from '../types'
|
||||
|
||||
const plainNumberFormatter = new Intl.NumberFormat('zh-CN')
|
||||
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
const percentFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
const monthLabelFormatter = new Intl.DateTimeFormat('zh-CN', { month: 'short' })
|
||||
const fullMonthFormatter = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' })
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
|
||||
let value = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
const fixed = value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)
|
||||
return `${fixed} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function buildMonthDate(month: string) {
|
||||
const [yearStr, monthStr] = month.split('-')
|
||||
const year = Number.parseInt(yearStr, 10)
|
||||
const monthIndex = Number.parseInt(monthStr, 10) - 1
|
||||
if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || monthIndex < 0 || monthIndex > 11) {
|
||||
return null
|
||||
}
|
||||
return new Date(Date.UTC(year, monthIndex, 1))
|
||||
}
|
||||
|
||||
function formatMonthLabel(month: string) {
|
||||
const date = buildMonthDate(month)
|
||||
return date ? monthLabelFormatter.format(date) : month
|
||||
}
|
||||
|
||||
function formatFullMonth(month: string) {
|
||||
const date = buildMonthDate(month)
|
||||
return date ? fullMonthFormatter.format(date) : month
|
||||
}
|
||||
|
||||
function TrendSkeleton() {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex h-44 items-end gap-2">
|
||||
{Array.from({ length: 12 }, (_, index) => (
|
||||
<div key={index} className="flex flex-1 flex-col items-center gap-1">
|
||||
<div className="bg-fill/15 relative flex h-40 w-full items-end overflow-hidden rounded-md">
|
||||
<div className="bg-fill/25 mb-0 w-full rounded-md" style={{ height: `${20 + (index % 3) * 10}%` }} />
|
||||
</div>
|
||||
<div className="bg-fill/20 h-3 w-6 rounded-full" />
|
||||
<div className="bg-fill/15 h-3 w-8 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProvidersSkeleton() {
|
||||
return (
|
||||
<div className="mt-5 space-y-3">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="bg-fill/20 h-3 w-36 rounded-full" />
|
||||
<div className="bg-fill/15 h-2.5 w-full rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RankedListSkeleton() {
|
||||
return (
|
||||
<div className="mt-4 space-y-2">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="bg-fill/20 h-3 w-32 rounded-full" />
|
||||
<div className="bg-fill/15 h-3 w-12 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadTrendsChart({ data }: { data: UploadTrendPoint[] }) {
|
||||
const maxUploads = data.reduce((max, point) => Math.max(max, point.uploads), 0)
|
||||
|
||||
return (
|
||||
<div className="mt-6 overflow-x-auto pb-2">
|
||||
<div className="flex h-44 min-w-[480px] items-end gap-3">
|
||||
{data.map((point, index) => {
|
||||
const basePercent = maxUploads === 0 ? 0 : (point.uploads / maxUploads) * 100
|
||||
const heightPercent = Math.max(basePercent, point.uploads > 0 ? 8 : 0)
|
||||
const monthLabel = formatMonthLabel(point.month)
|
||||
const fullLabel = formatFullMonth(point.month)
|
||||
|
||||
return (
|
||||
<m.div
|
||||
key={point.month}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
|
||||
className="group flex min-w-[32px] flex-1 flex-col items-center gap-1"
|
||||
title={`${fullLabel} · ${plainNumberFormatter.format(point.uploads)} 张`}
|
||||
>
|
||||
<div className="bg-fill/15 relative flex h-40 w-full items-end overflow-hidden rounded-md">
|
||||
<div
|
||||
className="bg-accent/70 group-hover:bg-accent absolute bottom-0 left-0 right-0 rounded-md transition-colors duration-200"
|
||||
style={{ height: `${heightPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-tertiary text-[11px] leading-none">{monthLabel}</span>
|
||||
<span className="text-text-secondary text-[11px] leading-none">
|
||||
{plainNumberFormatter.format(point.uploads)}
|
||||
</span>
|
||||
</m.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProvidersList({ providers, totalBytes }: { providers: StorageProviderUsage[]; totalBytes: number }) {
|
||||
if (providers.length === 0) {
|
||||
return <div className="text-text-tertiary mt-5 text-sm">暂无存储使用数据。</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-3">
|
||||
{providers.map((provider, index) => {
|
||||
const ratio = totalBytes > 0 ? provider.bytes / totalBytes : 0
|
||||
const percent = Math.round(ratio * 100)
|
||||
return (
|
||||
<m.div
|
||||
key={provider.provider || index}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
|
||||
className="space-y-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text capitalize">{provider.provider}</span>
|
||||
<span className="text-text-secondary">
|
||||
{formatBytes(provider.bytes)}
|
||||
<span className="text-text-tertiary ml-2">
|
||||
{percent}% · {provider.photoCount} 张
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-fill/15 h-2.5 w-full rounded-full">
|
||||
<div
|
||||
className="bg-accent/70 h-full rounded-full"
|
||||
style={{ width: `${Math.min(Math.max(percent, 2), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RankedList({ items, emptyText }: { items: Array<{ label: string; value: number }>; emptyText: string }) {
|
||||
if (items.length === 0) {
|
||||
return <div className="text-text-tertiary mt-4 text-sm">{emptyText}</div>
|
||||
}
|
||||
|
||||
const maxValue = items.reduce((max, item) => Math.max(max, item.value), 0)
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-2.5">
|
||||
{items.map((item, index) => {
|
||||
const ratio = maxValue > 0 ? item.value / maxValue : 0
|
||||
return (
|
||||
<m.div
|
||||
key={item.label}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: index * 0.03 }}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<div className="text-text-tertiary w-6 text-right text-[11px]">#{index + 1}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text truncate">{item.label}</span>
|
||||
<span className="text-text-secondary text-[13px]">{plainNumberFormatter.format(item.value)}</span>
|
||||
</div>
|
||||
<div className="bg-fill/15 mt-1.5 h-2 rounded-full">
|
||||
<div
|
||||
className="bg-accent/60 h-full rounded-full"
|
||||
style={{ width: `${Math.min(Math.max(ratio * 100, 4), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionPanel({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<LinearBorderPanel className={clsxm('bg-background-tertiary p-5', className)}>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-text text-sm font-semibold">{title}</h2>
|
||||
<p className="text-text-tertiary text-[13px] leading-relaxed">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardAnalytics() {
|
||||
const { data, isLoading, isError } = useDashboardAnalyticsQuery()
|
||||
|
||||
const uploadTrendStats = useMemo(() => {
|
||||
if (!data?.uploadTrends?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalUploads = data.uploadTrends.reduce((sum, point) => sum + point.uploads, 0)
|
||||
const bestMonth = data.uploadTrends.reduce(
|
||||
(best, current) => (current.uploads > best.uploads ? current : best),
|
||||
data.uploadTrends[0],
|
||||
)
|
||||
const currentMonth = data.uploadTrends.at(-1)!
|
||||
const previousMonth = data.uploadTrends.length > 1 ? (data.uploadTrends.at(-2) ?? null) : null
|
||||
|
||||
const delta = previousMonth ? currentMonth.uploads - previousMonth.uploads : currentMonth.uploads
|
||||
const growth = previousMonth && previousMonth.uploads > 0 ? delta / previousMonth.uploads : null
|
||||
|
||||
return {
|
||||
totalUploads,
|
||||
bestMonth,
|
||||
currentMonth,
|
||||
previousMonth,
|
||||
delta,
|
||||
growth,
|
||||
}
|
||||
}, [data?.uploadTrends])
|
||||
|
||||
const storageUsage = data?.storageUsage
|
||||
|
||||
const popularTagItems = data?.popularTags?.map((entry) => ({
|
||||
label: entry.tag,
|
||||
value: entry.count,
|
||||
}))
|
||||
|
||||
const deviceItems = data?.topDevices?.map((entry) => ({
|
||||
label: entry.device,
|
||||
value: entry.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SectionPanel title="Upload Trends" description="近 12 个月的上传趋势">
|
||||
{isLoading ? (
|
||||
<TrendSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-6 text-sm">无法加载上传趋势,请稍后再试。</div>
|
||||
) : data?.uploadTrends?.length ? (
|
||||
<>
|
||||
{uploadTrendStats ? (
|
||||
<div className="border-fill/20 mt-5 grid gap-3 rounded-lg border bg-fill/5 p-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">累计上传</span>
|
||||
<span className="text-text font-semibold">
|
||||
{compactNumberFormatter.format(uploadTrendStats.totalUploads)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">表现最佳</span>
|
||||
<span className="text-text font-semibold">
|
||||
{formatFullMonth(uploadTrendStats.bestMonth.month)}
|
||||
<span className="text-text-tertiary ml-2 text-[13px]">
|
||||
{plainNumberFormatter.format(uploadTrendStats.bestMonth.uploads)} 张
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">本月上传</span>
|
||||
<span className="text-text font-semibold">
|
||||
{plainNumberFormatter.format(uploadTrendStats.currentMonth.uploads)} 张
|
||||
{uploadTrendStats.growth !== null ? (
|
||||
<span
|
||||
className={clsxm(
|
||||
'ml-2 text-[13px]',
|
||||
uploadTrendStats.growth >= 0 ? 'text-emerald-300' : 'text-red-300',
|
||||
)}
|
||||
>
|
||||
{uploadTrendStats.growth === 0
|
||||
? '与上月持平'
|
||||
: `${uploadTrendStats.growth >= 0 ? '+' : ''}${percentFormatter.format(uploadTrendStats.growth)}`}
|
||||
</span>
|
||||
) : uploadTrendStats.previousMonth ? (
|
||||
<span className="text-text-tertiary ml-2 text-[13px]">
|
||||
{uploadTrendStats.delta > 0 ? '首次出现上传记录' : '与上月持平'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<UploadTrendsChart data={data.uploadTrends} />
|
||||
</>
|
||||
) : (
|
||||
<div className="text-text-tertiary mt-6 text-sm">暂无上传记录。</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="Storage Usage" description="按存储提供方统计的容量占比">
|
||||
{isLoading ? (
|
||||
<ProvidersSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-5 text-sm">无法加载存储数据,请稍后再试。</div>
|
||||
) : storageUsage ? (
|
||||
(() => {
|
||||
const monthDeltaBytes = storageUsage.currentMonthBytes - storageUsage.previousMonthBytes
|
||||
let monthDeltaDescription = '与上月持平'
|
||||
|
||||
if (storageUsage.previousMonthBytes > 0) {
|
||||
if (monthDeltaBytes !== 0) {
|
||||
const prefix = monthDeltaBytes > 0 ? '+' : '-'
|
||||
monthDeltaDescription = `${prefix}${formatBytes(Math.abs(monthDeltaBytes))} 对比上月`
|
||||
}
|
||||
} else if (storageUsage.currentMonthBytes > 0) {
|
||||
monthDeltaDescription = '首次记录'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-fill/20 mt-5 grid gap-3 rounded-lg border bg-fill/5 p-4 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">总占用</span>
|
||||
<span className="text-text font-semibold">{formatBytes(storageUsage.totalBytes)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">照片数量</span>
|
||||
<span className="text-text font-semibold">
|
||||
{plainNumberFormatter.format(storageUsage.totalPhotos)} 张
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-secondary">本月新增</span>
|
||||
<span className="text-text font-semibold">
|
||||
{formatBytes(storageUsage.currentMonthBytes)}
|
||||
<span className="text-text-tertiary ml-2 text-[13px]">{monthDeltaDescription}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProvidersList providers={storageUsage.providers} totalBytes={storageUsage.totalBytes} />
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<div className="text-text-tertiary mt-5 text-sm">暂无存储使用数据。</div>
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="Popular Tags" description="最近上传中最常使用的标签">
|
||||
{isLoading ? (
|
||||
<RankedListSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-4 text-sm">无法加载标签数据,请稍后再试。</div>
|
||||
) : (
|
||||
<RankedList items={popularTagItems ?? []} emptyText="暂无标签统计数据。" />
|
||||
)}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="Top Devices" description="根据 EXIF 信息统计的热门拍摄设备">
|
||||
{isLoading ? (
|
||||
<RankedListSkeleton />
|
||||
) : isError ? (
|
||||
<div className="text-red mt-4 text-sm">无法加载设备数据,请稍后再试。</div>
|
||||
) : (
|
||||
<RankedList items={deviceItems ?? []} emptyText="暂无设备统计数据。" />
|
||||
)}
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
)
|
||||
}
|
||||
14
be/apps/dashboard/src/modules/analytics/hooks.ts
Normal file
14
be/apps/dashboard/src/modules/analytics/hooks.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { fetchDashboardAnalytics } from './api'
|
||||
import type { DashboardAnalyticsResponse } from './types'
|
||||
|
||||
export const DASHBOARD_ANALYTICS_QUERY_KEY = ['dashboard', 'analytics'] as const
|
||||
|
||||
export function useDashboardAnalyticsQuery() {
|
||||
return useQuery<DashboardAnalyticsResponse>({
|
||||
queryKey: DASHBOARD_ANALYTICS_QUERY_KEY,
|
||||
queryFn: fetchDashboardAnalytics,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
}
|
||||
35
be/apps/dashboard/src/modules/analytics/types.ts
Normal file
35
be/apps/dashboard/src/modules/analytics/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export interface UploadTrendPoint {
|
||||
month: string
|
||||
uploads: number
|
||||
}
|
||||
|
||||
export interface StorageProviderUsage {
|
||||
provider: string
|
||||
bytes: number
|
||||
photoCount: number
|
||||
}
|
||||
|
||||
export interface StorageUsageSummary {
|
||||
totalBytes: number
|
||||
totalPhotos: number
|
||||
currentMonthBytes: number
|
||||
previousMonthBytes: number
|
||||
providers: StorageProviderUsage[]
|
||||
}
|
||||
|
||||
export interface PopularTagStat {
|
||||
tag: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DeviceStat {
|
||||
device: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface DashboardAnalyticsResponse {
|
||||
uploadTrends: UploadTrendPoint[]
|
||||
storageUsage: StorageUsageSummary
|
||||
popularTags: PopularTagStat[]
|
||||
topDevices: DeviceStat[]
|
||||
}
|
||||
@@ -1,83 +1,3 @@
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
import { DashboardAnalytics } from '~/modules/analytics/components/AnalyticsPage'
|
||||
|
||||
export function Component() {
|
||||
return (
|
||||
<MainPageLayout title="Analytics" description="Track your photo collection statistics and trends">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Upload Trends */}
|
||||
<div className="bg-background-tertiary relative p-5">
|
||||
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
|
||||
<h2 className="text-text mb-4 text-sm font-semibold">Upload Trends</h2>
|
||||
<div className="text-text-tertiary flex h-64 items-center justify-center text-[13px]">Chart placeholder</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Usage */}
|
||||
<div className="bg-background-tertiary relative p-5">
|
||||
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
|
||||
<h2 className="text-text mb-4 text-sm font-semibold">Storage Usage</h2>
|
||||
<div className="text-text-tertiary flex h-64 items-center justify-center text-[13px]">Chart placeholder</div>
|
||||
</div>
|
||||
|
||||
{/* Popular Tags */}
|
||||
<div className="bg-background-tertiary relative p-5">
|
||||
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
|
||||
<h2 className="text-text mb-4 text-sm font-semibold">Popular Tags</h2>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ tag: 'Nature', count: 234 },
|
||||
{ tag: 'Travel', count: 189 },
|
||||
{ tag: 'Portrait', count: 156 },
|
||||
{ tag: 'Architecture', count: 142 },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.tag}
|
||||
className="bg-fill/10 hover:bg-fill/20 flex items-center justify-between px-3 py-2 transition-colors"
|
||||
>
|
||||
<span className="text-text text-[13px]">{item.tag}</span>
|
||||
<span className="text-accent text-[13px] font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Stats */}
|
||||
<div className="bg-background-tertiary relative p-5">
|
||||
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute right-0 bottom-0 left-0 h-[0.5px] bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 bottom-0 left-0 w-[0.5px] bg-gradient-to-b from-transparent to-transparent" />
|
||||
|
||||
<h2 className="text-text mb-4 text-sm font-semibold">Top Devices</h2>
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ device: 'iPhone 15 Pro', count: 456 },
|
||||
{ device: 'Canon EOS R5', count: 342 },
|
||||
{ device: 'Sony A7 IV', count: 287 },
|
||||
{ device: 'Fujifilm X-T5', count: 149 },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.device}
|
||||
className="bg-fill/10 hover:bg-fill/20 flex items-center justify-between px-3 py-2 transition-colors"
|
||||
>
|
||||
<span className="text-text text-[13px]">{item.device}</span>
|
||||
<span className="text-accent text-[13px] font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
)
|
||||
}
|
||||
export const Component = () => <DashboardAnalytics />
|
||||
|
||||
@@ -97,7 +97,7 @@ export function Component() {
|
||||
</Button>
|
||||
|
||||
{/* Additional Links */}
|
||||
<div className="mt-6 flex items-center justify-between text-sm">
|
||||
{/* <div className="mt-6 flex items-center justify-between text-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -116,7 +116,7 @@ export function Component() {
|
||||
>
|
||||
Create account
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</form>
|
||||
</LinearBorderContainer>
|
||||
|
||||
Reference in New Issue
Block a user