mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
feat(dashboard): implement dashboard overview component and related services
- Added DashboardModule, DashboardController, and DashboardService to manage dashboard functionality. - Created DashboardOverview component to display statistics and recent activity. - Introduced API hooks for fetching dashboard data and handling state. - Updated AGENTS.md to reflect new page structure for modular organization. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -163,6 +163,7 @@ class PhotoLoader {
|
||||
- **Hot Reloading**: SPA changes reflect immediately, SSR provides SEO preview
|
||||
- **Manifest Building**: `pnpm run build:manifest` processes photos and updates data
|
||||
- **Type Safety**: Shared types between builder, SPA, and SSR ensure consistency
|
||||
- **Page Structure**: Keep files under `pages/` as thin routing shells; move reusable UI/logic into `modules/<domain>/**` (e.g., dashboard overview lives in `modules/dashboard/components`).
|
||||
|
||||
### Code Quality Rules
|
||||
|
||||
|
||||
15
be/apps/core/src/modules/dashboard/dashboard.controller.ts
Normal file
15
be/apps/core/src/modules/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@afilmory/framework'
|
||||
import { Roles } from 'core/guards/roles.decorator'
|
||||
|
||||
import { DashboardService } from './dashboard.service'
|
||||
|
||||
@Controller('dashboard')
|
||||
@Roles('admin')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview() {
|
||||
return await this.dashboardService.getOverview()
|
||||
}
|
||||
}
|
||||
12
be/apps/core/src/modules/dashboard/dashboard.module.ts
Normal file
12
be/apps/core/src/modules/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { DatabaseModule } from '../../database/database.module'
|
||||
import { DashboardController } from './dashboard.controller'
|
||||
import { DashboardService } from './dashboard.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
96
be/apps/core/src/modules/dashboard/dashboard.service.ts
Normal file
96
be/apps/core/src/modules/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { photoAssets } from '@afilmory/db'
|
||||
import { desc, eq, sql } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { requireTenantContext } from '../tenant/tenant.context'
|
||||
import type { DashboardOverview, DashboardRecentActivityItem } from './dashboard.types'
|
||||
|
||||
const ZERO_STATS = {
|
||||
totalPhotos: 0,
|
||||
totalStorageBytes: 0,
|
||||
thisMonthUploads: 0,
|
||||
previousMonthUploads: 0,
|
||||
sync: {
|
||||
synced: 0,
|
||||
pending: 0,
|
||||
conflicts: 0,
|
||||
},
|
||||
} as const
|
||||
|
||||
@injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly dbAccessor: DbAccessor) {}
|
||||
|
||||
async getOverview(): Promise<DashboardOverview> {
|
||||
const tenant = requireTenantContext()
|
||||
const db = this.dbAccessor.get()
|
||||
|
||||
const [rawStats] = await db
|
||||
.select({
|
||||
totalPhotos: sql<number>`count(*)`,
|
||||
totalStorageBytes: sql<number>`coalesce(sum(${photoAssets.size}), 0)`,
|
||||
thisMonthUploads: sql<number>`count(*) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now()))`,
|
||||
previousMonthUploads: sql<number>`count(*) filter (where date_trunc('month', ${photoAssets.createdAt}) = date_trunc('month', now() - interval '1 month'))`,
|
||||
synced: sql<number>`count(*) filter (where ${photoAssets.syncStatus} = 'synced')`,
|
||||
pending: sql<number>`count(*) filter (where ${photoAssets.syncStatus} = 'pending')`,
|
||||
conflicts: sql<number>`count(*) filter (where ${photoAssets.syncStatus} = 'conflict')`,
|
||||
})
|
||||
.from(photoAssets)
|
||||
.where(eq(photoAssets.tenantId, tenant.tenant.id))
|
||||
|
||||
const stats = rawStats
|
||||
? {
|
||||
totalPhotos: Number(rawStats.totalPhotos ?? 0),
|
||||
totalStorageBytes: Number(rawStats.totalStorageBytes ?? 0),
|
||||
thisMonthUploads: Number(rawStats.thisMonthUploads ?? 0),
|
||||
previousMonthUploads: Number(rawStats.previousMonthUploads ?? 0),
|
||||
sync: {
|
||||
synced: Number(rawStats.synced ?? 0),
|
||||
pending: Number(rawStats.pending ?? 0),
|
||||
conflicts: Number(rawStats.conflicts ?? 0),
|
||||
},
|
||||
}
|
||||
: { ...ZERO_STATS }
|
||||
|
||||
const recentRecords = await db
|
||||
.select({
|
||||
id: photoAssets.id,
|
||||
photoId: photoAssets.photoId,
|
||||
createdAt: photoAssets.createdAt,
|
||||
storageProvider: photoAssets.storageProvider,
|
||||
manifest: photoAssets.manifest,
|
||||
size: photoAssets.size,
|
||||
syncStatus: photoAssets.syncStatus,
|
||||
})
|
||||
.from(photoAssets)
|
||||
.where(eq(photoAssets.tenantId, tenant.tenant.id))
|
||||
.orderBy(desc(photoAssets.createdAt))
|
||||
.limit(8)
|
||||
|
||||
const recentActivity: DashboardRecentActivityItem[] = recentRecords.map((record) => {
|
||||
const { manifest } = record
|
||||
const manifestData = manifest?.data
|
||||
const tags = manifestData?.tags?.slice(0, 5) ?? []
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
photoId: record.photoId,
|
||||
title: manifestData?.title?.trim() || manifestData?.description?.trim() || record.photoId,
|
||||
description: manifestData?.description?.trim() || null,
|
||||
createdAt: record.createdAt,
|
||||
takenAt: manifestData?.dateTaken ?? null,
|
||||
storageProvider: record.storageProvider,
|
||||
size: record.size ?? null,
|
||||
syncStatus: record.syncStatus,
|
||||
tags,
|
||||
previewUrl: manifestData?.thumbnailUrl ?? manifestData?.originalUrl ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
stats,
|
||||
recentActivity,
|
||||
}
|
||||
}
|
||||
}
|
||||
34
be/apps/core/src/modules/dashboard/dashboard.types.ts
Normal file
34
be/apps/core/src/modules/dashboard/dashboard.types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { photoAssets } from '@afilmory/db'
|
||||
|
||||
type PhotoAssetSyncStatus = (typeof photoAssets.$inferSelect)['syncStatus']
|
||||
|
||||
export interface DashboardStats {
|
||||
totalPhotos: number
|
||||
totalStorageBytes: number
|
||||
thisMonthUploads: number
|
||||
previousMonthUploads: number
|
||||
sync: {
|
||||
synced: number
|
||||
pending: number
|
||||
conflicts: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardRecentActivityItem {
|
||||
id: string
|
||||
photoId: string
|
||||
title: string
|
||||
description: string | null
|
||||
createdAt: string
|
||||
takenAt: string | null
|
||||
storageProvider: string
|
||||
size: number | null
|
||||
syncStatus: PhotoAssetSyncStatus
|
||||
tags: string[]
|
||||
previewUrl: string | null
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
stats: DashboardStats
|
||||
recentActivity: DashboardRecentActivityItem[]
|
||||
}
|
||||
@@ -1263,6 +1263,7 @@ export class DataSyncService {
|
||||
this.photoBuilderService.applyStorageConfig(builder, options.storageConfig)
|
||||
}
|
||||
|
||||
this.photoStorageService.registerStorageProviderPlugin(builder, effectiveStorageConfig)
|
||||
const storageManager = builder.getStorageManager()
|
||||
|
||||
if (payload.type === 'missing-in-storage') {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RedisAccessor } from 'core/redis/redis.provider'
|
||||
import { DatabaseModule } from '../database/database.module'
|
||||
import { RedisModule } from '../redis/redis.module'
|
||||
import { AuthModule } from './auth/auth.module'
|
||||
import { DashboardModule } from './dashboard/dashboard.module'
|
||||
import { DataSyncModule } from './data-sync/data-sync.module'
|
||||
import { OnboardingModule } from './onboarding/onboarding.module'
|
||||
import { PhotoModule } from './photo/photo.module'
|
||||
@@ -32,6 +33,7 @@ function createEventModuleOptions(redis: RedisAccessor) {
|
||||
SuperAdminModule,
|
||||
OnboardingModule,
|
||||
PhotoModule,
|
||||
DashboardModule,
|
||||
TenantModule,
|
||||
DataSyncModule,
|
||||
EventModule.forRootAsync({
|
||||
|
||||
@@ -196,7 +196,7 @@ export class PhotoAssetService {
|
||||
|
||||
const manifest = this.createManifestPayload(item)
|
||||
const snapshot = this.createStorageSnapshot(storageObject)
|
||||
const now = this.nowIso()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const insertPayload: typeof photoAssets.$inferInsert = {
|
||||
tenantId: tenant.tenant.id,
|
||||
@@ -315,10 +315,6 @@ export class PhotoAssetService {
|
||||
}
|
||||
}
|
||||
|
||||
private nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
private createStorageKey(input: UploadAssetInput, storageConfig: StorageConfig): string {
|
||||
const ext = path.extname(input.filename)
|
||||
const base = path.basename(input.filename, ext).trim()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { clsxm } from '@afilmory/utils'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export const LinearBorderPanel = ({ className, children }: { className?: string; children: ReactNode }) => (
|
||||
<div className={clsxm('group relative overflow-hidden -mx-6', className)}>
|
||||
<div className={clsxm('group relative overflow-hidden', className)}>
|
||||
{/* Linear gradient borders - sharp edges */}
|
||||
<div className="via-text/20 absolute top-0 right-0 left-0 h-[0.5px] bg-linear-to-r from-transparent to-transparent" />
|
||||
<div className="via-text/20 absolute top-0 right-0 bottom-0 w-[0.5px] bg-linear-to-b from-transparent to-transparent" />
|
||||
|
||||
13
be/apps/dashboard/src/modules/dashboard/api.ts
Normal file
13
be/apps/dashboard/src/modules/dashboard/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { coreApi } from '~/lib/api-client'
|
||||
import { camelCaseKeys } from '~/lib/case'
|
||||
|
||||
import type { DashboardOverviewResponse } from './types'
|
||||
|
||||
const DASHBOARD_OVERVIEW_ENDPOINT = '/dashboard/overview'
|
||||
|
||||
export const fetchDashboardOverview = async () =>
|
||||
camelCaseKeys<DashboardOverviewResponse>(
|
||||
await coreApi<DashboardOverviewResponse>(DASHBOARD_OVERVIEW_ENDPOINT, {
|
||||
method: 'GET',
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
|
||||
import { LinearBorderPanel } from '~/components/common/GlassPanel'
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
|
||||
import { useDashboardOverviewQuery } from '../hooks'
|
||||
import type { DashboardRecentActivityItem } from '../types'
|
||||
|
||||
const compactNumberFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const plainNumberFormatter = new Intl.NumberFormat('zh-CN')
|
||||
|
||||
const percentFormatter = new Intl.NumberFormat('zh-CN', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' })
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
function formatCompactNumber(value: number) {
|
||||
if (!Number.isFinite(value)) return '--'
|
||||
if (value === 0) return '0'
|
||||
return compactNumberFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let value = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
type TimeDivision = {
|
||||
amount: number
|
||||
unit: Intl.RelativeTimeFormatUnit
|
||||
}
|
||||
|
||||
const timeDivisions: TimeDivision[] = [
|
||||
{ amount: 60, unit: 'second' },
|
||||
{ amount: 60, unit: 'minute' },
|
||||
{ amount: 24, unit: 'hour' },
|
||||
{ amount: 7, unit: 'day' },
|
||||
{ amount: 4.34524, unit: 'week' },
|
||||
{ amount: 12, unit: 'month' },
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
|
||||
]
|
||||
|
||||
function formatRelativeTime(iso: string | null | undefined) {
|
||||
if (!iso) return '时间未知'
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '时间未知'
|
||||
}
|
||||
|
||||
let diffInSeconds = (date.getTime() - Date.now()) / 1000
|
||||
for (const division of timeDivisions) {
|
||||
if (Math.abs(diffInSeconds) < division.amount) {
|
||||
return relativeTimeFormatter.format(Math.round(diffInSeconds), division.unit)
|
||||
}
|
||||
diffInSeconds /= division.amount
|
||||
}
|
||||
|
||||
return dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
function formatTakenAt(iso: string | null) {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
const STATUS_META = {
|
||||
synced: {
|
||||
label: '已同步',
|
||||
barClass: 'bg-emerald-400/80',
|
||||
dotClass: 'bg-emerald-400/90',
|
||||
badgeClass: 'bg-emerald-500/10 text-emerald-300',
|
||||
},
|
||||
pending: {
|
||||
label: '处理中',
|
||||
barClass: 'bg-orange-400/80',
|
||||
dotClass: 'bg-orange-400/90',
|
||||
badgeClass: 'bg-orange-500/10 text-orange-300',
|
||||
},
|
||||
conflict: {
|
||||
label: '需关注',
|
||||
barClass: 'bg-red-500/80',
|
||||
dotClass: 'bg-red-500/90',
|
||||
badgeClass: 'bg-red-500/10 text-red-300',
|
||||
},
|
||||
} satisfies Record<
|
||||
DashboardRecentActivityItem['syncStatus'],
|
||||
{ label: string; barClass: string; dotClass: string; badgeClass: string }
|
||||
>
|
||||
|
||||
const EMPTY_STATS = {
|
||||
totalPhotos: 0,
|
||||
totalStorageBytes: 0,
|
||||
thisMonthUploads: 0,
|
||||
previousMonthUploads: 0,
|
||||
sync: {
|
||||
synced: 0,
|
||||
pending: 0,
|
||||
conflicts: 0,
|
||||
},
|
||||
} as const
|
||||
|
||||
const ActivitySkeleton = () => (
|
||||
<div className="bg-fill/10 animate-pulse rounded-lg border border-fill-tertiary px-3.5 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-fill/20 h-11 w-11 shrink-0 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="bg-fill/20 h-3.5 w-32 rounded-full" />
|
||||
<div className="bg-fill/15 h-3 w-48 rounded-full" />
|
||||
<div className="bg-fill/15 h-3 w-40 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const StatSkeleton = () => (
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden p-5">
|
||||
<div className="space-y-2.5">
|
||||
<div className="bg-fill/20 h-3 w-20 rounded-full" />
|
||||
<div className="bg-fill/30 h-7 w-24 rounded-md" />
|
||||
<div className="bg-fill/20 h-3 w-32 rounded-full" />
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
)
|
||||
|
||||
const ActivityList = ({ items }: { items: DashboardRecentActivityItem[] }) => {
|
||||
if (items.length === 0) {
|
||||
return <div className="text-text-tertiary mt-5 text-sm">暂无最近活动,上传照片后即可看到这里的动态。</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{items.map((item, index) => {
|
||||
const statusMeta = STATUS_META[item.syncStatus]
|
||||
const takenAtText = formatTakenAt(item.takenAt)
|
||||
|
||||
return (
|
||||
<m.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.snappy, delay: index * 0.04 }}
|
||||
className="bg-fill/5 hover:bg-fill/10 group rounded-lg border border-fill-tertiary px-3.5 py-3 transition-colors duration-200"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-fill/10 relative h-11 w-11 shrink-0 overflow-hidden rounded-lg">
|
||||
{item.previewUrl ? (
|
||||
<img src={item.previewUrl} alt={item.title} className="size-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="text-text-tertiary flex size-full items-center justify-center text-[10px]">
|
||||
No Preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="text-text truncate text-sm font-semibold">{item.title}</div>
|
||||
<div className="text-text-tertiary text-xs leading-relaxed">
|
||||
<span>上传于 {formatRelativeTime(item.createdAt)}</span>
|
||||
{takenAtText ? (
|
||||
<>
|
||||
<span className="mx-1.5">•</span>
|
||||
<span>拍摄时间 {takenAtText}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-text-secondary flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span>{item.size != null && item.size > 0 ? formatBytes(item.size) : '大小未知'}</span>
|
||||
<span className="text-text-tertiary">•</span>
|
||||
<span>{item.storageProvider}</span>
|
||||
<span className="text-text-tertiary">•</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] ${statusMeta.badgeClass}`}>
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
{item.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{item.tags.map((tag) => (
|
||||
<span key={tag} className="bg-accent/10 text-accent rounded-full px-2 py-0.5 text-[10px]">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-text-tertiary min-w-0 truncate text-right text-[11px] sm:text-right">
|
||||
ID:
|
||||
<span className="ml-1 truncate">{item.photoId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DashboardOverview = () => {
|
||||
const { data, isLoading, isError } = useDashboardOverviewQuery()
|
||||
|
||||
const stats = data?.stats ?? EMPTY_STATS
|
||||
const statusTotal = stats.sync.synced + stats.sync.pending + stats.sync.conflicts
|
||||
const syncCompletion = statusTotal === 0 ? null : stats.sync.synced / statusTotal
|
||||
|
||||
const monthlyDelta = stats.thisMonthUploads - stats.previousMonthUploads
|
||||
let monthlyTrendDescription = '与上月持平'
|
||||
if (stats.previousMonthUploads === 0) {
|
||||
monthlyTrendDescription = stats.thisMonthUploads === 0 ? '与上月持平' : '首次出现上传记录'
|
||||
} else if (monthlyDelta > 0) {
|
||||
monthlyTrendDescription = `比上月多 ${plainNumberFormatter.format(monthlyDelta)} 张`
|
||||
} else if (monthlyDelta < 0) {
|
||||
monthlyTrendDescription = `比上月少 ${plainNumberFormatter.format(Math.abs(monthlyDelta))} 张`
|
||||
}
|
||||
|
||||
const averageSize = stats.totalPhotos > 0 ? stats.totalStorageBytes / stats.totalPhotos : 0
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
key: 'total-photos',
|
||||
label: '照片总数',
|
||||
value: formatCompactNumber(stats.totalPhotos),
|
||||
helper: `${plainNumberFormatter.format(stats.totalPhotos)} 张照片`,
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
label: '占用存储',
|
||||
value: formatBytes(stats.totalStorageBytes),
|
||||
helper: stats.totalPhotos > 0 ? `平均每张 ${formatBytes(averageSize || 0)}` : '暂无照片,存储占用为 0',
|
||||
},
|
||||
{
|
||||
key: 'this-month',
|
||||
label: '本月新增',
|
||||
value: formatCompactNumber(stats.thisMonthUploads),
|
||||
helper: monthlyTrendDescription,
|
||||
},
|
||||
{
|
||||
key: 'sync',
|
||||
label: '同步完成率',
|
||||
value: syncCompletion === null ? '--' : percentFormatter.format(syncCompletion),
|
||||
helper: statusTotal
|
||||
? `待处理 ${plainNumberFormatter.format(stats.sync.pending)} | 冲突 ${plainNumberFormatter.format(stats.sync.conflicts)}`
|
||||
: '暂无同步任务',
|
||||
},
|
||||
]
|
||||
|
||||
const syncSummary = [
|
||||
{ key: 'synced', value: stats.sync.synced },
|
||||
{ key: 'pending', value: stats.sync.pending },
|
||||
{ key: 'conflict', value: stats.sync.conflicts },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<MainPageLayout title="Dashboard" description="掌握图库运行状态与最近同步活动">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }, (_, i) => `skeleton-${i}`).map((key) => <StatSkeleton key={key} />)
|
||||
: statCards.map((card, index) => (
|
||||
<LinearBorderPanel key={card.key} className="bg-background-tertiary/60 relative overflow-hidden p-5">
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: index * 0.05 }}
|
||||
className="space-y-2.5"
|
||||
>
|
||||
<span className="text-text-secondary text-xs font-medium tracking-wide uppercase">
|
||||
{card.label}
|
||||
</span>
|
||||
<div className="text-text text-2xl font-semibold">{card.value}</div>
|
||||
<div className="text-text-tertiary text-xs leading-relaxed">{card.helper}</div>
|
||||
</m.div>
|
||||
</LinearBorderPanel>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-text text-base font-semibold">最近活动</h2>
|
||||
<p className="text-text-tertiary text-sm leading-relaxed">
|
||||
{data?.recentActivity?.length
|
||||
? `展示最近 ${data.recentActivity.length} 次上传和同步记录`
|
||||
: '还没有任何上传,快来添加第一张照片吧~'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{Array.from({ length: 3 }, (_, i) => `activity-skeleton-${i}`).map((key) => (
|
||||
<ActivitySkeleton key={key} />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="text-red-400 mt-5 text-sm">无法获取活动数据,请稍后再试。</div>
|
||||
) : (
|
||||
<ActivityList items={data?.recentActivity ?? []} />
|
||||
)}
|
||||
</LinearBorderPanel>
|
||||
|
||||
<LinearBorderPanel className="bg-background-tertiary/60 relative overflow-hidden px-5 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-text text-base font-semibold">同步概览</h2>
|
||||
<p className="text-text-tertiary text-sm leading-relaxed">了解当前同步状态,及时处理异常项。</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
{syncSummary.map((entry) => {
|
||||
const meta = STATUS_META[entry.key]
|
||||
const percent = statusTotal ? Math.round((entry.value / statusTotal) * 100) : 0
|
||||
|
||||
return (
|
||||
<div key={entry.key} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-text-secondary inline-flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${meta.dotClass}`} />
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-text font-medium">
|
||||
{plainNumberFormatter.format(entry.value)}
|
||||
<span className="text-text-tertiary ml-1.5">{percent}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-fill/20 h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${meta.barClass}`}
|
||||
style={{ width: `${statusTotal ? Math.max(percent, entry.value > 0 ? 4 : 0) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-text-secondary mt-5 rounded-lg border border-fill-tertiary bg-fill/5 px-3.5 py-2.5 text-xs leading-relaxed">
|
||||
{statusTotal === 0
|
||||
? '暂无同步任务,添加照片后即可查看同步健康度。'
|
||||
: syncCompletion !== null && syncCompletion >= 0.85
|
||||
? '同步状态良好,保持当前处理效率即可。'
|
||||
: '存在待处理或冲突的项目,建议尽快检查同步日志。'}
|
||||
</div>
|
||||
</LinearBorderPanel>
|
||||
</div>
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
)
|
||||
}
|
||||
13
be/apps/dashboard/src/modules/dashboard/hooks.ts
Normal file
13
be/apps/dashboard/src/modules/dashboard/hooks.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { fetchDashboardOverview } from './api'
|
||||
import type { DashboardOverviewResponse } from './types'
|
||||
|
||||
export const DASHBOARD_OVERVIEW_QUERY_KEY = ['dashboard', 'overview'] as const
|
||||
|
||||
export const useDashboardOverviewQuery = () =>
|
||||
useQuery<DashboardOverviewResponse>({
|
||||
queryKey: DASHBOARD_OVERVIEW_QUERY_KEY,
|
||||
queryFn: fetchDashboardOverview,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
30
be/apps/dashboard/src/modules/dashboard/types.ts
Normal file
30
be/apps/dashboard/src/modules/dashboard/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface DashboardStats {
|
||||
totalPhotos: number
|
||||
totalStorageBytes: number
|
||||
thisMonthUploads: number
|
||||
previousMonthUploads: number
|
||||
sync: {
|
||||
synced: number
|
||||
pending: number
|
||||
conflicts: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardRecentActivityItem {
|
||||
id: string
|
||||
photoId: string
|
||||
title: string
|
||||
description: string | null
|
||||
createdAt: string
|
||||
takenAt: string | null
|
||||
storageProvider: string
|
||||
size: number | null
|
||||
syncStatus: 'pending' | 'synced' | 'conflict'
|
||||
tags: string[]
|
||||
previewUrl: string | null
|
||||
}
|
||||
|
||||
export interface DashboardOverviewResponse {
|
||||
stats: DashboardStats
|
||||
recentActivity: DashboardRecentActivityItem[]
|
||||
}
|
||||
@@ -1,68 +1,3 @@
|
||||
import { Spring } from '@afilmory/utils'
|
||||
import { m } from 'motion/react'
|
||||
import { DashboardOverview } from '~/modules/dashboard/components/DashboardOverview'
|
||||
|
||||
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<MainPageLayout title="Dashboard" description="Welcome to your photo management dashboard">
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards - Sharp Edges */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{ label: 'Total Photos', value: '1,234', trend: '+12%' },
|
||||
{ label: 'Storage Used', value: '45.2 GB', trend: '+8%' },
|
||||
{ label: 'This Month', value: '156', trend: '+24%' },
|
||||
].map((stat, index) => (
|
||||
<m.div
|
||||
key={stat.label}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ ...Spring.presets.smooth, delay: index * 0.1 }}
|
||||
className="bg-background-tertiary relative p-4"
|
||||
>
|
||||
{/* Gradient borders */}
|
||||
<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" />
|
||||
|
||||
<div className="text-text-secondary text-[11px] font-medium">{stat.label}</div>
|
||||
<div className="text-text mt-2 text-2xl font-semibold">{stat.value}</div>
|
||||
<div className="text-accent mt-1 text-[11px] font-medium">{stat.trend} from last month</div>
|
||||
</m.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Activity - Sharp Edges */}
|
||||
<div className="bg-background-tertiary relative p-4">
|
||||
{/* Gradient borders */}
|
||||
<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 text-sm font-semibold">Recent Activity</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{[
|
||||
{ action: 'Uploaded 23 photos', time: '2 hours ago' },
|
||||
{
|
||||
action: 'Created new album "Summer 2024"',
|
||||
time: '5 hours ago',
|
||||
},
|
||||
{ action: 'Shared album with 3 people', time: '1 day ago' },
|
||||
].map((activity) => (
|
||||
<div
|
||||
key={activity.action}
|
||||
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]">{activity.action}</span>
|
||||
<span className="text-text-tertiary text-[11px]">{activity.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainPageLayout>
|
||||
)
|
||||
}
|
||||
export const Component = () => <DashboardOverview />
|
||||
|
||||
Reference in New Issue
Block a user