feat(photo): implement photo tag management and update functionality

- Added UpdatePhotoTagsDto for validating photo tag updates.
- Implemented updateAssetTags method in PhotoAssetService to handle tag updates, including validation and error handling.
- Enhanced PhotoController with a new endpoint for updating photo tags.
- Introduced PhotoTagEditorModal for editing tags in the UI, allowing batch updates for selected photos.
- Updated PhotoLibrary components to support tag editing and display changes in the UI.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-17 15:01:56 +08:00
parent 765cd18598
commit 76debaa386
22 changed files with 948 additions and 94 deletions

View File

@@ -165,6 +165,7 @@ export async function executePhotoProcessingPipeline(
if (!processedData) return null
const { sharpInstance, imageBuffer, metadata } = processedData
const contentDigest = crypto.createHash('sha256').update(imageBuffer).digest('hex')
// 3. 处理缩略图和 blurhash
const thumbnailResult = await processThumbnailAndBlurhash(imageBuffer, photoId, existingItem, options)
@@ -223,23 +224,24 @@ export async function executePhotoProcessingPipeline(
s3Key: photoKey,
lastModified: obj.LastModified?.toISOString() || new Date().toISOString(),
size: obj.Size || 0,
digest: contentDigest,
exif: exifData,
toneAnalysis,
// Video source (Motion Photo or Live Photo)
video:
motionPhotoMetadata?.isMotionPhoto && motionPhotoMetadata.motionPhotoOffset !== undefined
? {
type: 'motion-photo',
offset: motionPhotoMetadata.motionPhotoOffset,
size: motionPhotoMetadata.motionPhotoVideoSize,
presentationTimestamp: motionPhotoMetadata.presentationTimestampUs,
}
type: 'motion-photo',
offset: motionPhotoMetadata.motionPhotoOffset,
size: motionPhotoMetadata.motionPhotoVideoSize,
presentationTimestamp: motionPhotoMetadata.presentationTimestampUs,
}
: livePhotoResult.isLivePhoto
? {
type: 'live-photo',
videoUrl: livePhotoResult.livePhotoVideoUrl!,
s3Key: livePhotoResult.livePhotoVideoS3Key!,
}
type: 'live-photo',
videoUrl: livePhotoResult.livePhotoVideoUrl!,
s3Key: livePhotoResult.livePhotoVideoS3Key!,
}
: undefined,
// HDR 相关字段
isHDR:

View File

