mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 14:55:29 +00:00
feat: revamp dashboard photo management
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
333
be/apps/core/src/modules/photo/photo-asset.service.ts
Normal file
333
be/apps/core/src/modules/photo/photo-asset.service.ts
Normal 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, '/')
|
||||
}
|
||||
}
|
||||
286
be/apps/core/src/modules/photo/photo-storage.service.ts
Normal file
286
be/apps/core/src/modules/photo/photo-storage.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
83
be/apps/core/src/modules/photo/photo.controller.ts
Normal file
83
be/apps/core/src/modules/photo/photo.controller.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
77
be/apps/dashboard/src/components/navigation/PageTabs.tsx
Normal file
77
be/apps/dashboard/src/components/navigation/PageTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
168
be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx
Normal file
168
be/apps/dashboard/src/modules/photos/components/PhotoPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
75
be/apps/dashboard/src/modules/photos/hooks.ts
Normal file
75
be/apps/dashboard/src/modules/photos/hooks.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 存储提供商实例
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user