mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +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() {
|
async getOverview() {
|
||||||
return await this.dashboardService.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 { DbAccessor } from '../../database/database.provider'
|
||||||
import { requireTenantContext } from '../tenant/tenant.context'
|
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 = {
|
const ZERO_STATS = {
|
||||||
totalPhotos: 0,
|
totalPhotos: 0,
|
||||||
@@ -93,4 +98,140 @@ export class DashboardService {
|
|||||||
recentActivity,
|
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
|
stats: DashboardStats
|
||||||
recentActivity: DashboardRecentActivityItem[]
|
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() {
|
export const Component = () => <DashboardAnalytics />
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function Component() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Additional Links */}
|
{/* 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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -116,7 +116,7 @@ export function Component() {
|
|||||||
>
|
>
|
||||||
Create account
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</LinearBorderContainer>
|
</LinearBorderContainer>
|
||||||
|
|||||||
Reference in New Issue
Block a user