@@ -69,6 +69,14 @@ export interface StorageProvider {
* @param options 上传选项
*/
uploadFile: (key: string, data: Buffer, options?: StorageUploadOptions) => Promise<StorageObject>
/**
* 将存储中的文件移动到新的键值/路径
* @param sourceKey 原文件键值
* @param targetKey 目标文件键值
* @param options 上传选项(供部分存储在复制时复用)
*/
moveFile: (sourceKey: string, targetKey: string, options?: StorageUploadOptions) => Promise<StorageObject>
}
export type S3Config = {

View File

@@ -77,6 +77,46 @@ export class StorageManager {
return await this.provider.uploadFile(key, data, options)
}
async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise<StorageObject> {
if (!sourceKey || !targetKey) {
throw new Error('moveFile requires both sourceKey and targetKey')
}
if (sourceKey === targetKey) {
const buffer = await this.provider.getFile(sourceKey)
if (!buffer) {
throw new Error(`moveFile failed: source ${sourceKey} does not exist`)
}
return {
key: targetKey,
size: buffer.length,
lastModified: new Date(),
}
}
if (typeof this.provider.moveFile === 'function') {
return await this.provider.moveFile(sourceKey, targetKey, options)
}
// Fallback: download, upload, delete
const fileBuffer = await this.provider.getFile(sourceKey)
if (!fileBuffer) {
throw new Error(`moveFile failed: source ${sourceKey} does not exist`)
}
const uploaded = await this.provider.uploadFile(targetKey, fileBuffer, options)
try {
await this.provider.deleteFile(sourceKey)
} catch (error) {
try {
await this.provider.deleteFile(targetKey)
} catch {
// ignore rollback error, rethrow original failure
}
throw error
}
return uploaded
}
addExcludeFilter(filter: (key: string) => boolean): void {
this.excludeFilters.push(filter)
}

View File

@@ -192,6 +192,10 @@ export class EagleStorageProvider implements StorageProvider {
throw new Error('EagleStorageProvider: 当前不支持上传文件操作')
}
async moveFile(_sourceKey: string, _targetKey: string): Promise<StorageObject> {
throw new Error('EagleStorageProvider: 当前不支持移动文件操作')
}
async generatePublicUrl(key: string) {
const imageName = await this.copyToDist(key)
const publicPath = path.join(this.config.baseUrl, imageName)

View File

@@ -295,6 +295,36 @@ export class GitHubStorageProvider implements StorageProvider {
}
}
async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise<StorageObject> {
if (sourceKey === targetKey) {
const metadata = await this.fetchContentMetadata(sourceKey)
return {
key: targetKey,
size: metadata?.size,
lastModified: new Date(),
etag: metadata?.sha,
}
}
const buffer = await this.getFile(sourceKey)
if (!buffer) {
throw new Error(`GitHub move failed源文件不存在 ${sourceKey}`)
}
const uploaded = await this.uploadFile(targetKey, buffer, options)
try {
await this.deleteFile(sourceKey)
} catch (error) {
try {
await this.deleteFile(targetKey)
} catch {
// ignore rollback failure
}
throw error
}
return uploaded
}
generatePublicUrl(key: string): string {
const fullPath = this.getFullPath(key)

View File

@@ -203,6 +203,38 @@ export class LocalStorageProvider implements StorageProvider {
}
}
async moveFile(sourceKey: string, targetKey: string): Promise<StorageObject> {
const sourcePath = this.resolveSafePath(sourceKey)
const targetPath = this.resolveSafePath(targetKey)
if (sourceKey === targetKey) {
const stats = await fs.stat(sourcePath)
return {
key: targetKey,
size: stats.size,
lastModified: stats.mtime,
}
}
await fs.mkdir(path.dirname(targetPath), { recursive: true })
try {
await fs.rename(sourcePath, targetPath)
} catch (error) {
this.logger.error(`重命名本地文件失败:${sourceKey} -> ${targetKey}`, error)
throw error
}
await this.syncDistFile(targetKey, targetPath)
await this.removeDistFile(sourceKey)
const stats = await fs.stat(targetPath)
return {
key: targetKey,
size: stats.size,
lastModified: stats.mtime,
}
}
private async scanDirectory(
dirPath: string,
relativePath: string,

View File

@@ -0,0 +1,107 @@
import type { SimpleS3Client } from '../../s3/client.js'
import { createS3Client, encodeS3Key } from '../../s3/client.js'
import type { S3Config } from '../interfaces.js'
import { sanitizeS3Etag } from './s3-utils.js'
export class S3ProviderClient {
private readonly client: SimpleS3Client
constructor(config: S3Config) {
this.client = createS3Client(config)
}
buildObjectUrl(key?: string): string {
return this.client.buildObjectUrl(key)
}
async getObject(key: string, init?: RequestInit): Promise<Response> {
return await this.client.fetch(this.buildObjectUrl(key), {
method: 'GET',
...init,
})
}
async headObject(key: string): Promise<Response> {
return await this.client.fetch(this.buildObjectUrl(key), {
method: 'HEAD',
})
}
async putObject(key: string, body: BodyInit, headers?: HeadersInit): Promise<Response> {
return await this.client.fetch(this.buildObjectUrl(key), {
method: 'PUT',
body,
headers,
})
}
async deleteObject(key: string): Promise<Response> {
return await this.client.fetch(this.buildObjectUrl(key), {
method: 'DELETE',
})
}
async copyObject(sourceKey: string, targetKey: string, headers?: HeadersInit): Promise<Response> {
const copySource = `/${this.client.bucket}/${encodeS3Key(sourceKey)}`
return await this.client.fetch(this.buildObjectUrl(targetKey), {
method: 'PUT',
headers: {
'x-amz-copy-source': copySource,
...headers,
},
})
}
async listObjects(params?: { prefix?: string | null; maxKeys?: number }): Promise<Response> {
const url = new URL(this.buildObjectUrl())
url.searchParams.set('list-type', '2')
if (params?.prefix) {
url.searchParams.set('prefix', encodeS3Key(params.prefix))
}
if (params?.maxKeys) {
url.searchParams.set('max-keys', String(params.maxKeys))
}
return await this.client.fetch(url.toString(), { method: 'GET' })
}
async moveObject(
sourceKey: string,
targetKey: string,
options?: { headers?: HeadersInit },
): Promise<{ metadata: { key: string; size?: number; etag?: string | null; lastModified?: Date } }> {
const copyResponse = await this.copyObject(sourceKey, targetKey, options?.headers)
if (!copyResponse.ok) {
const text = await copyResponse.text().catch(() => '')
throw new Error(
`复制 S3 对象失败:${sourceKey} -> ${targetKey} (status ${copyResponse.status}) ${text ? text.slice(0, 200) : ''}`,
)
}
let metadata: { key: string; size?: number; etag?: string | null; lastModified?: Date } = { key: targetKey }
const headResponse = await this.headObject(targetKey)
if (headResponse.ok) {
const size = headResponse.headers.get('content-length')
const etag = headResponse.headers.get('etag')
const lastModified = headResponse.headers.get('last-modified')
metadata = {
key: targetKey,
size: size ? Number(size) : undefined,
etag: sanitizeS3Etag(etag) ?? null,
lastModified: lastModified ? new Date(lastModified) : undefined,
}
}
const deleteResponse = await this.deleteObject(sourceKey)
if (!deleteResponse.ok) {
await this.deleteObject(targetKey).catch(() => {
/* ignore */
})
const text = await deleteResponse.text().catch(() => '')
throw new Error(
`删除源对象失败:${sourceKey} (status ${deleteResponse.status}) ${text ? text.slice(0, 200) : ''}`,
)
}
return { metadata }
}
}

View File

@@ -6,9 +6,9 @@ import { backoffDelay, sleep } from '../../../../utils/src/backoff.js'
import { Semaphore } from '../../../../utils/src/semaphore.js'
import { SUPPORTED_FORMATS } from '../../constants/index.js'
import { logger } from '../../logger/index.js'
import type { SimpleS3Client } from '../../s3/client.js'
import { createS3Client, encodeS3Key } from '../../s3/client.js'
import type { ProgressCallback, S3Config, StorageObject, StorageProvider, StorageUploadOptions } from '../interfaces'
import { S3ProviderClient } from './s3-client.js'
import { sanitizeS3Etag } from './s3-utils.js'
// 将 AWS S3 对象转换为通用存储对象
const xmlParser = new XMLParser({ ignoreAttributes: false })
@@ -110,19 +110,15 @@ function formatS3ErrorBody(body?: string | null): string {
export class S3StorageProvider implements StorageProvider {
private config: S3Config
private client: SimpleS3Client
private client: S3ProviderClient
private limiter: Semaphore
constructor(config: S3Config) {
this.config = config
this.client = createS3Client(config)
this.client = new S3ProviderClient(config)
this.limiter = new Semaphore(this.config.downloadConcurrency ?? 16)
}
private buildObjectUrl(key?: string): string {
return this.client.buildObjectUrl(key)
}
private async readStreamWithIdleTimeout(
response: Response,
controller: AbortController,
@@ -184,8 +180,7 @@ export class S3StorageProvider implements StorageProvider {
try {
logger.s3.info(`下载开始:${key} (attempt ${attempt}/${maxAttempts})`)
const response = await this.client.fetch(this.buildObjectUrl(key), {
method: 'GET',
const response = await this.client.getObject(key, {
signal: controller.signal,
})
@@ -331,17 +326,10 @@ export class S3StorageProvider implements StorageProvider {
}
private async listObjects(): Promise<StorageObject[]> {
const url = new URL(this.buildObjectUrl())
url.search = ''
url.searchParams.set('list-type', '2')
if (this.config.prefix) {
url.searchParams.set('prefix', encodeS3Key(this.config.prefix))
}
if (this.config.maxFileLimit) {
url.searchParams.set('max-keys', String(this.config.maxFileLimit))
}
const response = await this.client.fetch(url.toString(), { method: 'GET' })
const response = await this.client.listObjects({
prefix: this.config.prefix,
maxKeys: this.config.maxFileLimit,
})
const text = await response.text()
if (!response.ok) {
throw new Error(`列出 S3 对象失败 (status ${response.status}): ${formatS3ErrorBody(text)}`)
@@ -357,16 +345,14 @@ export class S3StorageProvider implements StorageProvider {
key,
size: item?.Size !== undefined ? Number(item.Size) : undefined,
lastModified: item?.LastModified ? new Date(item.LastModified) : undefined,
etag: typeof item?.ETag === 'string' ? item.ETag.replaceAll('"', '') : undefined,
etag: sanitizeS3Etag(typeof item?.ETag === 'string' ? item.ETag : undefined),
} satisfies StorageObject
})
.filter((item) => Boolean(item.key))
}
async deleteFile(key: string): Promise<void> {
const response = await this.client.fetch(this.buildObjectUrl(key), {
method: 'DELETE',
})
const response = await this.client.deleteObject(key)
if (!response.ok) {
const text = await response.text().catch(() => '')
@@ -375,13 +361,9 @@ export class S3StorageProvider implements StorageProvider {
}
async uploadFile(key: string, data: Buffer, options?: StorageUploadOptions): Promise<StorageObject> {
const response = await this.client.fetch(this.buildObjectUrl(key), {
method: 'PUT',
body: data as unknown as BodyInit,
headers: {
'content-type': options?.contentType ?? 'application/octet-stream',
'content-length': data.byteLength.toString(),
},
const response = await this.client.putObject(key, data as unknown as BodyInit, {
'content-type': options?.contentType ?? 'application/octet-stream',
'content-length': data.byteLength.toString(),
})
if (!response.ok) {
@@ -395,7 +377,31 @@ export class S3StorageProvider implements StorageProvider {
key,
size: data.byteLength,
lastModified,
etag: response.headers.get('etag') ?? undefined,
etag: sanitizeS3Etag(response.headers.get('etag')),
}
}
async moveFile(sourceKey: string, targetKey: string, options?: StorageUploadOptions): Promise<StorageObject> {
if (sourceKey === targetKey) {
const object = await this.getFile(sourceKey)
if (!object) {
throw new Error(`S3 move failed源文件不存在 ${sourceKey}`)
}
return {
key: targetKey,
size: object.length,
lastModified: new Date(),
}
}
const { metadata } = await this.client.moveObject(sourceKey, targetKey, {
headers: options?.contentType ? { 'content-type': options.contentType } : undefined,
})
return {
key: metadata.key,
size: metadata.size,
lastModified: metadata.lastModified,
etag: sanitizeS3Etag(metadata.etag),
}
}
}

View File

@@ -0,0 +1,7 @@
export function sanitizeS3Etag(value?: string | null): string | undefined {
if (!value) {
return undefined
}
const normalized = value.replaceAll('"', '').trim()
return normalized.length > 0 ? normalized : undefined
}

View File

@@ -57,6 +57,7 @@ export interface PhotoManifestItem extends PhotoInfo {
s3Key: string
lastModified: string
size: number
digest?: string
exif: PickedExif | null
toneAnalysis: ToneAnalysis | null // 影调分析结果
isHDR?: boolean

View File

@@ -162,8 +162,8 @@ function DialogContent({
{...props}
>
{children}
<DialogPrimitive.Close className="focus:bg-fill data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-3 right-2 flex size-6 items-center justify-center rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
<i className="i-mingcute-close-line h-4 w-4" aria-hidden="true" />
<DialogPrimitive.Close className="focus:bg-fill data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-3 right-2 flex size-6 items-center justify-center rounded opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none">
<i className="i-mingcute-close-line size-4" aria-hidden="true" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</motion.div>