feat: revamp dashboard photo management

This commit is contained in:
Innei
2025-10-30 14:03:38 +08:00
parent 7dd6c1c812
commit cd16342079
24 changed files with 2218 additions and 626 deletions

View File

@@ -1,29 +1,14 @@
import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageManager, StorageObject } from '@afilmory/builder'
import { createDefaultBuilderConfig, StorageFactory } from '@afilmory/builder'
import {
EagleStorageProvider,
GitHubStorageProvider,
LocalStorageProvider,
S3StorageProvider,
} from '@afilmory/builder/storage/index.js'
import type {
EagleConfig,
EagleRule,
GitHubConfig,
LocalConfig,
S3Config,
} from '@afilmory/builder/storage/interfaces.js'
import type { PhotoAssetConflictPayload, PhotoAssetConflictSnapshot, PhotoAssetManifest } from '@afilmory/db'
import { CURRENT_PHOTO_MANIFEST_VERSION, photoAssets } from '@afilmory/db'
import { createLogger } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { PhotoBuilderService } from 'core/modules/photo/photo.service'
import { PhotoStorageService } from 'core/modules/photo/photo-storage.service'
import { and, eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import { SettingService } from '../setting/setting.service'
import type { BuilderStorageProvider } from '../setting/storage-provider.utils'
import { requireTenantContext } from '../tenant/tenant.context'
import type {
ConflictPayload,
@@ -76,7 +61,7 @@ export class DataSyncService {
constructor(
private readonly dbAccessor: DbAccessor,
private readonly photoBuilderService: PhotoBuilderService,
private readonly settingService: SettingService,
private readonly photoStorageService: PhotoStorageService,
) {}
async runSync(options: DataSyncOptions): Promise<DataSyncResult> {
@@ -211,256 +196,14 @@ export class DataSyncService {
builder: ReturnType<PhotoBuilderService['createBuilder']>,
storageConfig: StorageConfig,
): void {
switch (storageConfig.provider) {
case 's3': {
builder.registerStorageProvider('s3', (config) => new S3StorageProvider(config as S3Config))
break
}
case 'github': {
builder.registerStorageProvider('github', (config) => new GitHubStorageProvider(config as GitHubConfig))
break
}
case 'local': {
builder.registerStorageProvider('local', (config) => new LocalStorageProvider(config as LocalConfig))
break
}
case 'eagle': {
builder.registerStorageProvider('eagle', (config) => new EagleStorageProvider(config as EagleConfig))
break
}
default: {
const provider = (storageConfig as StorageConfig)?.provider as string
const registered = StorageFactory.getRegisteredProviders()
if (!registered.includes(provider)) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: `Unsupported storage provider type: ${provider}`,
})
}
}
}
this.photoStorageService.registerStorageProviderPlugin(builder, storageConfig)
}
private async resolveBuilderConfigForTenant(
tenantId: string,
overrides: Pick<DataSyncOptions, 'builderConfig' | 'storageConfig'>,
): Promise<{ builderConfig: BuilderConfig; storageConfig: StorageConfig }> {
if (overrides.builderConfig) {
const storageConfig = overrides.storageConfig ?? overrides.builderConfig.storage
return { builderConfig: overrides.builderConfig, storageConfig }
}
const activeProvider = await this.settingService.getActiveStorageProvider({ tenantId })
if (!activeProvider) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: 'Active storage provider is not configured. Configure storage settings before running sync.',
})
}
const storageConfig = this.mapProviderToStorageConfig(activeProvider)
const builderConfig = createDefaultBuilderConfig()
builderConfig.storage = storageConfig
return { builderConfig, storageConfig }
}
private mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig {
const config = provider.config ?? {}
switch (provider.type) {
case 's3': {
const bucket = this.requireString(config.bucket, 'Active S3 storage provider is missing `bucket`.')
const result: S3Config = {
provider: 's3',
bucket,
}
const region = this.normalizeString(config.region)
if (region) result.region = region
const endpoint = this.normalizeString(config.endpoint)
if (endpoint) result.endpoint = endpoint
const accessKeyId = this.normalizeString(config.accessKeyId)
if (accessKeyId) result.accessKeyId = accessKeyId
const secretAccessKey = this.normalizeString(config.secretAccessKey)
if (secretAccessKey) result.secretAccessKey = secretAccessKey
const prefix = this.normalizeString(config.prefix)
if (prefix) result.prefix = prefix
const customDomain = this.normalizeString(config.customDomain)
if (customDomain) result.customDomain = customDomain
const excludeRegex = this.normalizeString(config.excludeRegex)
if (excludeRegex) result.excludeRegex = excludeRegex
const maxFileLimit = this.parseNumber(config.maxFileLimit)
if (typeof maxFileLimit === 'number') result.maxFileLimit = maxFileLimit
const keepAlive = this.parseBoolean(config.keepAlive)
if (typeof keepAlive === 'boolean') result.keepAlive = keepAlive
const maxSockets = this.parseNumber(config.maxSockets)
if (typeof maxSockets === 'number') result.maxSockets = maxSockets
const connectionTimeoutMs = this.parseNumber(config.connectionTimeoutMs)
if (typeof connectionTimeoutMs === 'number') result.connectionTimeoutMs = connectionTimeoutMs
const socketTimeoutMs = this.parseNumber(config.socketTimeoutMs)
if (typeof socketTimeoutMs === 'number') result.socketTimeoutMs = socketTimeoutMs
const requestTimeoutMs = this.parseNumber(config.requestTimeoutMs)
if (typeof requestTimeoutMs === 'number') result.requestTimeoutMs = requestTimeoutMs
const idleTimeoutMs = this.parseNumber(config.idleTimeoutMs)
if (typeof idleTimeoutMs === 'number') result.idleTimeoutMs = idleTimeoutMs
const totalTimeoutMs = this.parseNumber(config.totalTimeoutMs)
if (typeof totalTimeoutMs === 'number') result.totalTimeoutMs = totalTimeoutMs
const retryMode = this.parseRetryMode(config.retryMode)
if (retryMode) result.retryMode = retryMode
const maxAttempts = this.parseNumber(config.maxAttempts)
if (typeof maxAttempts === 'number') result.maxAttempts = maxAttempts
const downloadConcurrency = this.parseNumber(config.downloadConcurrency)
if (typeof downloadConcurrency === 'number') result.downloadConcurrency = downloadConcurrency
return result
}
case 'github': {
const owner = this.requireString(config.owner, 'Active GitHub storage provider is missing `owner`.')
const repo = this.requireString(config.repo, 'Active GitHub storage provider is missing `repo`.')
const result: GitHubConfig = {
provider: 'github',
owner,
repo,
}
const branch = this.normalizeString(config.branch)
if (branch) result.branch = branch
const token = this.normalizeString(config.token)
if (token) result.token = token
const path = this.normalizeString(config.path)
if (path) result.path = path
const useRawUrl = this.parseBoolean(config.useRawUrl)
if (typeof useRawUrl === 'boolean') result.useRawUrl = useRawUrl
return result
}
case 'local': {
const basePath = this.normalizeString(config.basePath) ?? this.normalizeString(config.path)
const resolvedBasePath = this.requireString(
basePath,
'Active local storage provider is missing `basePath`. ' +
'Please provide a valid path to your photo directory.',
)
const result: LocalConfig = {
provider: 'local',
basePath: resolvedBasePath,
}
const baseUrl = this.normalizeString(config.baseUrl)
if (baseUrl) result.baseUrl = baseUrl
const distPath = this.normalizeString(config.distPath)
if (distPath) result.distPath = distPath
const excludeRegex = this.normalizeString(config.excludeRegex)
if (excludeRegex) result.excludeRegex = excludeRegex
const maxFileLimit = this.parseNumber(config.maxFileLimit)
if (typeof maxFileLimit === 'number') result.maxFileLimit = maxFileLimit
return result
}
case 'eagle': {
const libraryPath = this.requireString(
config.libraryPath,
'Active Eagle storage provider is missing `libraryPath`. Provide the path to your Eagle library.',
)
const result: EagleConfig = {
provider: 'eagle',
libraryPath,
}
const distPath = this.normalizeString(config.distPath)
if (distPath) result.distPath = distPath
const baseUrl = this.normalizeString(config.baseUrl)
if (baseUrl) result.baseUrl = baseUrl
const includeRules = this.parseJsonArray<EagleRule>(config.include)
if (includeRules) result.include = includeRules
const excludeRules = this.parseJsonArray<EagleRule>(config.exclude)
if (excludeRules) result.exclude = excludeRules
return result
}
default: {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: `Unsupported storage provider type: ${provider.type}`,
})
}
}
}
private normalizeString(value?: string | null): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = value.trim()
return normalized.length > 0 ? normalized : undefined
}
private parseNumber(value?: string | null): number | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : undefined
}
private parseBoolean(value?: string | null): boolean | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
const lowered = normalized.toLowerCase()
if (['true', '1', 'yes', 'y', 'on'].includes(lowered)) {
return true
}
if (['false', '0', 'no', 'n', 'off'].includes(lowered)) {
return false
}
return undefined
}
private parseRetryMode(value?: string | null): S3Config['retryMode'] | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
if (normalized === 'standard' || normalized === 'adaptive' || normalized === 'legacy') {
return normalized
}
return undefined
}
private parseJsonArray<T>(value?: string | null): T[] | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
try {
const parsed = JSON.parse(normalized)
if (Array.isArray(parsed)) {
return parsed as T[]
}
} catch {
/* ignore parse errors */
}
return undefined
}
private requireString(value: string | undefined | null, message: string): string {
const normalized = this.normalizeString(value)
if (!normalized) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message })
}
return normalized
return await this.photoStorageService.resolveConfigForTenant(tenantId, overrides)
}
private createSummary(context: SyncPreparation): DataSyncResult['summary'] {
@@ -502,6 +245,7 @@ export class DataSyncService {
snapshots: {
after: storageSnapshot,
},
manifestAfter: null,
})
continue
}
@@ -521,6 +265,7 @@ export class DataSyncService {
snapshots: {
after: storageSnapshot,
},
manifestAfter: null,
})
continue
}
@@ -570,6 +315,7 @@ export class DataSyncService {
snapshots: {
after: storageSnapshot,
},
manifestAfter: result.item,
})
}
}
@@ -617,6 +363,8 @@ export class DataSyncService {
snapshots: {
before: recordSnapshot,
},
manifestBefore: record.manifest.data,
manifestAfter: null,
})
}
}
@@ -666,6 +414,8 @@ export class DataSyncService {
before: recordSnapshot,
after: storageSnapshot,
},
manifestBefore: record.manifest.data,
manifestAfter: null,
})
}
}
@@ -714,6 +464,8 @@ export class DataSyncService {
before: this.createRecordSnapshot(record),
after: storageSnapshot,
},
manifestBefore: record.manifest.data,
manifestAfter: record.manifest.data,
})
}
}
@@ -950,6 +702,8 @@ export class DataSyncService {
snapshots: {
before: this.createRecordSnapshot(record),
},
manifestBefore: record.manifest.data,
manifestAfter: null,
}
}
@@ -965,6 +719,8 @@ export class DataSyncService {
snapshots: {
before: this.createRecordSnapshot(record),
},
manifestBefore: record.manifest.data,
manifestAfter: null,
}
}
@@ -1020,6 +776,8 @@ export class DataSyncService {
before: this.createRecordSnapshot(record),
after: storageSnapshot,
},
manifestBefore: record.manifest.data,
manifestAfter: processResult.item,
}
}
@@ -1044,6 +802,8 @@ export class DataSyncService {
snapshots: {
before: recordSnapshot,
},
manifestBefore: record.manifest.data,
manifestAfter: record.manifest.data,
}
}
@@ -1070,6 +830,8 @@ export class DataSyncService {
snapshots: {
before: recordSnapshot,
},
manifestBefore: record.manifest.data,
manifestAfter: record.manifest.data,
}
}
@@ -1109,6 +871,8 @@ export class DataSyncService {
before: recordSnapshot,
after: storageSnapshot,
},
manifestBefore: record.manifest.data,
manifestAfter: record.manifest.data,
}
}
}

View File

@@ -1,4 +1,4 @@
import type { BuilderConfig, StorageConfig } from '@afilmory/builder'
import type { BuilderConfig, PhotoManifestItem, StorageConfig } from '@afilmory/builder'
import type { PhotoAssetConflictPayload, PhotoAssetManifest } from '@afilmory/db'
export enum ConflictResolutionStrategy {
@@ -33,6 +33,8 @@ export interface DataSyncAction {
before?: SyncObjectSnapshot | null
after?: SyncObjectSnapshot | null
}
manifestBefore?: PhotoManifestItem | null
manifestAfter?: PhotoManifestItem | null
}
export interface DataSyncResultSummary {

View File

@@ -0,0 +1,333 @@
import path from 'node:path'
import type { PhotoManifestItem, StorageConfig, StorageObject } from '@afilmory/builder'
import { StorageManager } from '@afilmory/builder/storage/index.js'
import type { PhotoAssetManifest } from '@afilmory/db'
import { CURRENT_PHOTO_MANIFEST_VERSION, photoAssets } from '@afilmory/db'
import { BizException, ErrorCode } from 'core/errors'
import { PhotoBuilderService } from 'core/modules/photo/photo.service'
import { requireTenantContext } from 'core/modules/tenant/tenant.context'
import { and, eq, inArray } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import { PhotoStorageService } from './photo-storage.service'
type PhotoAssetRecord = typeof photoAssets.$inferSelect
export interface PhotoAssetListItem {
id: string
photoId: string
storageKey: string
storageProvider: string
manifest: PhotoAssetManifest
syncedAt: string
updatedAt: string
createdAt: string
publicUrl: string | null
size: number | null
syncStatus: PhotoAssetRecord['syncStatus']
}
export interface PhotoAssetSummary {
total: number
synced: number
conflicts: number
pending: number
}
export interface UploadAssetInput {
filename: string
buffer: Buffer
contentType?: string
directory?: string | null
}
const DATABASE_ONLY_PROVIDER = 'database-only'
@injectable()
export class PhotoAssetService {
constructor(
private readonly dbAccessor: DbAccessor,
private readonly photoBuilderService: PhotoBuilderService,
private readonly photoStorageService: PhotoStorageService,
) {}
async listAssets(): Promise<PhotoAssetListItem[]> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const records = await db
.select()
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenant.tenant.id))
.orderBy(photoAssets.createdAt)
if (records.length === 0) {
return []
}
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const storageManager = this.createStorageManager(storageConfig)
return await Promise.all(
records.map(async (record) => {
let publicUrl: string | null = null
if (record.storageProvider !== DATABASE_ONLY_PROVIDER) {
try {
publicUrl = await Promise.resolve(storageManager.generatePublicUrl(record.storageKey))
} catch {
publicUrl = null
}
}
return {
id: record.id,
photoId: record.photoId,
storageKey: record.storageKey,
storageProvider: record.storageProvider,
manifest: record.manifest,
syncedAt: record.syncedAt,
updatedAt: record.updatedAt,
createdAt: record.createdAt,
publicUrl,
size: record.size ?? null,
syncStatus: record.syncStatus,
}
}),
)
}
async getSummary(): Promise<PhotoAssetSummary> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const records = await db
.select({ status: photoAssets.syncStatus })
.from(photoAssets)
.where(eq(photoAssets.tenantId, tenant.tenant.id))
const summary = {
total: records.length,
synced: 0,
conflicts: 0,
pending: 0,
}
for (const record of records) {
if (record.status === 'synced') summary.synced += 1
else if (record.status === 'conflict') summary.conflicts += 1
else summary.pending += 1
}
return summary
}
async deleteAssets(ids: readonly string[]): Promise<void> {
if (ids.length === 0) {
return
}
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const records = await db
.select()
.from(photoAssets)
.where(and(eq(photoAssets.tenantId, tenant.tenant.id), inArray(photoAssets.id, ids)))
if (records.length === 0) {
return
}
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const storageManager = this.createStorageManager(storageConfig)
for (const record of records) {
if (record.storageProvider !== DATABASE_ONLY_PROVIDER) {
try {
await storageManager.deleteFile(record.storageKey)
} catch (error) {
throw new BizException(ErrorCode.COMMON_INTERNAL_ERROR, {
message: `无法删除存储中的文件 ${record.storageKey}: ${String(error)}`,
})
}
}
}
await db.delete(photoAssets).where(and(eq(photoAssets.tenantId, tenant.tenant.id), inArray(photoAssets.id, ids)))
}
async uploadAssets(inputs: readonly UploadAssetInput[]): Promise<PhotoAssetListItem[]> {
if (inputs.length === 0) {
return []
}
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
const { builderConfig, storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const builder = this.photoBuilderService.createBuilder(builderConfig)
this.photoStorageService.registerStorageProviderPlugin(builder, storageConfig)
this.photoBuilderService.applyStorageConfig(builder, storageConfig)
const storageManager = builder.getStorageManager()
const results: PhotoAssetListItem[] = []
for (const input of inputs) {
const key = this.createStorageKey(input)
const storageObject = await storageManager.uploadFile(key, input.buffer, {
contentType: input.contentType,
})
const processed = await this.photoBuilderService.processPhotoFromStorageObject(storageObject, {
builder,
builderConfig,
processorOptions: {
isForceMode: true,
isForceManifest: true,
isForceThumbnails: true,
},
})
const item = processed?.item
if (!item) {
throw new BizException(ErrorCode.COMMON_INTERNAL_ERROR, {
message: `无法为文件 ${key} 生成照片清单`,
})
}
const manifest = this.createManifestPayload(item)
const snapshot = this.createStorageSnapshot(storageObject)
const now = this.nowIso()
const insertPayload: typeof photoAssets.$inferInsert = {
tenantId: tenant.tenant.id,
photoId: item.id,
storageKey: key,
storageProvider: storageConfig.provider,
size: snapshot.size ?? null,
etag: snapshot.etag ?? null,
lastModified: snapshot.lastModified ?? null,
metadataHash: snapshot.metadataHash,
manifestVersion: CURRENT_PHOTO_MANIFEST_VERSION,
manifest,
syncStatus: 'synced',
conflictReason: null,
conflictPayload: null,
syncedAt: now,
createdAt: now,
updatedAt: now,
}
const [record] = await db
.insert(photoAssets)
.values(insertPayload)
.onConflictDoUpdate({
target: [photoAssets.tenantId, photoAssets.storageKey],
set: {
photoId: item.id,
storageProvider: storageConfig.provider,
size: snapshot.size ?? null,
etag: snapshot.etag ?? null,
lastModified: snapshot.lastModified ?? null,
metadataHash: snapshot.metadataHash,
manifestVersion: CURRENT_PHOTO_MANIFEST_VERSION,
manifest,
syncStatus: 'synced',
conflictReason: null,
conflictPayload: null,
syncedAt: now,
updatedAt: now,
},
})
.returning()
const saved =
record ??
(
await db
.select()
.from(photoAssets)
.where(and(eq(photoAssets.tenantId, tenant.tenant.id), eq(photoAssets.storageKey, key)))
.limit(1)
)[0]
const publicUrl = await Promise.resolve(storageManager.generatePublicUrl(key))
results.push({
id: saved.id,
photoId: saved.photoId,
storageKey: saved.storageKey,
storageProvider: saved.storageProvider,
manifest: saved.manifest,
syncedAt: saved.syncedAt,
updatedAt: saved.updatedAt,
createdAt: saved.createdAt,
publicUrl,
size: saved.size ?? null,
syncStatus: saved.syncStatus,
})
}
return results
}
async generatePublicUrl(storageKey: string): Promise<string> {
const tenant = requireTenantContext()
const { storageConfig } = await this.photoStorageService.resolveConfigForTenant(tenant.tenant.id)
const storageManager = this.createStorageManager(storageConfig)
return await Promise.resolve(storageManager.generatePublicUrl(storageKey))
}
private createStorageManager(storageConfig: StorageConfig): StorageManager {
return new StorageManager(storageConfig)
}
private createStorageSnapshot(object: StorageObject) {
const lastModified = object.lastModified ? object.lastModified.toISOString() : null
const metadataHash = this.computeMetadataHash({
size: object.size ?? null,
etag: object.etag ?? null,
lastModified,
})
return {
size: object.size ?? null,
etag: object.etag ?? null,
lastModified,
metadataHash,
}
}
private computeMetadataHash(parts: { size: number | null; etag: string | null; lastModified: string | null }) {
const normalizedSize = parts.size !== null ? String(parts.size) : ''
const normalizedEtag = parts.etag ?? ''
const normalizedLastModified = parts.lastModified ?? ''
const digestValue = `${normalizedEtag}::${normalizedSize}::${normalizedLastModified}`
return digestValue === '::::' ? null : digestValue
}
private createManifestPayload(item: PhotoManifestItem): PhotoAssetManifest {
return {
version: CURRENT_PHOTO_MANIFEST_VERSION,
data: structuredClone(item),
}
}
private nowIso(): string {
return new Date().toISOString()
}
private createStorageKey(input: UploadAssetInput): string {
const ext = path.extname(input.filename)
const base = path.basename(input.filename, ext)
const slug = base
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '-')
.replaceAll(/^-+|-+$/g, '')
const timestamp = Date.now()
const dir = input.directory?.trim() ? input.directory.trim().replaceAll(/\\+/g, '/') : 'uploads'
return `${dir}/${timestamp}-${slug || 'photo'}${ext}`.replaceAll(/\\+/g, '/')
}
}

View File

@@ -0,0 +1,286 @@
import type { BuilderConfig, StorageConfig } from '@afilmory/builder'
import { createDefaultBuilderConfig, StorageFactory } from '@afilmory/builder'
import {
EagleStorageProvider,
GitHubStorageProvider,
LocalStorageProvider,
S3StorageProvider,
} from '@afilmory/builder/storage/index.js'
import type {
EagleConfig,
EagleRule,
GitHubConfig,
LocalConfig,
S3Config,
} from '@afilmory/builder/storage/interfaces.js'
import { BizException, ErrorCode } from 'core/errors'
import type { BuilderStorageProvider } from 'core/modules/setting/storage-provider.utils'
import { injectable } from 'tsyringe'
import { SettingService } from '../setting/setting.service'
import type { PhotoBuilderService } from './photo.service'
type ResolveOverrides = {
builderConfig?: BuilderConfig
storageConfig?: StorageConfig
}
@injectable()
export class PhotoStorageService {
constructor(private readonly settingService: SettingService) {}
async resolveConfigForTenant(
tenantId: string,
overrides: ResolveOverrides = {},
): Promise<{ builderConfig: BuilderConfig; storageConfig: StorageConfig }> {
if (overrides.builderConfig) {
const storageConfig = overrides.storageConfig ?? overrides.builderConfig.storage
return { builderConfig: overrides.builderConfig, storageConfig }
}
const activeProvider = await this.settingService.getActiveStorageProvider({ tenantId })
if (!activeProvider) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: 'Active storage provider is not configured. Configure storage settings before running sync.',
})
}
const storageConfig = this.mapProviderToStorageConfig(activeProvider)
const builderConfig = createDefaultBuilderConfig()
builderConfig.storage = storageConfig
return { builderConfig, storageConfig }
}
registerStorageProviderPlugin(
builder: ReturnType<PhotoBuilderService['createBuilder']>,
storageConfig: StorageConfig,
): void {
switch (storageConfig.provider) {
case 's3': {
builder.registerStorageProvider('s3', (config) => new S3StorageProvider(config as S3Config))
break
}
case 'github': {
builder.registerStorageProvider('github', (config) => new GitHubStorageProvider(config as GitHubConfig))
break
}
case 'local': {
builder.registerStorageProvider('local', (config) => new LocalStorageProvider(config as LocalConfig))
break
}
case 'eagle': {
builder.registerStorageProvider('eagle', (config) => new EagleStorageProvider(config as EagleConfig))
break
}
default: {
const provider = (storageConfig as StorageConfig)?.provider as string
const registered = StorageFactory.getRegisteredProviders()
if (!registered.includes(provider)) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: `Unsupported storage provider type: ${provider}`,
})
}
}
}
}
private mapProviderToStorageConfig(provider: BuilderStorageProvider): StorageConfig {
const config = provider.config ?? {}
switch (provider.type) {
case 's3': {
const bucket = this.requireString(config.bucket, 'Active S3 storage provider is missing `bucket`.')
const result: S3Config = {
provider: 's3',
bucket,
}
const region = this.normalizeString(config.region)
if (region) result.region = region
const endpoint = this.normalizeString(config.endpoint)
if (endpoint) result.endpoint = endpoint
const accessKeyId = this.normalizeString(config.accessKeyId)
if (accessKeyId) result.accessKeyId = accessKeyId
const secretAccessKey = this.normalizeString(config.secretAccessKey)
if (secretAccessKey) result.secretAccessKey = secretAccessKey
const prefix = this.normalizeString(config.prefix)
if (prefix) result.prefix = prefix
const customDomain = this.normalizeString(config.customDomain)
if (customDomain) result.customDomain = customDomain
const excludeRegex = this.normalizeString(config.excludeRegex)
if (excludeRegex) result.excludeRegex = excludeRegex
const maxFileLimit = this.parseNumber(config.maxFileLimit)
if (typeof maxFileLimit === 'number') result.maxFileLimit = maxFileLimit
const keepAlive = this.parseBoolean(config.keepAlive)
if (typeof keepAlive === 'boolean') result.keepAlive = keepAlive
const maxSockets = this.parseNumber(config.maxSockets)
if (typeof maxSockets === 'number') result.maxSockets = maxSockets
const connectionTimeoutMs = this.parseNumber(config.connectionTimeoutMs)
if (typeof connectionTimeoutMs === 'number') result.connectionTimeoutMs = connectionTimeoutMs
const socketTimeoutMs = this.parseNumber(config.socketTimeoutMs)
if (typeof socketTimeoutMs === 'number') result.socketTimeoutMs = socketTimeoutMs
const requestTimeoutMs = this.parseNumber(config.requestTimeoutMs)
if (typeof requestTimeoutMs === 'number') result.requestTimeoutMs = requestTimeoutMs
const idleTimeoutMs = this.parseNumber(config.idleTimeoutMs)
if (typeof idleTimeoutMs === 'number') result.idleTimeoutMs = idleTimeoutMs
const totalTimeoutMs = this.parseNumber(config.totalTimeoutMs)
if (typeof totalTimeoutMs === 'number') result.totalTimeoutMs = totalTimeoutMs
const retryMode = this.parseRetryMode(config.retryMode)
if (retryMode) result.retryMode = retryMode
const maxAttempts = this.parseNumber(config.maxAttempts)
if (typeof maxAttempts === 'number') result.maxAttempts = maxAttempts
const downloadConcurrency = this.parseNumber(config.downloadConcurrency)
if (typeof downloadConcurrency === 'number') result.downloadConcurrency = downloadConcurrency
return result
}
case 'github': {
const owner = this.requireString(config.owner, 'Active GitHub storage provider is missing `owner`.')
const repo = this.requireString(config.repo, 'Active GitHub storage provider is missing `repo`.')
const result: GitHubConfig = {
provider: 'github',
owner,
repo,
}
const branch = this.normalizeString(config.branch)
if (branch) result.branch = branch
const token = this.normalizeString(config.token)
if (token) result.token = token
const pathValue = this.normalizeString(config.path)
if (pathValue) result.path = pathValue
const useRawUrl = this.parseBoolean(config.useRawUrl)
if (typeof useRawUrl === 'boolean') result.useRawUrl = useRawUrl
return result
}
case 'local': {
const basePath = this.normalizeString(config.basePath) ?? this.normalizeString(config.path)
const resolvedBasePath = this.requireString(
basePath,
'Active local storage provider is missing `basePath`. Please provide a valid path to your photo directory.',
)
const result: LocalConfig = {
provider: 'local',
basePath: resolvedBasePath,
}
const baseUrl = this.normalizeString(config.baseUrl)
if (baseUrl) result.baseUrl = baseUrl
const distPath = this.normalizeString(config.distPath)
if (distPath) result.distPath = distPath
const excludeRegex = this.normalizeString(config.excludeRegex)
if (excludeRegex) result.excludeRegex = excludeRegex
const maxFileLimit = this.parseNumber(config.maxFileLimit)
if (typeof maxFileLimit === 'number') result.maxFileLimit = maxFileLimit
return result
}
case 'eagle': {
const libraryPath = this.requireString(
config.libraryPath,
'Active Eagle storage provider is missing `libraryPath`. Provide the path to your Eagle library.',
)
const result: EagleConfig = {
provider: 'eagle',
libraryPath,
}
const distPath = this.normalizeString(config.distPath)
if (distPath) result.distPath = distPath
const baseUrl = this.normalizeString(config.baseUrl)
if (baseUrl) result.baseUrl = baseUrl
const includeRules = this.parseJsonArray<EagleRule>(config.include)
if (includeRules) result.include = includeRules
const excludeRules = this.parseJsonArray<EagleRule>(config.exclude)
if (excludeRules) result.exclude = excludeRules
return result
}
default: {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: `Unsupported storage provider type: ${provider.type}`,
})
}
}
}
private normalizeString(value?: string | null): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const normalized = value.trim()
return normalized.length > 0 ? normalized : undefined
}
private parseNumber(value?: string | null): number | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : undefined
}
private parseBoolean(value?: string | null): boolean | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
const lowered = normalized.toLowerCase()
if (['true', '1', 'yes', 'y', 'on'].includes(lowered)) {
return true
}
if (['false', '0', 'no', 'n', 'off'].includes(lowered)) {
return false
}
return undefined
}
private parseRetryMode(value?: string | null): S3Config['retryMode'] | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
if (normalized === 'standard' || normalized === 'adaptive' || normalized === 'legacy') {
return normalized
}
return undefined
}
private parseJsonArray<T>(value?: string | null): T[] | undefined {
const normalized = this.normalizeString(value)
if (!normalized) {
return undefined
}
try {
const parsed = JSON.parse(normalized)
if (Array.isArray(parsed)) {
return parsed as T[]
}
} catch {
/* ignore parse errors */
}
return undefined
}
private requireString(value: string | undefined | null, message: string): string {
const normalized = this.normalizeString(value)
if (!normalized) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message })
}
return normalized
}
}

View File

@@ -0,0 +1,83 @@
import { Body, ContextParam, Controller, Get, Post, Query } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { Roles } from 'core/guards/roles.decorator'
import type { Context } from 'hono'
import type { File } from 'undici'
import type { PhotoAssetListItem, PhotoAssetSummary } from './photo-asset.service'
import { PhotoAssetService } from './photo-asset.service'
type DeleteAssetsDto = {
ids: string[]
}
@Controller('photos')
@Roles('admin')
export class PhotoController {
constructor(private readonly photoAssetService: PhotoAssetService) {}
@Get('assets')
async listAssets(): Promise<PhotoAssetListItem[]> {
return await this.photoAssetService.listAssets()
}
@Get('assets/summary')
async getSummary(): Promise<PhotoAssetSummary> {
return await this.photoAssetService.getSummary()
}
@Post('assets/delete')
async deleteAssets(@Body() body: DeleteAssetsDto) {
const ids = Array.isArray(body?.ids) ? body.ids : []
await this.photoAssetService.deleteAssets(ids)
return { ids, deleted: true }
}
@Post('assets/upload')
async uploadAssets(@ContextParam() context: Context) {
const payload = await context.req.parseBody()
const directory = typeof payload.directory === 'string' ? payload.directory : null
const files: File[] = []
for (const value of Object.values(payload)) {
if (value instanceof File) {
files.push(value)
} else if (Array.isArray(value)) {
for (const entry of value) {
if (entry instanceof File) {
files.push(entry)
}
}
}
}
if (files.length === 0) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '未找到可上传的文件',
})
}
const inputs = await Promise.all(
files.map(async (file) => ({
filename: file.name,
buffer: Buffer.from(await file.arrayBuffer()),
contentType: file.type || undefined,
directory,
})),
)
const assets = await this.photoAssetService.uploadAssets(inputs)
return { assets }
}
@Get('storage-url')
async getStorageUrl(@Query() query: { key?: string }) {
const key = query?.key?.trim()
if (!key) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 storage key 参数' })
}
const url = await this.photoAssetService.generatePublicUrl(key)
return { url }
}
}

View File

@@ -1,8 +1,12 @@
import { Module } from '@afilmory/framework'
import { PhotoController } from './photo.controller'
import { PhotoBuilderService } from './photo.service'
import { PhotoAssetService } from './photo-asset.service'
import { PhotoStorageService } from './photo-storage.service'
@Module({
providers: [PhotoBuilderService],
controllers: [PhotoController],
providers: [PhotoBuilderService, PhotoStorageService, PhotoAssetService],
})
export class PhotoModule {}

View File

@@ -0,0 +1,77 @@
import { clsxm } from '@afilmory/utils'
import type { MouseEventHandler, ReactNode } from 'react'
import { NavLink } from 'react-router'
type PageTabItem = {
id: string
label: ReactNode
to?: string
end?: boolean
onSelect?: () => void
disabled?: boolean
}
export interface PageTabsProps {
items: PageTabItem[]
activeId?: string
onSelect?: (id: string) => void
className?: string
}
export const PageTabs = ({
items,
activeId,
onSelect,
className,
}: PageTabsProps) => {
const renderTabContent = (selected: boolean, label: ReactNode) => (
<span
className={clsxm(
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
selected
? 'bg-accent/15 text-accent'
: 'bg-fill/10 text-text-secondary hover:bg-fill/20 hover:text-text',
)}
>
{label}
</span>
)
return (
<div className={clsxm('flex flex-wrap items-center gap-2', className)}>
{items.map((item) => {
if (item.to) {
return (
<NavLink key={item.id} to={item.to} end={item.end}>
{({ isActive }) => {
const selected = isActive || activeId === item.id
return renderTabContent(selected, item.label)
}}
</NavLink>
)
}
const handleClick: MouseEventHandler<HTMLButtonElement> = (event) => {
event.preventDefault()
if (item.disabled) return
onSelect?.(item.id)
item.onSelect?.()
}
const selected = activeId === item.id
return (
<button
key={item.id}
type="button"
onClick={handleClick}
disabled={item.disabled}
className="focus-visible:outline-none"
>
{renderTabContent(selected, item.label)}
</button>
)
})}
</div>
)
}

View File

@@ -1,10 +1,68 @@
import { coreApi } from '~/lib/api-client'
import type { PhotoSyncResult, RunPhotoSyncPayload } from './types'
import type {
PhotoAssetListItem,
PhotoAssetSummary,
PhotoSyncResult,
RunPhotoSyncPayload,
} from './types'
export const runPhotoSync = async (payload: RunPhotoSyncPayload): Promise<PhotoSyncResult> => {
export const runPhotoSync = async (
payload: RunPhotoSyncPayload,
): Promise<PhotoSyncResult> => {
return await coreApi<PhotoSyncResult>('/data-sync/run', {
method: 'POST',
body: { dryRun: payload.dryRun ?? false },
})
}
export const listPhotoAssets = async (): Promise<PhotoAssetListItem[]> => {
return await coreApi<PhotoAssetListItem[]>('/photos/assets')
}
export const getPhotoAssetSummary = async (): Promise<PhotoAssetSummary> => {
return await coreApi<PhotoAssetSummary>('/photos/assets/summary')
}
export const deletePhotoAssets = async (ids: string[]): Promise<void> => {
await coreApi('/photos/assets/delete', {
method: 'POST',
body: { ids },
})
}
export const uploadPhotoAssets = async (
files: File[],
options?: { directory?: string },
): Promise<PhotoAssetListItem[]> => {
const formData = new FormData()
if (options?.directory) {
formData.append('directory', options.directory)
}
for (const file of files) {
formData.append('files', file)
}
const response = await coreApi<{ assets: PhotoAssetListItem[] }>(
'/photos/assets/upload',
{
method: 'POST',
body: formData,
},
)
return response.assets
}
export const getPhotoStorageUrl = async (
storageKey: string,
): Promise<string> => {
const result = await coreApi<{ url: string }>('/photos/storage-url', {
method: 'GET',
query: { key: storageKey },
})
return result.url
}

View File

@@ -0,0 +1,168 @@
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { MainPageLayout } from '~/components/layouts/MainPageLayout'
import { PageTabs } from '~/components/navigation/PageTabs'
import { getPhotoStorageUrl } from '../api'
import {
useDeletePhotoAssetsMutation,
usePhotoAssetListQuery,
usePhotoAssetSummaryQuery,
useUploadPhotoAssetsMutation,
} from '../hooks'
import type { PhotoAssetListItem, PhotoSyncResult } from '../types'
import { PhotoLibraryActionBar } from './library/PhotoLibraryActionBar'
import { PhotoLibraryGrid } from './library/PhotoLibraryGrid'
import { PhotoSyncActions } from './sync/PhotoSyncActions'
import { PhotoSyncResultPanel } from './sync/PhotoSyncResultPanel'
type PhotoPageTab = 'sync' | 'library'
export const PhotoPage = () => {
const [activeTab, setActiveTab] = useState<PhotoPageTab>('sync')
const [result, setResult] = useState<PhotoSyncResult | null>(null)
const [lastWasDryRun, setLastWasDryRun] = useState<boolean | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const summaryQuery = usePhotoAssetSummaryQuery()
const listQuery = usePhotoAssetListQuery({ enabled: activeTab === 'library' })
const deleteMutation = useDeletePhotoAssetsMutation()
const uploadMutation = useUploadPhotoAssetsMutation()
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
const isListLoading = listQuery.isLoading || listQuery.isFetching
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
if (prev.includes(id)) {
return prev.filter((item) => item !== id)
}
return [...prev, id]
})
}
const handleClearSelection = () => {
setSelectedIds([])
}
const handleDeleteAssets = async (ids: string[]) => {
if (ids.length === 0) return
try {
await deleteMutation.mutateAsync(ids)
toast.success(`已删除 ${ids.length} 个资源`)
setSelectedIds((prev) => prev.filter((item) => !ids.includes(item)))
void listQuery.refetch()
} catch (error) {
const message =
error instanceof Error ? error.message : '删除失败,请稍后重试。'
toast.error('删除失败', { description: message })
}
}
const handleUploadAssets = async (files: FileList) => {
const fileArray = Array.from(files)
if (fileArray.length === 0) return
try {
await uploadMutation.mutateAsync(fileArray)
toast.success(`成功上传 ${fileArray.length} 张图片`)
void listQuery.refetch()
} catch (error) {
const message =
error instanceof Error ? error.message : '上传失败,请稍后重试。'
toast.error('上传失败', { description: message })
}
}
const handleOpenAsset = async (asset: PhotoAssetListItem) => {
const manifest = asset.manifest?.data
const candidate =
manifest?.originalUrl ?? manifest?.thumbnailUrl ?? asset.publicUrl
if (candidate) {
window.open(candidate, '_blank', 'noopener,noreferrer')
return
}
try {
const url = await getPhotoStorageUrl(asset.storageKey)
window.open(url, '_blank', 'noopener,noreferrer')
} catch (error) {
const message =
error instanceof Error ? error.message : '无法获取原图链接'
toast.error('打开失败', { description: message })
}
}
const handleDeleteSingle = (asset: PhotoAssetListItem) => {
void handleDeleteAssets([asset.id])
}
const handleTabChange = (tab: PhotoPageTab) => {
setActiveTab(tab)
if (tab === 'sync') {
setSelectedIds([])
}
}
return (
<MainPageLayout
title="照片库"
description="在此同步和管理服务器中的照片资产。"
>
<MainPageLayout.Actions>
{activeTab === 'sync' ? (
<PhotoSyncActions
onCompleted={(data, context) => {
setResult(data)
setLastWasDryRun(context.dryRun)
void summaryQuery.refetch()
void listQuery.refetch()
}}
/>
) : (
<PhotoLibraryActionBar
selectionCount={selectedIds.length}
isUploading={uploadMutation.isPending}
isDeleting={deleteMutation.isPending}
onUpload={handleUploadAssets}
onDeleteSelected={() => {
void handleDeleteAssets(selectedIds)
}}
onClearSelection={handleClearSelection}
/>
)}
</MainPageLayout.Actions>
<div className="space-y-6">
<PageTabs
activeId={activeTab}
onSelect={(id) => handleTabChange(id as PhotoPageTab)}
items={[
{ id: 'sync', label: '同步结果' },
{ id: 'library', label: '图库管理' },
]}
/>
{activeTab === 'sync' ? (
<PhotoSyncResultPanel
result={result}
lastWasDryRun={lastWasDryRun}
baselineSummary={summaryQuery.data}
isSummaryLoading={summaryQuery.isLoading}
onRequestStorageUrl={getPhotoStorageUrl}
/>
) : (
<PhotoLibraryGrid
assets={listQuery.data}
isLoading={isListLoading}
selectedIds={selectedSet}
onToggleSelect={handleToggleSelect}
onOpenAsset={handleOpenAsset}
onDeleteAsset={handleDeleteSingle}
isDeleting={deleteMutation.isPending}
/>
)}
</div>
</MainPageLayout>
)
}

View File

@@ -0,0 +1,95 @@
import { Button } from '@afilmory/ui'
import { clsxm } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import type { ChangeEventHandler } from 'react'
import { useRef } from 'react'
type PhotoLibraryActionBarProps = {
selectionCount: number
isUploading: boolean
isDeleting: boolean
onUpload: (files: FileList) => void | Promise<void>
onDeleteSelected: () => void
onClearSelection: () => void
}
export const PhotoLibraryActionBar = ({
selectionCount,
isUploading,
isDeleting,
onUpload,
onDeleteSelected,
onClearSelection,
}: PhotoLibraryActionBarProps) => {
const fileInputRef = useRef<HTMLInputElement | null>(null)
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const { files } = event.currentTarget
if (!files || files.length === 0) return
void onUpload(files)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
return (
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
accept="image/*"
onChange={handleFileChange}
/>
<Button
type="button"
variant="primary"
size="sm"
disabled={isUploading}
onClick={handleUploadClick}
className="flex items-center gap-1"
>
<DynamicIcon name="upload" className="h-3.5 w-3.5" />
</Button>
{selectionCount > 0 ? (
<div className="flex items-center gap-2">
<span
className={clsxm(
'inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium',
'bg-accent/10 text-accent',
)}
>
{selectionCount}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isDeleting}
onClick={onDeleteSelected}
className="flex items-center gap-1 text-rose-400 hover:text-rose-300"
>
<DynamicIcon name="trash-2" className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClearSelection}
>
<DynamicIcon name="x" className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,158 @@
import { Button, Skeleton } from '@afilmory/ui'
import { clsxm } from '@afilmory/utils'
import { DynamicIcon } from 'lucide-react/dynamic'
import type { PhotoAssetListItem } from '../../types'
type PhotoLibraryGridProps = {
assets: PhotoAssetListItem[] | undefined
isLoading: boolean
selectedIds: Set<string>
onToggleSelect: (id: string) => void
onOpenAsset: (asset: PhotoAssetListItem) => void
onDeleteAsset: (asset: PhotoAssetListItem) => void
isDeleting?: boolean
}
export const PhotoLibraryGrid = ({
assets,
isLoading,
selectedIds,
onToggleSelect,
onOpenAsset,
onDeleteAsset,
isDeleting,
}: PhotoLibraryGridProps) => {
if (isLoading) {
const skeletonKeys = [
'photo-skeleton-1',
'photo-skeleton-2',
'photo-skeleton-3',
'photo-skeleton-4',
'photo-skeleton-5',
'photo-skeleton-6',
]
return (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{skeletonKeys.map((key) => (
<div key={key} className="mb-4 break-inside-avoid">
<Skeleton className="h-48 w-full rounded-xl" />
</div>
))}
</div>
)
}
if (!assets || assets.length === 0) {
return (
<div className="bg-background-tertiary relative overflow-hidden rounded-xl p-8 text-center">
<p className="text-text text-base font-semibold"></p>
<p className="text-text-tertiary mt-2 text-sm">
使
</p>
</div>
)
}
return (
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{assets.map((asset) => {
const manifest = asset.manifest?.data
const previewUrl =
manifest?.thumbnailUrl ?? manifest?.originalUrl ?? asset.publicUrl
const isSelected = selectedIds.has(asset.id)
const deviceLabel =
manifest?.exif?.Model || manifest?.exif?.Make || '未知设备'
const updatedAtLabel = new Date(asset.updatedAt).toLocaleString()
const fileSizeLabel =
asset.size !== null && asset.size !== undefined
? `${(asset.size / (1024 * 1024)).toFixed(2)} MB`
: manifest?.size
? `${(manifest.size / (1024 * 1024)).toFixed(2)} MB`
: '未知大小'
return (
<div key={asset.id} className="mb-4 break-inside-avoid">
<div
className={clsxm(
'relative group overflow-hidden rounded-xl border border-border/10 bg-background-secondary/40 shadow-sm transition-all duration-200',
isSelected && 'ring-2 ring-accent/80',
)}
>
{previewUrl ? (
<img
src={previewUrl}
alt={manifest?.id ?? asset.photoId}
className="h-auto w-full object-cover"
/>
) : (
<div className="bg-background-secondary/80 text-text-tertiary flex h-48 items-center justify-center">
</div>
)}
<div className="bg-background/5 absolute inset-0 flex flex-col justify-between opacity-0 backdrop-blur-sm transition-opacity duration-200 group-hover:opacity-100">
<div className="flex items-start justify-between p-3 text-xs text-white">
<div className="max-w-[70%] truncate font-medium">
{manifest?.title ?? manifest?.id ?? asset.photoId}
</div>
<button
type="button"
className={clsxm(
'inline-flex items-center rounded-full border border-white/30 bg-black/40 px-2 py-1 text-[10px] uppercase tracking-wide text-white transition-colors',
isSelected ? 'bg-accent text-white' : 'hover:bg-white/10',
)}
onClick={() => onToggleSelect(asset.id)}
>
<DynamicIcon
name={isSelected ? 'check' : 'square'}
className="mr-1 h-3 w-3"
/>
<span>{isSelected ? '已选择' : '选择'}</span>
</button>
</div>
<div className="flex items-center justify-between gap-2 p-3">
<div className="flex flex-col text-[10px] text-white/80">
<span>{deviceLabel}</span>
<span>{updatedAtLabel}</span>
<span>{fileSizeLabel}</span>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="xs"
className="bg-black/40 text-white hover:bg-black/60"
onClick={() => onOpenAsset(asset)}
>
<DynamicIcon
name="external-link"
className="mr-1 h-3.5 w-3.5"
/>
<span></span>
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="bg-rose-500/20 text-rose-50 hover:bg-rose-500/30"
disabled={isDeleting}
onClick={() => onDeleteAsset(asset)}
>
<DynamicIcon
name="trash-2"
className="mr-1 h-3.5 w-3.5"
/>
<span></span>
</Button>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { Button } from '@afilmory/ui'
import { useMutation } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useMainPageLayout } from '~/components/layouts/MainPageLayout'
import { runPhotoSync } from '../../api'
import type { PhotoSyncResult, RunPhotoSyncPayload } from '../../types'
type PhotoSyncActionsProps = {
onCompleted: (result: PhotoSyncResult, context: { dryRun: boolean }) => void
}
export const PhotoSyncActions = ({ onCompleted }: PhotoSyncActionsProps) => {
const { setHeaderActionState } = useMainPageLayout()
const [pendingMode, setPendingMode] = useState<'dry-run' | 'apply' | null>(
null,
)
const mutation = useMutation({
mutationFn: async (variables: RunPhotoSyncPayload) => {
return await runPhotoSync({ dryRun: variables.dryRun ?? false })
},
onMutate: (variables) => {
setPendingMode(variables.dryRun ? 'dry-run' : 'apply')
setHeaderActionState({ disabled: true, loading: true })
},
onSuccess: (data, variables) => {
onCompleted(data, { dryRun: variables.dryRun ?? false })
const { inserted, updated, conflicts } = data.summary
toast.success(variables.dryRun ? '同步预览完成' : '照片同步完成', {
description: `新增 ${inserted} · 更新 ${updated} · 冲突 ${conflicts}`,
})
},
onError: (error) => {
const message =
error instanceof Error ? error.message : '照片同步失败,请稍后重试。'
toast.error('同步失败', { description: message })
},
onSettled: () => {
setPendingMode(null)
setHeaderActionState({ disabled: false, loading: false })
},
})
const { isPending } = mutation
useEffect(() => {
return () => {
setHeaderActionState({ disabled: false, loading: false })
}
}, [setHeaderActionState])
const handleSync = (dryRun: boolean) => {
mutation.mutate({ dryRun })
}
return (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}
isLoading={isPending && pendingMode === 'dry-run'}
onClick={() => handleSync(true)}
>
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={isPending}
isLoading={isPending && pendingMode === 'apply'}
onClick={() => handleSync(false)}
>
</Button>
</div>
)
}

View File

@@ -0,0 +1,439 @@
import { Button, Skeleton } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { m } from 'motion/react'
import { useMemo } from 'react'
import type {
PhotoAssetSummary,
PhotoSyncAction,
PhotoSyncResult,
PhotoSyncSnapshot,
} from '../../types'
const BorderOverlay = () => (
<>
<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" />
</>
)
type SummaryCardProps = {
label: string
value: number
tone?: 'accent' | 'warning' | 'muted'
}
const SummaryCard = ({ label, value, tone }: SummaryCardProps) => {
const toneClass =
tone === 'accent'
? 'text-accent'
: tone === 'warning'
? 'text-amber-400'
: tone === 'muted'
? 'text-text-secondary'
: 'text-text'
return (
<div className="bg-background-tertiary relative overflow-hidden rounded-lg p-5">
<BorderOverlay />
<p className="text-text-tertiary text-xs tracking-wide uppercase">
{label}
</p>
<p className={`mt-2 text-2xl font-semibold ${toneClass}`}>{value}</p>
</div>
)
}
type PhotoSyncResultPanelProps = {
result: PhotoSyncResult | null
lastWasDryRun: boolean | null
baselineSummary?: PhotoAssetSummary | null
isSummaryLoading?: boolean
onRequestStorageUrl?: (storageKey: string) => Promise<string>
}
const actionTypeConfig: Record<
PhotoSyncAction['type'],
{ label: string; badgeClass: string }
> = {
insert: { label: '新增', badgeClass: 'bg-emerald-500/10 text-emerald-400' },
update: { label: '更新', badgeClass: 'bg-sky-500/10 text-sky-400' },
delete: { label: '删除', badgeClass: 'bg-rose-500/10 text-rose-400' },
conflict: { label: '冲突', badgeClass: 'bg-amber-500/10 text-amber-400' },
noop: { label: '跳过', badgeClass: 'bg-slate-500/10 text-slate-400' },
}
const SUMMARY_SKELETON_KEYS = [
'summary-skeleton-1',
'summary-skeleton-2',
'summary-skeleton-3',
'summary-skeleton-4',
]
export const PhotoSyncResultPanel = ({
result,
lastWasDryRun,
baselineSummary,
isSummaryLoading,
onRequestStorageUrl,
}: PhotoSyncResultPanelProps) => {
const summaryItems = useMemo(() => {
if (result) {
return [
{ label: '存储对象', value: result.summary.storageObjects },
{ label: '数据库记录', value: result.summary.databaseRecords },
{
label: '新增照片',
value: result.summary.inserted,
tone: 'accent' as const,
},
{ label: '更新记录', value: result.summary.updated },
{ label: '删除记录', value: result.summary.deleted },
{
label: '冲突条目',
value: result.summary.conflicts,
tone:
result.summary.conflicts > 0
? ('warning' as const)
: ('muted' as const),
},
{
label: '跳过条目',
value: result.summary.skipped,
tone: 'muted' as const,
},
]
}
if (baselineSummary) {
return [
{ label: '数据库记录', value: baselineSummary.total },
{ label: '同步完成', value: baselineSummary.synced },
{
label: '冲突条目',
value: baselineSummary.conflicts,
tone:
baselineSummary.conflicts > 0
? ('warning' as const)
: ('muted' as const),
},
{
label: '待处理',
value: baselineSummary.pending,
tone:
baselineSummary.pending > 0
? ('accent' as const)
: ('muted' as const),
},
]
}
return []
}, [result, baselineSummary])
const renderManifestMetadata = (
manifest: PhotoSyncAction['manifestAfter'],
) => {
if (!manifest) return null
const dimensions = `${manifest.width} × ${manifest.height}`
const sizeMB =
typeof manifest.size === 'number' && manifest.size > 0
? `${(manifest.size / (1024 * 1024)).toFixed(2)} MB`
: '未知'
return (
<dl className="text-text-tertiary mt-2 space-y-1 text-xs">
<div className="flex items-center justify-between gap-4">
<dt> ID</dt>
<dd className="text-text truncate text-right">{manifest.id}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right">{dimensions}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right">{sizeMB}</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right">
{new Date(manifest.lastModified).toLocaleString()}
</dd>
</div>
</dl>
)
}
const handleOpenOriginal = async (action: PhotoSyncAction) => {
const manifest = action.manifestAfter ?? action.manifestBefore
if (!manifest) return
const candidate = manifest.originalUrl ?? manifest.thumbnailUrl
if (candidate) {
window.open(candidate, '_blank', 'noopener,noreferrer')
return
}
if (!onRequestStorageUrl) return
try {
const url = await onRequestStorageUrl(action.storageKey)
window.open(url, '_blank', 'noopener,noreferrer')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
window.alert(`无法打开原图:${message}`)
}
}
const renderActionDetails = (action: PhotoSyncAction) => {
const config = actionTypeConfig[action.type]
const resolutionLabel =
action.resolution === 'prefer-storage'
? '以存储为准'
: action.resolution === 'prefer-database'
? '以数据库为准'
: null
const beforeManifest = action.manifestBefore
const afterManifest = action.manifestAfter
return (
<div className="bg-fill/10 relative overflow-hidden rounded-lg p-4">
<BorderOverlay />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ${config.badgeClass}`}
>
{config.label}
</span>
<code className="text-text-secondary text-xs">
{action.storageKey}
</code>
</div>
<span className="text-text-tertiary inline-flex items-center gap-1 text-xs">
<span>{action.applied ? '已应用' : '未应用'}</span>
{resolutionLabel ? <span>· {resolutionLabel}</span> : null}
</span>
</div>
{action.reason ? (
<p className="text-text-tertiary mt-2 text-sm">{action.reason}</p>
) : null}
{(beforeManifest || afterManifest || action.snapshots) && (
<div className="mt-4 grid gap-4 md:grid-cols-2">
{beforeManifest ? (
<div className="bg-background-secondary/60 border-border/20 rounded-lg border p-3">
<p className="text-text text-sm font-semibold"></p>
{beforeManifest.thumbnailUrl ? (
<img
src={beforeManifest.thumbnailUrl}
alt={beforeManifest.id}
className="mt-2 aspect-[4/3] w-full rounded-md object-cover"
/>
) : null}
{renderManifestMetadata(beforeManifest)}
</div>
) : null}
{afterManifest ? (
<div className="bg-background-secondary/60 border-border/20 rounded-lg border p-3">
<p className="text-text text-sm font-semibold"></p>
{afterManifest.thumbnailUrl ? (
<img
src={afterManifest.thumbnailUrl}
alt={afterManifest.id}
className="mt-2 aspect-[4/3] w-full rounded-md object-cover"
/>
) : null}
{renderManifestMetadata(afterManifest)}
<Button
type="button"
variant="ghost"
size="xs"
className="mt-3"
onClick={() => handleOpenOriginal(action)}
>
</Button>
</div>
) : null}
</div>
)}
{action.snapshots ? (
<div className="text-text-tertiary mt-4 grid gap-4 text-xs md:grid-cols-2">
{action.snapshots.before ? (
<div>
<p className="text-text font-semibold"></p>
<MetadataSnapshot snapshot={action.snapshots.before} />
</div>
) : null}
{action.snapshots.after ? (
<div>
<p className="text-text font-semibold"></p>
<MetadataSnapshot snapshot={action.snapshots.after} />
</div>
) : null}
</div>
) : null}
</div>
)
}
if (!result) {
return (
<div className="bg-background-tertiary relative overflow-hidden rounded-lg p-6">
<BorderOverlay />
<div className="space-y-4">
<div className="space-y-2">
<h2 className="text-text text-base font-semibold"></h2>
<p className="text-text-tertiary text-sm">
使
</p>
</div>
{isSummaryLoading ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{SUMMARY_SKELETON_KEYS.map((key) => (
<Skeleton key={key} className="h-24 rounded-lg" />
))}
</div>
) : summaryItems.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryItems.map((item) => (
<SummaryCard
key={item.label}
label={item.label}
value={item.value}
tone={item.tone}
/>
))}
</div>
) : null}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm">
{lastWasDryRun === null
? '以下为最新同步结果。'
: lastWasDryRun
? '最近执行了预览模式,数据库未发生变更。'
: '最近一次同步结果已写入数据库。'}
</p>
</div>
<p className="text-text-tertiary text-xs">
{result.actions.length}
</p>
</div>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-4">
{summaryItems.map((item, index) => (
<m.div
key={item.label}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.04 }}
>
<SummaryCard
label={item.label}
value={item.value}
tone={item.tone}
/>
</m.div>
))}
</div>
<div className="bg-background-tertiary relative overflow-hidden rounded-lg">
<BorderOverlay />
<div className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-text text-base font-semibold"></h3>
<span className="text-text-tertiary text-xs">
{lastWasDryRun
? '预览模式 · 未应用变更'
: '实时模式 · 结果已写入'}
</span>
</div>
{result.actions.length === 0 ? (
<p className="text-text-tertiary mt-4 text-sm">
</p>
) : (
<div className="mt-4 space-y-3">
{result.actions.slice(0, 20).map((action, index) => {
const actionKey = `${action.storageKey}-${action.type}-${action.photoId ?? 'none'}-${action.manifestAfter?.id ?? action.manifestBefore?.id ?? 'unknown'}`
return (
<m.div
key={actionKey}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
...Spring.presets.snappy,
delay: index * 0.03,
}}
>
{renderActionDetails(action)}
</m.div>
)
})}
{result.actions.length > 20 ? (
<p className="text-text-tertiary text-xs">
20 使 API
</p>
) : null}
</div>
)}
</div>
</div>
</div>
)
}
type MetadataSnapshotProps = {
snapshot: PhotoSyncSnapshot | null | undefined
}
const MetadataSnapshot = ({ snapshot }: MetadataSnapshotProps) => {
if (!snapshot) return null
return (
<dl className="mt-2 space-y-1">
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right">
{snapshot.size !== null
? `${(snapshot.size / 1024 / 1024).toFixed(2)} MB`
: '未知'}
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt>ETag</dt>
<dd className="text-text text-right font-mono text-[10px]">
{snapshot.etag ?? '未知'}
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right">
{snapshot.lastModified ?? '未知'}
</dd>
</div>
<div className="flex items-center justify-between gap-4">
<dt></dt>
<dd className="text-text text-right font-mono text-[10px]">
{snapshot.metadataHash ?? '无'}
</dd>
</div>
</dl>
)
}

View File

@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
deletePhotoAssets,
getPhotoAssetSummary,
listPhotoAssets,
uploadPhotoAssets,
} from './api'
import type { PhotoAssetListItem } from './types'
export const PHOTO_ASSET_SUMMARY_QUERY_KEY = [
'photo-assets',
'summary',
] as const
export const PHOTO_ASSET_LIST_QUERY_KEY = ['photo-assets', 'list'] as const
export const usePhotoAssetSummaryQuery = () => {
return useQuery({
queryKey: PHOTO_ASSET_SUMMARY_QUERY_KEY,
queryFn: getPhotoAssetSummary,
})
}
export const usePhotoAssetListQuery = (options?: { enabled?: boolean }) => {
return useQuery({
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
queryFn: listPhotoAssets,
enabled: options?.enabled ?? true,
})
}
export const useDeletePhotoAssetsMutation = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (ids: string[]) => {
await deletePhotoAssets(ids)
},
onSuccess: (_, ids) => {
void queryClient.invalidateQueries({
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
})
void queryClient.invalidateQueries({
queryKey: PHOTO_ASSET_SUMMARY_QUERY_KEY,
})
// Optimistically remove deleted ids from cache if available
queryClient.setQueryData<PhotoAssetListItem[] | undefined>(
PHOTO_ASSET_LIST_QUERY_KEY,
(previous) => {
if (!previous) return previous
const idSet = new Set(ids)
return previous.filter((item) => !idSet.has(item.id))
},
)
},
})
}
export const useUploadPhotoAssetsMutation = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (files: File[]) => {
return await uploadPhotoAssets(files)
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: PHOTO_ASSET_LIST_QUERY_KEY,
})
void queryClient.invalidateQueries({
queryKey: PHOTO_ASSET_SUMMARY_QUERY_KEY,
})
},
})
}

View File

@@ -1,2 +1,8 @@
export * from './api'
export * from './components/library/PhotoLibraryActionBar'
export * from './components/library/PhotoLibraryGrid'
export * from './components/PhotoPage'
export * from './components/sync/PhotoSyncActions'
export * from './components/sync/PhotoSyncResultPanel'
export * from './hooks'
export * from './types'

View File

@@ -1,6 +1,16 @@
export type PhotoSyncActionType = 'insert' | 'update' | 'delete' | 'conflict' | 'noop'
import type { PhotoManifestItem } from '@afilmory/builder'
export type PhotoSyncResolution = 'prefer-storage' | 'prefer-database' | undefined
export type PhotoSyncActionType =
| 'insert'
| 'update'
| 'delete'
| 'conflict'
| 'noop'
export type PhotoSyncResolution =
| 'prefer-storage'
| 'prefer-database'
| undefined
export interface PhotoSyncSnapshot {
size: number | null
@@ -20,6 +30,8 @@ export interface PhotoSyncAction {
before?: PhotoSyncSnapshot | null
after?: PhotoSyncSnapshot | null
}
manifestBefore?: PhotoManifestItem | null
manifestAfter?: PhotoManifestItem | null
}
export interface PhotoSyncResultSummary {
@@ -40,3 +52,29 @@ export interface PhotoSyncResult {
export interface RunPhotoSyncPayload {
dryRun?: boolean
}
export interface PhotoAssetManifestPayload {
version: string
data: PhotoManifestItem
}
export interface PhotoAssetListItem {
id: string
photoId: string
storageKey: string
storageProvider: string
manifest: PhotoAssetManifestPayload
syncedAt: string
updatedAt: string
createdAt: string
publicUrl: string | null
size: number | null
syncStatus: 'pending' | 'synced' | 'conflict'
}
export interface PhotoAssetSummary {
total: number
synced: number
conflicts: number
pending: number
}

View File

@@ -1,5 +1,4 @@
import { clsxm } from '@afilmory/utils'
import { NavLink } from 'react-router'
import { PageTabs } from '~/components/navigation/PageTabs'
const SETTINGS_TABS = [
{
@@ -16,32 +15,20 @@ const SETTINGS_TABS = [
},
] as const
interface SettingsNavigationProps {
type SettingsNavigationProps = {
active: (typeof SETTINGS_TABS)[number]['id']
}
export const SettingsNavigation = ({ active }: SettingsNavigationProps) => {
return (
<div className="flex flex-wrap items-center gap-2">
{SETTINGS_TABS.map((tab) => (
<NavLink key={tab.id} to={tab.path} end={tab.end}>
{({ isActive }) => {
const selected = isActive || active === tab.id
return (
<span
className={clsxm(
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
selected
? 'bg-accent/15 text-accent'
: 'bg-fill/10 text-text-secondary hover:bg-fill/20 hover:text-text',
)}
>
{tab.label}
</span>
)
}}
</NavLink>
))}
</div>
<PageTabs
activeId={active}
items={SETTINGS_TABS.map((tab) => ({
id: tab.id,
label: tab.label,
to: tab.path,
end: tab.end,
}))}
/>
)
}

View File

@@ -1,322 +1,5 @@
import { Button } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { useMutation } from '@tanstack/react-query'
import { m } from 'motion/react'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
MainPageLayout,
useMainPageLayout,
} from '~/components/layouts/MainPageLayout'
import type { PhotoSyncAction, PhotoSyncResult } from '~/modules/photos'
import { runPhotoSync } from '~/modules/photos'
type PhotoSyncActionsProps = {
onCompleted: (result: PhotoSyncResult, context: { dryRun: boolean }) => void
}
const PhotoSyncActions = ({ onCompleted }: PhotoSyncActionsProps) => {
const { setHeaderActionState } = useMainPageLayout()
const [pendingMode, setPendingMode] = useState<'dry-run' | 'apply' | null>(
null,
)
const mutation = useMutation({
mutationFn: async (variables: { dryRun: boolean }) => {
return await runPhotoSync({ dryRun: variables.dryRun })
},
onMutate: (variables) => {
setPendingMode(variables.dryRun ? 'dry-run' : 'apply')
setHeaderActionState({ disabled: true, loading: true })
},
onSuccess: (data, variables) => {
onCompleted(data, { dryRun: variables.dryRun })
const { inserted, updated, conflicts } = data.summary
toast.success(variables.dryRun ? '同步预览完成' : '照片同步完成', {
description: `新增 ${inserted} · 更新 ${updated} · 冲突 ${conflicts}`,
})
},
onError: (error) => {
const message =
error instanceof Error ? error.message : '照片同步失败,请稍后重试。'
toast.error('同步失败', { description: message })
},
onSettled: () => {
setPendingMode(null)
setHeaderActionState({ disabled: false, loading: false })
},
})
useEffect(() => {
return () => {
setHeaderActionState({ disabled: false, loading: false })
}
}, [setHeaderActionState])
const { isPending } = mutation
const handleSync = (dryRun: boolean) => {
mutation.mutate({ dryRun })
}
return (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending}
isLoading={isPending && pendingMode === 'dry-run'}
onClick={() => handleSync(true)}
>
</Button>
<Button
type="button"
variant="primary"
size="sm"
disabled={isPending}
isLoading={isPending && pendingMode === 'apply'}
onClick={() => handleSync(false)}
>
</Button>
</div>
)
}
type SummaryCardProps = {
label: string
value: number
tone?: 'accent' | 'warning' | 'muted'
}
const BorderOverlay = () => (
<>
<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" />
</>
)
const SummaryCard = ({ label, value, tone }: SummaryCardProps) => {
const toneClass =
tone === 'accent'
? 'text-accent'
: tone === 'warning'
? 'text-amber-400'
: tone === 'muted'
? 'text-text-secondary'
: 'text-text'
return (
<div className="bg-background-tertiary relative overflow-hidden rounded-lg p-5">
<BorderOverlay />
<p className="text-text-tertiary text-xs uppercase tracking-wide">
{label}
</p>
<p className={`mt-2 text-2xl font-semibold ${toneClass}`}>{value}</p>
</div>
)
}
const actionTypeConfig: Record<
PhotoSyncAction['type'],
{ label: string; badgeClass: string }
> = {
insert: { label: '新增', badgeClass: 'bg-emerald-500/10 text-emerald-400' },
update: { label: '更新', badgeClass: 'bg-sky-500/10 text-sky-400' },
delete: { label: '删除', badgeClass: 'bg-rose-500/10 text-rose-400' },
conflict: { label: '冲突', badgeClass: 'bg-amber-500/10 text-amber-400' },
noop: { label: '跳过', badgeClass: 'bg-slate-500/10 text-slate-400' },
}
const ActionRow = ({ action }: { action: PhotoSyncAction }) => {
const config = actionTypeConfig[action.type]
const resolutionLabel =
action.resolution === 'prefer-storage'
? '以存储为准'
: action.resolution === 'prefer-database'
? '以数据库为准'
: null
return (
<div className="bg-fill/10 relative overflow-hidden rounded-lg p-4">
<BorderOverlay />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<span
className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold ${config.badgeClass}`}
>
{config.label}
</span>
<code className="text-text-secondary text-xs">
{action.storageKey}
</code>
</div>
<span className="text-text-tertiary text-xs">
{action.applied ? '已应用' : '未应用'}
{resolutionLabel ? ` · ${resolutionLabel}` : ''}
</span>
</div>
{action.reason ? (
<p className="text-text-tertiary mt-2 text-sm">{action.reason}</p>
) : null}
</div>
)
}
type PhotoSyncResultPanelProps = {
result: PhotoSyncResult | null
lastWasDryRun: boolean | null
}
const PhotoSyncResultPanel = ({
result,
lastWasDryRun,
}: PhotoSyncResultPanelProps) => {
const summaryItems = useMemo(
() =>
result
? [
{ label: '存储对象', value: result.summary.storageObjects },
{ label: '数据库记录', value: result.summary.databaseRecords },
{
label: '新增照片',
value: result.summary.inserted,
tone: 'accent' as const,
},
{ label: '更新记录', value: result.summary.updated },
{ label: '删除记录', value: result.summary.deleted },
{
label: '冲突条目',
value: result.summary.conflicts,
tone:
result.summary.conflicts > 0
? ('warning' as const)
: ('muted' as const),
},
{
label: '跳过条目',
value: result.summary.skipped,
tone: 'muted' as const,
},
]
: [],
[result],
)
if (!result) {
return (
<div className="bg-background-tertiary relative overflow-hidden rounded-lg p-6">
<BorderOverlay />
<div className="space-y-2">
<h2 className="text-text text-base font-semibold"></h2>
<p className="text-text-tertiary text-sm">
使
</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-text text-lg font-semibold"></h2>
<p className="text-text-tertiary text-sm">
{lastWasDryRun === null
? '以下为最新同步结果。'
: lastWasDryRun
? '最近执行了预览模式,数据库未发生变更。'
: '最近一次同步结果已写入数据库。'}
</p>
</div>
<p className="text-text-tertiary text-xs">
{result.actions.length}
</p>
</div>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-4">
{summaryItems.map((item, index) => (
<m.div
key={item.label}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.smooth, delay: index * 0.04 }}
>
<SummaryCard
label={item.label}
value={item.value}
tone={item.tone}
/>
</m.div>
))}
</div>
<div className="bg-background-tertiary relative overflow-hidden rounded-lg">
<BorderOverlay />
<div className="p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-text text-base font-semibold"></h3>
<span className="text-text-tertiary text-xs">
{lastWasDryRun
? '预览模式 · 未应用变更'
: '实时模式 · 结果已写入'}
</span>
</div>
{result.actions.length === 0 ? (
<p className="text-text-tertiary mt-4 text-sm">
</p>
) : (
<div className="mt-4 space-y-3">
{result.actions.slice(0, 20).map((action, index) => (
<m.div
key={`${action.storageKey}-${action.type}-${action.photoId ?? 'none'}-${index}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...Spring.presets.snappy, delay: index * 0.03 }}
>
<ActionRow action={action} />
</m.div>
))}
{result.actions.length > 20 ? (
<p className="text-text-tertiary text-xs">
20 使 API
</p>
) : null}
</div>
)}
</div>
</div>
</div>
)
}
import { PhotoPage } from '~/modules/photos'
export const Component = () => {
const [result, setResult] = useState<PhotoSyncResult | null>(null)
const [lastWasDryRun, setLastWasDryRun] = useState<boolean | null>(null)
return (
<MainPageLayout
title="照片库"
description="从已激活的存储提供商同步照片清单,并解决潜在冲突。"
>
<MainPageLayout.Actions>
<PhotoSyncActions
onCompleted={(data, context) => {
setResult(data)
setLastWasDryRun(context.dryRun)
}}
/>
</MainPageLayout.Actions>
<PhotoSyncResultPanel result={result} lastWasDryRun={lastWasDryRun} />
</MainPageLayout>
)
return <PhotoPage />
}

View File

@@ -1,4 +1,3 @@
// 扫描进度接口
export interface ScanProgress {
currentPath: string
@@ -17,6 +16,10 @@ export interface StorageObject {
etag?: string
}
export interface StorageUploadOptions {
contentType?: string
}
// 存储提供商的通用接口
export interface StorageProvider {
/**
@@ -55,6 +58,23 @@ export interface StorageProvider {
* @returns Live Photo 配对映射 (图片 key -> 视频对象)
*/
detectLivePhotos: (allObjects: StorageObject[]) => Map<string, StorageObject>
/**
* 从存储中删除文件
*/
deleteFile: (key: string) => Promise<void>
/**
* 向存储上传文件
* @param key 文件的键值/路径
* @param data 文件数据
* @param options 上传选项
*/
uploadFile: (
key: string,
data: Buffer,
options?: StorageUploadOptions,
) => Promise<StorageObject>
}
export type S3Config = {

View File

@@ -3,6 +3,7 @@ import type {
StorageConfig,
StorageObject,
StorageProvider,
StorageUploadOptions,
} from './interfaces.js'
export class StorageManager {
@@ -59,6 +60,18 @@ export class StorageManager {
return this.provider.detectLivePhotos(objects)
}
async deleteFile(key: string): Promise<void> {
await this.provider.deleteFile(key)
}
async uploadFile(
key: string,
data: Buffer,
options?: StorageUploadOptions,
): Promise<StorageObject> {
return await this.provider.uploadFile(key, data, options)
}
/**
* 获取当前使用的存储提供商
* @returns 存储提供商实例

View File

@@ -11,6 +11,7 @@ import type {
EagleRule,
StorageObject,
StorageProvider,
StorageUploadOptions,
} from '../interfaces.js'
const EAGLE_VERSION = '4.0.0'
@@ -233,6 +234,18 @@ export class EagleStorageProvider implements StorageProvider {
return filtered
}
async deleteFile(_key: string): Promise<void> {
throw new Error('EagleStorageProvider: 当前不支持删除文件操作')
}
async uploadFile(
_key: string,
_data: Buffer,
_options?: StorageUploadOptions,
): Promise<StorageObject> {
throw new Error('EagleStorageProvider: 当前不支持上传文件操作')
}
async generatePublicUrl(key: string) {
const imageName = await this.copyToDist(key)
const publicPath = path.join(this.config.baseUrl, imageName)

View File

@@ -8,6 +8,7 @@ import type {
ProgressCallback,
StorageObject,
StorageProvider,
StorageUploadOptions,
} from '../interfaces.js'
// GitHub API 响应类型
@@ -86,6 +87,30 @@ export class GitHubStorageProvider implements StorageProvider {
return normalizedKey
}
private async fetchContentMetadata(
key: string,
): Promise<GitHubFileContent | null> {
const fullPath = this.getFullPath(key)
const url = `${this.baseApiUrl}/contents/${fullPath}?ref=${this.githubConfig.branch}`
const response = await fetch(url, {
headers: this.getAuthHeaders(),
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(
`GitHub API 请求失败:${response.status} ${response.statusText}`,
)
}
const data = (await response.json()) as GitHubContent
return data.type === 'file' ? data : null
}
async getFile(key: string): Promise<Buffer | null> {
const logger = getGlobalLoggers().s3
@@ -221,6 +246,81 @@ export class GitHubStorageProvider implements StorageProvider {
}
}
async deleteFile(key: string): Promise<void> {
const metadata = await this.fetchContentMetadata(key)
if (!metadata) {
return
}
const fullPath = this.getFullPath(key)
const url = `${this.baseApiUrl}/contents/${fullPath}`
const body = {
message: `Delete ${fullPath}`,
sha: metadata.sha,
branch: this.githubConfig.branch,
}
const response = await fetch(url, {
method: 'DELETE',
headers: {
...this.getAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(
`GitHub 删除文件失败:${response.status} ${response.statusText}`,
)
}
}
async uploadFile(
key: string,
data: Buffer,
_options?: StorageUploadOptions,
): Promise<StorageObject> {
const metadata = await this.fetchContentMetadata(key)
const fullPath = this.getFullPath(key)
const url = `${this.baseApiUrl}/contents/${fullPath}`
const payload: Record<string, unknown> = {
message: `Upload ${fullPath}`,
content: data.toString('base64'),
branch: this.githubConfig.branch,
}
if (metadata?.sha) {
payload.sha = metadata.sha
}
const response = await fetch(url, {
method: 'PUT',
headers: {
...this.getAuthHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(
`GitHub 上传文件失败:${response.status} ${response.statusText}`,
)
}
const result = (await response.json()) as { content?: GitHubFileContent }
const content = result.content ?? (await this.fetchContentMetadata(key))
return {
key,
size: content?.size ?? data.byteLength,
lastModified: new Date(),
etag: content?.sha,
}
}
generatePublicUrl(key: string): string {
const fullPath = this.getFullPath(key)

View File

@@ -3,14 +3,17 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
CompatibleLoggerAdapter,
} from '@afilmory/builder/photo/logger-adapter.js'
import { CompatibleLoggerAdapter } from '@afilmory/builder/photo/logger-adapter.js'
import consola from 'consola'
import { SUPPORTED_FORMATS } from '../../constants/index.js'
import { logger } from '../../logger/index.js'
import type { LocalConfig, StorageObject, StorageProvider } from '../interfaces'
import type {
LocalConfig,
StorageObject,
StorageProvider,
StorageUploadOptions,
} from '../interfaces'
export interface ScanProgress {
currentPath: string
@@ -80,16 +83,7 @@ export class LocalStorageProvider implements StorageProvider {
this.logger.info(`读取本地文件:${key}`)
const startTime = Date.now()
const filePath = path.join(this.basePath, key)
// 安全检查:确保文件路径在基础路径内
const resolvedPath = path.resolve(filePath)
const resolvedBasePath = path.resolve(this.basePath)
if (!resolvedPath.startsWith(resolvedBasePath)) {
this.logger.error(`文件路径不安全:${key}`)
return null
}
const filePath = this.resolveSafePath(key)
// 检查文件是否存在
try {
@@ -158,6 +152,81 @@ export class LocalStorageProvider implements StorageProvider {
return files
}
private resolveSafePath(key: string): string {
const filePath = path.join(this.basePath, key)
const resolvedPath = path.resolve(filePath)
const resolvedBasePath = path.resolve(this.basePath)
if (!resolvedPath.startsWith(resolvedBasePath)) {
throw new Error(`LocalStorageProvider: 文件路径不安全:${key}`)
}
return resolvedPath
}
private async syncDistFile(key: string, sourcePath: string): Promise<void> {
if (!this.distPath) {
return
}
const distFilePath = path.join(this.distPath, key)
const distDir = path.dirname(distFilePath)
await fs.mkdir(distDir, { recursive: true })
await fs.copyFile(sourcePath, distFilePath)
}
private async removeDistFile(key: string): Promise<void> {
if (!this.distPath) {
return
}
const distFilePath = path.join(this.distPath, key)
try {
await fs.rm(distFilePath, { force: true })
} catch (error) {
this.logger.warn(`删除 dist 文件失败:${distFilePath}`, error)
}
}
async deleteFile(key: string): Promise<void> {
const filePath = this.resolveSafePath(key)
try {
await fs.rm(filePath, { force: true })
await this.removeDistFile(key)
this.logger.success(`已删除本地文件:${key}`)
} catch (error) {
this.logger.error(`删除本地文件失败:${key}`, error)
throw error
}
}
async uploadFile(
key: string,
data: Buffer,
_options?: StorageUploadOptions,
): Promise<StorageObject> {
const filePath = this.resolveSafePath(key)
try {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, data)
await this.syncDistFile(key, filePath)
const stats = await fs.stat(filePath)
return {
key,
size: stats.size,
lastModified: stats.mtime,
}
} catch (error) {
this.logger.error(`上传本地文件失败:${key}`, error)
throw error
}
}
private async scanDirectory(
dirPath: string,
relativePath: string,

View File

@@ -1,7 +1,12 @@
import path from 'node:path'
import type { _Object, S3Client } from '@aws-sdk/client-s3'
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import {
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
} from '@aws-sdk/client-s3'
import { backoffDelay, sleep } from '../../../../utils/src/backoff.js'
import { Semaphore } from '../../../../utils/src/semaphore.js'
@@ -13,6 +18,7 @@ import type {
S3Config,
StorageObject,
StorageProvider,
StorageUploadOptions,
} from '../interfaces'
// 将 AWS S3 对象转换为通用存储对象
@@ -266,4 +272,36 @@ export class S3StorageProvider implements StorageProvider {
return livePhotoMap
}
async deleteFile(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: this.config.bucket,
Key: key,
})
await this.s3Client.send(command)
}
async uploadFile(
key: string,
data: Buffer,
options?: StorageUploadOptions,
): Promise<StorageObject> {
const command = new PutObjectCommand({
Bucket: this.config.bucket,
Key: key,
Body: data,
ContentType: options?.contentType,
})
const response = await this.s3Client.send(command)
const lastModified = new Date()
return {
key,
size: data.byteLength,
lastModified,
etag: response.ETag ?? undefined,
}
}
}