diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-asset-host.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-asset-host.service.ts new file mode 100644 index 00000000..84f58398 --- /dev/null +++ b/be/apps/core/src/modules/infrastructure/static-web/static-asset-host.service.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@afilmory/framework' +import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' +import { injectable } from 'tsyringe' + +@injectable() +export class StaticAssetHostService { + private readonly logger = createLogger('StaticAssetHostService') + private readonly cache = new Map() + + constructor(private readonly systemSettingService: SystemSettingService) {} + + async getStaticAssetHost(requestHost?: string | null): Promise { + const cacheKey = this.buildCacheKey(requestHost) + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey) ?? null + } + + const resolved = await this.resolveStaticAssetHost(requestHost) + this.cache.set(cacheKey, resolved ?? null) + return resolved ?? null + } + + private buildCacheKey(requestHost?: string | null): string { + if (!requestHost) { + return '__default__' + } + return requestHost.trim().toLowerCase() + } + + private async resolveStaticAssetHost(requestHost?: string | null): Promise { + try { + const settings = await this.systemSettingService.getSettings() + const baseDomain = settings.baseDomain?.trim().toLowerCase() + if (!baseDomain) { + return null + } + + if (this.isLocalDomain(baseDomain)) { + const port = this.extractPort(requestHost) + return port ? `//static.${baseDomain}:${port}` : `//static.${baseDomain}` + } + + return `//static.${baseDomain}` + } catch (error) { + this.logger.warn('Failed to load system settings for static asset host', error) + return null + } + } + + private isLocalDomain(baseDomain: string): boolean { + if (baseDomain === 'localhost') { + return true + } + + if (baseDomain.endsWith('.localhost')) { + return true + } + + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(baseDomain)) { + return true + } + + return false + } + + private extractPort(requestHost?: string | null): string | null { + if (!requestHost) { + return null + } + + const host = requestHost.trim() + if (!host) { + return null + } + + if (host.startsWith('[')) { + const closingIndex = host.indexOf(']') + if (closingIndex !== -1 && closingIndex + 1 < host.length && host[closingIndex + 1] === ':') { + return host.slice(closingIndex + 2) + } + return null + } + + const segments = host.split(':') + if (segments.length <= 1) { + return null + } + + return segments.at(-1) + } +} diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts index 4c9b2f5d..f8d29b2a 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-asset.service.ts @@ -31,6 +31,11 @@ export interface StaticAssetServiceOptions { loggerName?: string rewriteAssetReferences?: boolean assetLinkRels?: Iterable + staticAssetHostResolver?: (requestHost?: string | null) => Promise +} + +export interface StaticAssetRequestOptions { + requestHost?: string | null } export interface ResolvedStaticAsset { @@ -42,8 +47,10 @@ export interface ResolvedStaticAsset { export abstract class StaticAssetService { protected readonly logger: PrettyLogger private readonly assetLinkRels: ReadonlySet + private readonly staticAssetHostResolver?: (requestHost?: string | null) => Promise private staticRoot: string | null | undefined + private staticAssetHosts = new Map() private warnedMissingRoot = false protected constructor(private readonly options: StaticAssetServiceOptions) { @@ -51,9 +58,14 @@ export abstract class StaticAssetService { this.assetLinkRels = new Set( options.assetLinkRels ? Array.from(options.assetLinkRels, (rel) => rel.toLowerCase()) : DEFAULT_ASSET_LINK_RELS, ) + this.staticAssetHostResolver = options.staticAssetHostResolver } - async handleRequest(fullPath: string, headOnly: boolean): Promise { + async handleRequest( + fullPath: string, + headOnly: boolean, + options?: StaticAssetRequestOptions, + ): Promise { const staticRoot = await this.resolveStaticRoot() if (!staticRoot) { return null @@ -65,7 +77,7 @@ export abstract class StaticAssetService { return null } - return await this.createResponse(target, headOnly) + return await this.createResponse(target, headOnly, options) } protected get routeSegment(): string { @@ -78,10 +90,10 @@ export abstract class StaticAssetService { protected async decorateDocument(_document: StaticAssetDocument, _file: ResolvedStaticAsset): Promise {} - protected rewriteStaticAssetReferences(document: StaticAssetDocument): void { + protected rewriteStaticAssetReferences(document: StaticAssetDocument, staticAssetHost: string | null): void { const prefixAttr = (element: Element, attr: string) => { const current = element.getAttribute(attr) - const next = this.prefixStaticAssetPath(current) + const next = this.applyStaticAssetPrefixes(current, staticAssetHost) if (next !== null && next !== current) { element.setAttribute(attr, next) } @@ -106,7 +118,7 @@ export abstract class StaticAssetService { document.querySelectorAll('img[srcset], source[srcset]').forEach((element) => { const current = element.getAttribute('srcset') - const next = this.prefixSrcset(current) + const next = this.prefixSrcset(current, staticAssetHost) if (next !== null && next !== current) { element.setAttribute('srcset', next) } @@ -148,7 +160,12 @@ export abstract class StaticAssetService { return value === trimmed ? prefixed : value.replace(trimmed, prefixed) } - private prefixSrcset(value: string | null): string | null { + private applyStaticAssetPrefixes(value: string | null, staticAssetHost: string | null): string | null { + const prefixed = this.prefixStaticAssetPath(value) + return this.prefixStaticAssetHost(prefixed, staticAssetHost) + } + + private prefixSrcset(value: string | null, staticAssetHost: string | null): string | null { if (!value) { return value } @@ -160,13 +177,27 @@ export abstract class StaticAssetService { } const [url, ...rest] = trimmed.split(/\s+/) - const prefixed = this.prefixStaticAssetPath(url) ?? url + const prefixed = this.applyStaticAssetPrefixes(url, staticAssetHost) ?? url return [prefixed, ...rest].join(' ').trim() }) return parts.join(', ') } + private prefixStaticAssetHost(value: string | null, staticAssetHost: string | null): string | null { + if (!value || !staticAssetHost) { + return value + } + + const trimmed = value.trim() + if (!trimmed.startsWith(this.routeSegment)) { + return value + } + + const rewrote = `${staticAssetHost}${trimmed}` + return value === trimmed ? rewrote : value.replace(trimmed, rewrote) + } + private async resolveStaticRoot(): Promise { if (this.staticRoot !== undefined) { return this.staticRoot @@ -307,9 +338,13 @@ export abstract class StaticAssetService { return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath) } - private async createResponse(file: ResolvedStaticAsset, headOnly: boolean): Promise { + private async createResponse( + file: ResolvedStaticAsset, + headOnly: boolean, + options?: StaticAssetRequestOptions, + ): Promise { if (this.isHtml(file.relativePath)) { - return await this.createHtmlResponse(file, headOnly) + return await this.createHtmlResponse(file, headOnly, options) } const mimeType = lookupMimeType(file.absolutePath) || 'application/octet-stream' @@ -319,6 +354,7 @@ export abstract class StaticAssetService { headers.set('last-modified', file.stats.mtime.toUTCString()) this.applyCacheHeaders(headers, file.relativePath) + this.applyCorsHeaders(headers) if (headOnly) { return new Response(null, { headers, status: 200 }) @@ -329,14 +365,19 @@ export abstract class StaticAssetService { return new Response(body, { headers, status: 200 }) } - private async createHtmlResponse(file: ResolvedStaticAsset, headOnly: boolean): Promise { + private async createHtmlResponse( + file: ResolvedStaticAsset, + headOnly: boolean, + options?: StaticAssetRequestOptions, + ): Promise { const html = await readFile(file.absolutePath, 'utf-8') - const transformed = await this.transformIndexHtml(html, file) + const transformed = await this.transformIndexHtml(html, file, options) const headers = new Headers() headers.set('content-type', 'text/html; charset=utf-8') headers.set('content-length', `${Buffer.byteLength(transformed, 'utf-8')}`) headers.set('last-modified', file.stats.mtime.toUTCString()) this.applyCacheHeaders(headers, file.relativePath) + this.applyCorsHeaders(headers) if (headOnly) { return new Response(null, { headers, status: 200 }) @@ -345,12 +386,17 @@ export abstract class StaticAssetService { return new Response(transformed, { headers, status: 200 }) } - private async transformIndexHtml(html: string, file: ResolvedStaticAsset): Promise { + private async transformIndexHtml( + html: string, + file: ResolvedStaticAsset, + options?: StaticAssetRequestOptions, + ): Promise { try { const document = DOM_PARSER.parseFromString(html, 'text/html') as unknown as StaticAssetDocument await this.decorateDocument(document, file) if (this.shouldRewriteAssetReferences(file)) { - this.rewriteStaticAssetReferences(document) + const staticAssetHost = await this.getStaticAssetHost(options?.requestHost) + this.rewriteStaticAssetReferences(document, staticAssetHost) } return document.documentElement.outerHTML } catch (error) { @@ -359,6 +405,35 @@ export abstract class StaticAssetService { } } + private async getStaticAssetHost(requestHost?: string | null): Promise { + if (!this.staticAssetHostResolver) { + return null + } + + const cacheKey = this.buildStaticAssetHostCacheKey(requestHost) + if (this.staticAssetHosts.has(cacheKey)) { + return this.staticAssetHosts.get(cacheKey) ?? null + } + + try { + const resolved = await this.staticAssetHostResolver(requestHost) + this.staticAssetHosts.set(cacheKey, resolved ?? null) + return resolved ?? null + } catch (error) { + this.logger.warn('Failed to resolve static asset host', error) + this.staticAssetHosts.set(cacheKey, null) + } + + return null + } + + private buildStaticAssetHostCacheKey(requestHost?: string | null): string { + if (!requestHost) { + return '__default__' + } + return requestHost.trim().toLowerCase() + } + private shouldTreatAsImmutable(relativePath: string): boolean { if (this.isHtml(relativePath)) { return false @@ -374,6 +449,12 @@ export abstract class StaticAssetService { headers.set('surrogate-control', policy.cdn) } + private applyCorsHeaders(headers: Headers): void { + headers.set('access-control-allow-origin', '*') + headers.set('access-control-allow-methods', 'GET, HEAD, OPTIONS') + headers.set('access-control-allow-headers', 'content-type') + } + private resolveCachePolicy(relativePath: string): { browser: string; cdn: string } { if (this.isHtml(relativePath)) { return { diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-dashboard.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-dashboard.service.ts index 6e0b82b1..d501cfcb 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-dashboard.service.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-dashboard.service.ts @@ -5,6 +5,7 @@ import { injectable } from 'tsyringe' import type { StaticAssetDocument } from './static-asset.service' import { StaticAssetService } from './static-asset.service' +import { StaticAssetHostService } from './static-asset-host.service' const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)) @@ -36,11 +37,12 @@ const STATIC_DASHBOARD_ROOT_CANDIDATES = Array.from( @injectable() export class StaticDashboardService extends StaticAssetService { - constructor() { + constructor(private readonly staticAssetHostService: StaticAssetHostService) { super({ routeSegment: STATIC_DASHBOARD_ROUTE_SEGMENT, rootCandidates: STATIC_DASHBOARD_ROOT_CANDIDATES, loggerName: 'StaticDashboardService', + staticAssetHostResolver: (requestHost) => staticAssetHostService.getStaticAssetHost(requestHost), }) } diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts index 833f68d7..bef04aa6 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web.controller.ts @@ -101,7 +101,9 @@ export class StaticWebController { private async serve(context: Context, service: StaticAssetService, headOnly: boolean): Promise { const pathname = context.req.path const normalizedPath = this.normalizeRequestPath(pathname, service) - const response = await service.handleRequest(normalizedPath, headOnly) + const response = await service.handleRequest(normalizedPath, headOnly, { + requestHost: this.resolveRequestHost(context), + }) if (response) { return response } @@ -191,6 +193,25 @@ export class StaticWebController { return trimmed } + private resolveRequestHost(context: Context): string | null { + const forwardedHost = context.req.header('x-forwarded-host')?.trim() + if (forwardedHost) { + return forwardedHost + } + + const host = context.req.header('host')?.trim() + if (host) { + return host + } + + try { + const url = new URL(context.req.url) + return url.host + } catch { + return null + } + } + private isReservedTenant({ root = false }: { root?: boolean } = {}): boolean { const tenantContext = getTenantContext() if (!tenantContext) { diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web.module.ts b/be/apps/core/src/modules/infrastructure/static-web/static-web.module.ts index 0adc1274..d5a61545 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-web.module.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web.module.ts @@ -1,14 +1,16 @@ import { Module } from '@afilmory/framework' import { SiteSettingModule } from 'core/modules/configuration/site-setting/site-setting.module' +import { SystemSettingModule } from 'core/modules/configuration/system-setting/system-setting.module' import { ManifestModule } from 'core/modules/content/manifest/manifest.module' +import { StaticAssetHostService } from './static-asset-host.service' import { StaticDashboardService } from './static-dashboard.service' import { StaticWebController } from './static-web.controller' import { StaticWebService } from './static-web.service' @Module({ - imports: [SiteSettingModule, ManifestModule], + imports: [SiteSettingModule, SystemSettingModule, ManifestModule], controllers: [StaticWebController], - providers: [StaticWebService, StaticDashboardService], + providers: [StaticAssetHostService, StaticWebService, StaticDashboardService], }) export class StaticWebModule {} diff --git a/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts b/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts index 4cac09b7..4c743ef7 100644 --- a/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts +++ b/be/apps/core/src/modules/infrastructure/static-web/static-web.service.ts @@ -10,6 +10,7 @@ import { injectable } from 'tsyringe' import type { StaticAssetDocument } from './static-asset.service' import { StaticAssetService } from './static-asset.service' +import { StaticAssetHostService } from './static-asset-host.service' const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url)) @@ -54,12 +55,14 @@ export class StaticWebService extends StaticAssetService { constructor( private readonly manifestService: ManifestService, private readonly siteSettingService: SiteSettingService, + private readonly staticAssetHostService: StaticAssetHostService, ) { super({ routeSegment: STATIC_WEB_ROUTE_SEGMENT, rootCandidates: STATIC_WEB_ROOT_CANDIDATES, assetLinkRels: STATIC_WEB_ASSET_LINK_RELS, loggerName: 'StaticWebService', + staticAssetHostResolver: (requestHost) => this.staticAssetHostService.getStaticAssetHost(requestHost), }) } diff --git a/be/apps/dashboard/src/modules/photos/api.ts b/be/apps/dashboard/src/modules/photos/api.ts index bfe56e18..2195c4cb 100644 --- a/be/apps/dashboard/src/modules/photos/api.ts +++ b/be/apps/dashboard/src/modules/photos/api.ts @@ -106,7 +106,7 @@ export async function runPhotoSync( }) if (!response.ok || !response.body) { - const fallback = `同步请求失败:${response.status} ${response.statusText}` + const fallback = `Sync request failed: ${response.status} ${response.statusText}` const serverMessage = await readResponseErrorMessage(response) throw new Error(serverMessage ?? fallback) } @@ -200,7 +200,7 @@ export async function runPhotoSync( } if (!finalResult) { - throw new Error('同步过程中未收到最终结果,连接已终止。') + throw new Error('Sync completed without a final result. Connection terminated.') } return camelCaseKeys(finalResult) @@ -409,7 +409,7 @@ export async function uploadPhotoAssets( const event = camelCaseKeys(parsed) options?.onServerEvent?.(event) if (event.type === 'error') { - settle(() => {}, reject, new Error(event.payload.message || '服务器处理失败')) + settle(() => {}, reject, new Error(event.payload.message || 'Server processing failed')) xhr.abort() return } @@ -432,7 +432,7 @@ export async function uploadPhotoAssets( } xhr.onerror = () => { - settle(() => {}, reject, new Error('上传过程中出现网络错误,请稍后再试。')) + settle(() => {}, reject, new Error('Network error during upload. Please try again later.')) } xhr.onabort = () => { @@ -440,7 +440,7 @@ export async function uploadPhotoAssets( } xhr.ontimeout = () => { - settle(() => {}, reject, new Error('上传超时,请稍后再试。')) + settle(() => {}, reject, new Error('Upload timed out. Please try again later.')) } xhr.onload = () => { @@ -450,7 +450,8 @@ export async function uploadPhotoAssets( return } - const fallbackMessage = xhr.status >= 200 && xhr.status < 300 ? '上传响应未完成' : `上传失败:${xhr.status}` + const fallbackMessage = + xhr.status >= 200 && xhr.status < 300 ? 'Upload response incomplete' : `Upload failed: ${xhr.status}` const serverMessage = extractMessageFromXhr(xhr) settle(() => {}, reject, new Error(serverMessage ?? fallbackMessage)) } diff --git a/be/apps/dashboard/src/modules/photos/components/library/DeleteFromStorageOption.tsx b/be/apps/dashboard/src/modules/photos/components/library/DeleteFromStorageOption.tsx index ce53968a..0b789ff4 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/DeleteFromStorageOption.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/DeleteFromStorageOption.tsx @@ -1,4 +1,5 @@ import { Checkbox } from '@afilmory/ui' +import { useTranslation } from 'react-i18next' type DeleteFromStorageOptionProps = { defaultChecked?: boolean @@ -7,6 +8,7 @@ type DeleteFromStorageOptionProps = { } export function DeleteFromStorageOption({ defaultChecked = false, disabled, onChange }: DeleteFromStorageOptionProps) { + const { t } = useTranslation() return ( ) diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoExifDetailsModal.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoExifDetailsModal.tsx index eee5ec69..ee07b297 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoExifDetailsModal.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoExifDetailsModal.tsx @@ -2,6 +2,7 @@ import type { PhotoManifestItem, PickedExif } from '@afilmory/builder' import type { ModalComponent } from '@afilmory/ui' import { DialogDescription, DialogHeader, DialogTitle, LinearDivider, ScrollArea } from '@afilmory/ui' import { clsxm } from '@afilmory/utils' +import { useTranslation } from 'react-i18next' type Section = { title: string @@ -12,6 +13,106 @@ type PhotoExifDetailsModalProps = { manifest: PhotoManifestItem } +const exifKeys = { + headerFile: 'photos.library.exif.file', + empty: 'photos.library.exif.empty', + sections: { + basic: 'photos.library.exif.sections.basic', + capture: 'photos.library.exif.sections.capture', + metadata: 'photos.library.exif.sections.metadata', + location: 'photos.library.exif.sections.location', + fuji: 'photos.library.exif.sections.fuji', + }, + rows: { + title: 'photos.library.exif.rows.title', + photoId: 'photos.library.exif.rows.photo-id', + capturedAt: 'photos.library.exif.rows.captured-at', + resolution: 'photos.library.exif.rows.resolution', + megapixels: 'photos.library.exif.rows.megapixels', + fileSize: 'photos.library.exif.rows.file-size', + fileFormat: 'photos.library.exif.rows.file-format', + aspectRatio: 'photos.library.exif.rows.aspect-ratio', + device: 'photos.library.exif.rows.device', + lens: 'photos.library.exif.rows.lens', + aperture: 'photos.library.exif.rows.aperture', + shutter: 'photos.library.exif.rows.shutter', + iso: 'photos.library.exif.rows.iso', + exposureCompensation: 'photos.library.exif.rows.exposure-compensation', + eqFocalLength: 'photos.library.exif.rows.eq-focal-length', + focalLength: 'photos.library.exif.rows.focal-length', + exposureProgram: 'photos.library.exif.rows.exposure-program', + meteringMode: 'photos.library.exif.rows.metering-mode', + whiteBalance: 'photos.library.exif.rows.white-balance', + sceneType: 'photos.library.exif.rows.scene-type', + flash: 'photos.library.exif.rows.flash', + lightSource: 'photos.library.exif.rows.light-source', + exposureMode: 'photos.library.exif.rows.exposure-mode', + brightness: 'photos.library.exif.rows.brightness', + scaleFactor: 'photos.library.exif.rows.scale-factor', + sensor: 'photos.library.exif.rows.sensor', + author: 'photos.library.exif.rows.author', + copyright: 'photos.library.exif.rows.copyright', + software: 'photos.library.exif.rows.software', + rating: 'photos.library.exif.rows.rating', + colorSpace: 'photos.library.exif.rows.color-space', + timezone: 'photos.library.exif.rows.timezone', + timezoneSource: 'photos.library.exif.rows.timezone-source', + timeOffset: 'photos.library.exif.rows.time-offset', + latitude: 'photos.library.exif.rows.latitude', + longitude: 'photos.library.exif.rows.longitude', + altitude: 'photos.library.exif.rows.altitude', + }, + altitude: { + above: 'photos.library.exif.rows.altitude-value', + below: 'photos.library.exif.rows.altitude-below', + }, +} as const satisfies { + headerFile: I18nKeys + empty: I18nKeys + sections: Record<'basic' | 'capture' | 'metadata' | 'location' | 'fuji', I18nKeys> + rows: Record< + | 'title' + | 'photoId' + | 'capturedAt' + | 'resolution' + | 'megapixels' + | 'fileSize' + | 'fileFormat' + | 'aspectRatio' + | 'device' + | 'lens' + | 'aperture' + | 'shutter' + | 'iso' + | 'exposureCompensation' + | 'eqFocalLength' + | 'focalLength' + | 'exposureProgram' + | 'meteringMode' + | 'whiteBalance' + | 'sceneType' + | 'flash' + | 'lightSource' + | 'exposureMode' + | 'brightness' + | 'scaleFactor' + | 'sensor' + | 'author' + | 'copyright' + | 'software' + | 'rating' + | 'colorSpace' + | 'timezone' + | 'timezoneSource' + | 'timeOffset' + | 'latitude' + | 'longitude' + | 'altitude', + I18nKeys + > + altitude: Record<'above' | 'below', I18nKeys> +} + const candidateKeys = (key: string): string[] => { const variations = new Set([ key, @@ -97,11 +198,15 @@ const formatFocalLength = (source?: string | number | null): string | null => { return numeric !== null ? `${numeric}mm` : value } -const formatDateLabel = (value?: string | null): string | null => { +const formatDateLabel = (value: string | null | undefined, locale: string): string | null => { if (!value) return null const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return null - return parsed.toLocaleString() + try { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(parsed) + } catch { + return parsed.toLocaleString() + } } const toReadableValue = (maybeValue: unknown): string | null => { @@ -112,6 +217,7 @@ const toReadableValue = (maybeValue: unknown): string | null => { const convertGPSToDecimal = ( exif: PickedExif | null, + t: (key: I18nKeys, options?: Record) => string, ): { latitude: string; longitude: string; altitude?: string } | null => { const latitudeValue = getExifValue(exif, 'GPSLatitude') const longitudeValue = getExifValue(exif, 'GPSLongitude') @@ -125,7 +231,11 @@ const convertGPSToDecimal = ( const altitudeRef = getExifValue(exif, 'GPSAltitudeRef') const altitudeNumber = parseNumber(altitudeRaw) const altitudeValue = - altitudeNumber !== null ? `${altitudeNumber}${altitudeRef === 'Below Sea Level' ? 'm (海平面下)' : 'm'}` : null + altitudeNumber !== null + ? altitudeRef === 'Below Sea Level' + ? t(exifKeys.altitude.below, { value: altitudeNumber }) + : t(exifKeys.altitude.above, { value: altitudeNumber }) + : null return { latitude: `${latitude.toFixed(5)}° ${latitudeRef === 'S' || latitudeRef === 'South' ? 'S' : 'N'}`, @@ -143,28 +253,35 @@ const getFormatLabel = (manifest: PhotoManifestItem): string | null => { return parts.pop()?.toUpperCase() ?? null } -const buildSections = (manifest: PhotoManifestItem): Section[] => { +const buildSections = ( + manifest: PhotoManifestItem, + t: (key: I18nKeys, options?: Record) => string, + locale: string, +): Section[] => { const { exif } = manifest const sections: Section[] = [] const basicRows = [ - { label: '标题', value: manifest.title || manifest.id }, - { label: '照片 ID', value: manifest.id }, - { label: '拍摄时间', value: formatDateLabel(getExifValue(exif, 'DateTimeOriginal') ?? manifest.dateTaken) }, - { label: '分辨率', value: `${manifest.width} × ${manifest.height}` }, - { label: '像素数量', value: `${Math.round((manifest.width * manifest.height) / 1_000_000)} MP` }, - { label: '文件大小', value: formatFileSize(manifest.size) }, - { label: '文件格式', value: getFormatLabel(manifest) }, - { label: '宽高比', value: manifest.aspectRatio ? manifest.aspectRatio.toFixed(2) : null }, + { label: t(exifKeys.rows.title), value: manifest.title || manifest.id }, + { label: t(exifKeys.rows.photoId), value: manifest.id }, + { + label: t(exifKeys.rows.capturedAt), + value: formatDateLabel(getExifValue(exif, 'DateTimeOriginal') ?? manifest.dateTaken, locale), + }, + { label: t(exifKeys.rows.resolution), value: `${manifest.width} × ${manifest.height}` }, + { label: t(exifKeys.rows.megapixels), value: `${Math.round((manifest.width * manifest.height) / 1_000_000)} MP` }, + { label: t(exifKeys.rows.fileSize), value: formatFileSize(manifest.size) }, + { label: t(exifKeys.rows.fileFormat), value: getFormatLabel(manifest) }, + { label: t(exifKeys.rows.aspectRatio), value: manifest.aspectRatio ? manifest.aspectRatio.toFixed(2) : null }, ].filter((row) => row.value) if (basicRows.length > 0) { - sections.push({ title: '基本信息', rows: basicRows as Section['rows'] }) + sections.push({ title: t(exifKeys.sections.basic), rows: basicRows as Section['rows'] }) } const captureRows = [ { - label: '拍摄设备', + label: t(exifKeys.rows.device), value: (() => { const make = getExifValue(exif, 'Make') const model = getExifValue(exif, 'Model') @@ -172,77 +289,83 @@ const buildSections = (manifest: PhotoManifestItem): Section[] => { })(), }, { - label: '镜头', + label: t(exifKeys.rows.lens), value: (() => { const lensMake = getExifValue(exif, 'LensMake') const lensModel = getExifValue(exif, 'LensModel') return lensMake || lensModel ? [lensMake, lensModel].filter(Boolean).join(' ') : null })(), }, - { label: '光圈', value: formatAperture(exif) }, - { label: '快门', value: formatShutterSpeed(exif) }, + { label: t(exifKeys.rows.aperture), value: formatAperture(exif) }, + { label: t(exifKeys.rows.shutter), value: formatShutterSpeed(exif) }, { - label: '感光度', + label: t(exifKeys.rows.iso), value: (() => { const iso = getExifValue(exif, 'ISO') return iso ? `ISO ${iso}` : null })(), }, - { label: '曝光补偿', value: formatExposureCompensation(getExifValue(exif, 'ExposureCompensation')) }, - { label: '等效焦距', value: formatFocalLength(getExifValue(exif, 'FocalLengthIn35mmFormat')) }, - { label: '实际焦距', value: formatFocalLength(getExifValue(exif, 'FocalLength')) }, - { label: '曝光程序', value: toReadableValue(getExifValue(exif, 'ExposureProgram')) }, - { label: '测光模式', value: toReadableValue(getExifValue(exif, 'MeteringMode')) }, - { label: '白平衡', value: toReadableValue(getExifValue(exif, 'WhiteBalance')) }, - { label: '场景类型', value: toReadableValue(getExifValue(exif, 'SceneCaptureType')) }, - { label: '闪光灯', value: toReadableValue(getExifValue(exif, 'Flash')) }, - { label: '光源', value: toReadableValue(getExifValue(exif, 'LightSource')) }, - { label: '曝光模式', value: toReadableValue(getExifValue(exif, 'ExposureMode')) }, { - label: '亮度值', + label: t(exifKeys.rows.exposureCompensation), + value: formatExposureCompensation(getExifValue(exif, 'ExposureCompensation')), + }, + { label: t(exifKeys.rows.eqFocalLength), value: formatFocalLength(getExifValue(exif, 'FocalLengthIn35mmFormat')) }, + { label: t(exifKeys.rows.focalLength), value: formatFocalLength(getExifValue(exif, 'FocalLength')) }, + { label: t(exifKeys.rows.exposureProgram), value: toReadableValue(getExifValue(exif, 'ExposureProgram')) }, + { label: t(exifKeys.rows.meteringMode), value: toReadableValue(getExifValue(exif, 'MeteringMode')) }, + { label: t(exifKeys.rows.whiteBalance), value: toReadableValue(getExifValue(exif, 'WhiteBalance')) }, + { label: t(exifKeys.rows.sceneType), value: toReadableValue(getExifValue(exif, 'SceneCaptureType')) }, + { label: t(exifKeys.rows.flash), value: toReadableValue(getExifValue(exif, 'Flash')) }, + { label: t(exifKeys.rows.lightSource), value: toReadableValue(getExifValue(exif, 'LightSource')) }, + { label: t(exifKeys.rows.exposureMode), value: toReadableValue(getExifValue(exif, 'ExposureMode')) }, + { + label: t(exifKeys.rows.brightness), value: (() => { const brightness = getExifValue(exif, 'BrightnessValue', 'LightValue') return brightness ? String(brightness) : null })(), }, - { label: 'ScaleFactor35efl', value: toReadableValue(getExifValue(exif, 'ScaleFactor35efl')) }, - { label: '感光元件', value: toReadableValue(getExifValue(exif, 'SensingMethod')) }, + { label: t(exifKeys.rows.scaleFactor), value: toReadableValue(getExifValue(exif, 'ScaleFactor35efl')) }, + { label: t(exifKeys.rows.sensor), value: toReadableValue(getExifValue(exif, 'SensingMethod')) }, ].filter((row) => row.value) if (captureRows.length > 0) { - sections.push({ title: '拍摄参数', rows: captureRows as Section['rows'] }) + sections.push({ title: t(exifKeys.sections.capture), rows: captureRows as Section['rows'] }) } const metaRows = [ - { label: '作者', value: toReadableValue(getExifValue(exif, 'Artist')) }, - { label: '版权', value: toReadableValue(getExifValue(exif, 'Copyright')) }, - { label: '软件', value: toReadableValue(getExifValue(exif, 'Software')) }, + { label: t(exifKeys.rows.author), value: toReadableValue(getExifValue(exif, 'Artist')) }, + { label: t(exifKeys.rows.copyright), value: toReadableValue(getExifValue(exif, 'Copyright')) }, + { label: t(exifKeys.rows.software), value: toReadableValue(getExifValue(exif, 'Software')) }, { - label: '评分', + label: t(exifKeys.rows.rating), value: (() => { const rating = getExifValue(exif, 'Rating') return rating && rating > 0 ? `${'★'.repeat(rating)}` : null })(), }, - { label: '色彩空间', value: toReadableValue(getExifValue(exif, 'ColorSpace')) }, - { label: '时区', value: getExifValue(exif, 'zone', 'tz') }, - { label: '时区来源', value: toReadableValue(getExifValue(exif, 'tzSource')) }, - { label: '时间偏移', value: toReadableValue(getExifValue(exif, 'OffsetTime', 'OffsetTimeOriginal')) }, + { label: t(exifKeys.rows.colorSpace), value: toReadableValue(getExifValue(exif, 'ColorSpace')) }, + { label: t(exifKeys.rows.timezone), value: getExifValue(exif, 'zone', 'tz') }, + { label: t(exifKeys.rows.timezoneSource), value: toReadableValue(getExifValue(exif, 'tzSource')) }, + { + label: t(exifKeys.rows.timeOffset), + value: toReadableValue(getExifValue(exif, 'OffsetTime', 'OffsetTimeOriginal')), + }, ].filter((row) => row.value) if (metaRows.length > 0) { - sections.push({ title: '元数据', rows: metaRows as Section['rows'] }) + sections.push({ title: t(exifKeys.sections.metadata), rows: metaRows as Section['rows'] }) } - const gps = convertGPSToDecimal(exif) + const gps = convertGPSToDecimal(exif, t) const locationRows = [ - { label: '纬度', value: gps?.latitude ?? null }, - { label: '经度', value: gps?.longitude ?? null }, - { label: '海拔', value: gps?.altitude ?? null }, + { label: t(exifKeys.rows.latitude), value: gps?.latitude ?? null }, + { label: t(exifKeys.rows.longitude), value: gps?.longitude ?? null }, + { label: t(exifKeys.rows.altitude), value: gps?.altitude ?? null }, ].filter((row) => row.value) if (locationRows.length > 0) { - sections.push({ title: '位置信息', rows: locationRows as Section['rows'] }) + sections.push({ title: t(exifKeys.sections.location), rows: locationRows as Section['rows'] }) } const fujiRecipe = getExifValue>(exif, 'FujiRecipe') @@ -258,7 +381,7 @@ const buildSections = (manifest: PhotoManifestItem): Section[] => { .filter((row) => row.value) if (recipeRows.length > 0) { - sections.push({ title: '富士胶片配方', rows: recipeRows as Section['rows'] }) + sections.push({ title: t(exifKeys.sections.fuji), rows: recipeRows as Section['rows'] }) } } @@ -266,7 +389,9 @@ const buildSections = (manifest: PhotoManifestItem): Section[] => { } export const PhotoExifDetailsModal: ModalComponent = ({ manifest }) => { - const sections = buildSections(manifest) + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' + const sections = buildSections(manifest, t, locale) const hasExif = manifest.exif !== null return ( @@ -274,7 +399,7 @@ export const PhotoExifDetailsModal: ModalComponent = {manifest.title || manifest.id} -

文件:{manifest.s3Key}

+

{t(exifKeys.headerFile, { value: manifest.s3Key })}

@@ -301,7 +426,7 @@ export const PhotoExifDetailsModal: ModalComponent = ) : (
- 当前资源缺少 EXIF 数据。 + {t(exifKeys.empty)}
)} diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx index 8417de45..adfe9b8a 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryActionBar.tsx @@ -3,14 +3,27 @@ import { clsxm } from '@afilmory/utils' import { DynamicIcon } from 'lucide-react/dynamic' import type { ChangeEventHandler } from 'react' import { useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/shallow' import { usePhotoLibraryStore } from './PhotoLibraryProvider' import { PhotoTagEditorModal } from './PhotoTagEditorModal' import { PhotoUploadConfirmModal } from './PhotoUploadConfirmModal' +const photoLibraryActionKeys = { + upload: 'photos.library.actions.upload', + uploadShort: 'photos.library.actions.upload-short', + selectedCount: 'photos.library.actions.selected-count', + editTags: 'photos.library.actions.edit-tags', + delete: 'photos.library.actions.delete', + clear: 'photos.library.actions.clear-selection', + selectAll: 'photos.library.actions.select-all', + allSelected: 'photos.library.actions.all-selected', +} as const satisfies Record + const emptyArray = [] export function PhotoLibraryActionBar() { + const { t } = useTranslation() const { selectionCount, totalCount, @@ -42,6 +55,11 @@ export function PhotoLibraryActionBar() { const hasSelection = selectionCount > 0 const hasAssets = totalCount > 0 const canSelectAll = hasAssets && selectionCount < totalCount + const selectAllLabel = hasAssets + ? canSelectAll + ? t(photoLibraryActionKeys.selectAll) + : t(photoLibraryActionKeys.allSelected) + : t(photoLibraryActionKeys.selectAll) const selectedAssets = useMemo(() => { if (!assets || assets.length === 0 || selectedIds.length === 0) { return emptyArray @@ -107,8 +125,8 @@ export function PhotoLibraryActionBar() { className="flex items-center gap-1 text-xs sm:text-sm" > - 上传文件 - 上传 + {t(photoLibraryActionKeys.upload)} + {t(photoLibraryActionKeys.uploadShort)} @@ -125,7 +143,7 @@ export function PhotoLibraryActionBar() { 'bg-accent/10 text-accent', )} > - 已选 {selectionCount} 项 + {t(photoLibraryActionKeys.selectedCount, { count: selectionCount })} diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx index e9c75319..9cda34ee 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx @@ -13,6 +13,7 @@ import { useAtomValue } from 'jotai' import { DynamicIcon } from 'lucide-react/dynamic' import type { ReactNode } from 'react' import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/shallow' import { viewportAtom } from '~/atoms/viewport' @@ -30,16 +31,35 @@ import type { DeleteAssetOptions } from './types' type PhotoLibrarySortBy = 'uploadedAt' | 'capturedAt' type PhotoLibrarySortOrder = 'desc' | 'asc' -const SORT_BY_OPTIONS: { value: PhotoLibrarySortBy; label: string; icon: string }[] = [ - { value: 'uploadedAt', label: '按上传时间', icon: 'upload' }, - { value: 'capturedAt', label: '按拍摄时间', icon: 'camera' }, +const SORT_BY_OPTIONS: { value: PhotoLibrarySortBy; labelKey: I18nKeys; icon: string }[] = [ + { value: 'uploadedAt', labelKey: 'photos.library.sort.by-uploaded', icon: 'upload' }, + { value: 'capturedAt', labelKey: 'photos.library.sort.by-captured', icon: 'camera' }, ] -const SORT_ORDER_OPTIONS: { value: PhotoLibrarySortOrder; label: string; icon: string }[] = [ - { value: 'desc', label: '最新优先', icon: 'arrow-down' }, - { value: 'asc', label: '最早优先', icon: 'arrow-up' }, +const SORT_ORDER_OPTIONS: { value: PhotoLibrarySortOrder; labelKey: I18nKeys; icon: string }[] = [ + { value: 'desc', labelKey: 'photos.library.sort.order-desc', icon: 'arrow-down' }, + { value: 'asc', labelKey: 'photos.library.sort.order-asc', icon: 'arrow-up' }, ] +const photoLibraryGridKeys = { + card: { + deviceUnknown: 'photos.library.card.device-unknown', + sizeUnknown: 'photos.library.card.size-unknown', + noPreview: 'photos.library.card.no-preview', + selected: 'photos.library.card.selected', + select: 'photos.library.card.select', + }, + deletePrompt: { + title: 'photos.library.delete.title', + description: 'photos.library.delete.description', + confirm: 'photos.library.delete.confirm', + cancel: 'photos.library.delete.cancel', + }, +} as const satisfies { + card: Record<'deviceUnknown' | 'sizeUnknown' | 'noPreview' | 'selected' | 'select', I18nKeys> + deletePrompt: Record<'title' | 'description' | 'confirm' | 'cancel', I18nKeys> +} + function parseDate(value?: string | number | null) { if (!value) return 0 if (typeof value === 'number') { @@ -73,27 +93,29 @@ function PhotoGridItem({ onEditTags: (asset: PhotoAssetListItem) => void isDeleting?: boolean }) { + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' const manifest = asset.manifest?.data const previewUrl = manifest?.thumbnailUrl ?? manifest?.originalUrl ?? asset.publicUrl - const deviceLabel = manifest?.exif?.Model || manifest?.exif?.Make || '未知设备' - const updatedAtLabel = new Date(asset.updatedAt).toLocaleString() + const deviceLabel = manifest?.exif?.Model || manifest?.exif?.Make || t(photoLibraryGridKeys.card.deviceUnknown) + const updatedAtLabel = new Date(asset.updatedAt).toLocaleString(locale) const fileSizeLabel = asset.size !== null && asset.size !== undefined - ? `${(asset.size / (1024 * 1024)).toFixed(2)} MB` + ? `${(asset.size / (1024 * 1024)).toLocaleString(locale, { maximumFractionDigits: 2 })} MB` : manifest?.size - ? `${(manifest.size / (1024 * 1024)).toFixed(2)} MB` - : '未知大小' + ? `${(manifest.size / (1024 * 1024)).toLocaleString(locale, { maximumFractionDigits: 2 })} MB` + : t(photoLibraryGridKeys.card.sizeUnknown) const assetLabel = manifest?.title ?? manifest?.id ?? asset.photoId const handleDelete = () => { let deleteFromStorage = false Prompt.prompt({ - title: '确认删除该资源?', - description: `删除后将无法恢复,是否继续删除「${assetLabel}」?如需同时删除远程存储文件,可勾选下方选项。`, + title: t(photoLibraryGridKeys.deletePrompt.title), + description: t(photoLibraryGridKeys.deletePrompt.description, { name: assetLabel }), variant: 'danger', - onConfirmText: '删除', - onCancelText: '取消', + onConfirmText: t(photoLibraryGridKeys.deletePrompt.confirm), + onCancelText: t(photoLibraryGridKeys.deletePrompt.cancel), content: ( { @@ -144,7 +166,7 @@ function PhotoGridItem({ ) : (
- 无法预览 + {t(photoLibraryGridKeys.card.noPreview)}
)} @@ -164,7 +186,7 @@ function PhotoGridItem({ )} > - {isSelected ? '已选择' : '选择'} + {isSelected ? t(photoLibraryGridKeys.card.selected) : t(photoLibraryGridKeys.card.select)} @@ -209,14 +231,14 @@ function PhotoGridItem({ icon={} onSelect={() => onEditTags(asset)} > - 编辑标签 + {t('photos.library.card.edit-tags')} } disabled={!manifest} onSelect={handleViewExif} > - 查看 EXIF + {t('photos.library.card.view-exif')}
- 删除资源 + {t('photos.library.card.delete')} @@ -237,6 +259,7 @@ function PhotoGridItem({ } export function PhotoLibraryGrid() { + const { t } = useTranslation() const viewport = useAtomValue(viewportAtom) const columnWidth = viewport.sm ? 320 : 160 const [sortBy, setSortBy] = useState('uploadedAt') @@ -289,8 +312,8 @@ export function PhotoLibraryGrid() { } else if (!sortedAssets || sortedAssets.length === 0) { content = ( -

当前没有图片资源

-

使用右上角的"上传图片"按钮可以为图库添加新的照片。

+

{t('photos.library.empty.title')}

+

{t('photos.library.empty.description')}

) } else { @@ -322,7 +345,7 @@ export function PhotoLibraryGrid() { return (
-
+
@@ -344,7 +367,7 @@ export function PhotoLibraryGrid() { icon={} onSelect={() => setSortBy(option.value)} > - {option.label} + {t(option.labelKey)} ))} @@ -359,7 +382,7 @@ export function PhotoLibraryGrid() { className="hover:bg-background-secondary/70 flex items-center gap-1.5 rounded-full border px-3 h-8 text-text" > - {currentSortOrder.label} + {t(currentSortOrder.labelKey)} @@ -371,7 +394,7 @@ export function PhotoLibraryGrid() { icon={} onSelect={() => setSortOrder(option.value)} > - {option.label} + {t(option.labelKey)} ))} diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoTagEditorModal.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoTagEditorModal.tsx index 7be5d990..ed41da5e 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoTagEditorModal.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoTagEditorModal.tsx @@ -1,6 +1,7 @@ import type { ModalComponent } from '@afilmory/ui' import { Button, DialogDescription, DialogFooter, DialogHeader, DialogTitle, LinearDivider } from '@afilmory/ui' import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { getRequestErrorMessage } from '~/lib/errors' @@ -9,6 +10,45 @@ import { useUpdatePhotoTagsMutation } from '../../hooks' import type { PhotoAssetListItem } from '../../types' import { AutoSelect } from './photo-upload/AutoSelect' +const photoTagsKeys = { + modalTitle: 'photos.library.tags.modal.title', + modalDescriptionMultiple: 'photos.library.tags.modal.description.multiple', + modalDescriptionSingle: 'photos.library.tags.modal.description.single', + pathSample: 'photos.library.tags.modal.path.sample', + pathPreview: 'photos.library.tags.modal.path.preview', + pathHint: 'photos.library.tags.modal.path.hint', + inputPlaceholder: 'photos.library.tags.modal.input', + toastSuccessMulti: 'photos.library.tags.toast.multi-success', + toastSuccessSingle: 'photos.library.tags.toast.single-success', + toastSuccessDescription: 'photos.library.tags.toast.success-description', + toastErrorTitle: 'photos.library.tags.toast.error', + toastErrorDescription: 'photos.library.tags.toast.error-description', + cancel: 'photos.library.tags.modal.cancel', + save: 'photos.library.tags.modal.save', + saving: 'photos.library.tags.modal.saving', + noSelection: 'photos.library.tags.modal.no-selection', + assetCount: 'photos.library.tags.modal.asset-count', +} as const satisfies Record< + | 'modalTitle' + | 'modalDescriptionMultiple' + | 'modalDescriptionSingle' + | 'pathSample' + | 'pathPreview' + | 'pathHint' + | 'inputPlaceholder' + | 'toastSuccessMulti' + | 'toastSuccessSingle' + | 'toastSuccessDescription' + | 'toastErrorTitle' + | 'toastErrorDescription' + | 'cancel' + | 'save' + | 'saving' + | 'noSelection' + | 'assetCount', + I18nKeys +> + type PhotoTagEditorModalProps = { assets: PhotoAssetListItem[] availableTags: string[] @@ -18,6 +58,7 @@ const arraysEqual = (a: string[], b: string[]): boolean => a.length === b.length && a.every((value, index) => value === b[index]) export const PhotoTagEditorModal: ModalComponent = ({ assets, availableTags, dismiss }) => { + const { t } = useTranslation() const updateTagsMutation = useUpdatePhotoTagsMutation() const [isSaving, setIsSaving] = useState(false) const initialTags = useMemo(() => { @@ -60,12 +101,12 @@ export const PhotoTagEditorModal: ModalComponent = ({ const isBusy = isSaving || updateTagsMutation.isPending const assetTitle = useMemo(() => { - if (assets.length === 0) return '未选择资源' + if (assets.length === 0) return t(photoTagsKeys.noSelection) if (!isMultiEdit) { const single = assets[0] return single.manifest?.data?.title ?? single.photoId } - return `${assets.length} 个资源` + return t(photoTagsKeys.assetCount, { count: assets.length }) }, [assets, isMultiEdit]) const handleSave = async () => { @@ -78,13 +119,18 @@ export const PhotoTagEditorModal: ModalComponent = ({ for (const asset of assets) { await updateTagsMutation.mutateAsync({ id: asset.id, tags }) } - toast.success(isMultiEdit ? `已更新 ${assets.length} 个资源的标签` : '标签已更新', { - description: '远程存储路径已同步到新的标签目录。', - }) + toast.success( + isMultiEdit + ? t(photoTagsKeys.toastSuccessMulti, { count: assets.length }) + : t(photoTagsKeys.toastSuccessSingle), + { + description: t(photoTagsKeys.toastSuccessDescription), + }, + ) dismiss?.() } catch (error) { - toast.error('更新标签失败', { - description: getRequestErrorMessage(error, '请稍后重试。'), + toast.error(t(photoTagsKeys.toastErrorTitle), { + description: getRequestErrorMessage(error, t(photoTagsKeys.toastErrorDescription)), }) } finally { setIsSaving(false) @@ -94,20 +140,17 @@ export const PhotoTagEditorModal: ModalComponent = ({ return (
- 修改「{assetTitle}」的标签 + {t(photoTagsKeys.modalTitle, { name: assetTitle })} - 标签同时决定远程存储的目录结构, - {isMultiEdit - ? '所有选中资源都会应用同样的标签。' - : '调整后将自动移动原图文件(及其 Live Photo 视频)到新的路径。'} + {isMultiEdit ? t(photoTagsKeys.modalDescriptionMultiple) : t(photoTagsKeys.modalDescriptionSingle)} {nextPathPreview ? (
- {isMultiEdit ? '示例存储路径(第一项)' : '新存储路径预览'} - (基于标签顺序) + {isMultiEdit ? t(photoTagsKeys.pathSample) : t(photoTagsKeys.pathPreview)} + {t(photoTagsKeys.pathHint)}

{nextPathPreview}

@@ -117,7 +160,7 @@ export const PhotoTagEditorModal: ModalComponent = ({ options={tagOptions} value={tags} onChange={setTags} - placeholder="输入后按 Enter 添加,或从常用标签中选择" + placeholder={t(photoTagsKeys.inputPlaceholder)} disabled={isBusy} /> @@ -131,7 +174,7 @@ export const PhotoTagEditorModal: ModalComponent = ({ onClick={dismiss} className="text-text-secondary hover:text-text" > - 取消 + {t(photoTagsKeys.cancel)}
diff --git a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx index b2e19772..c73d4713 100644 --- a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx +++ b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncActions.tsx @@ -1,6 +1,7 @@ import { Button } from '@afilmory/ui' import { useMutation } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { usePhotoSyncAutoRunValue, useSetPhotoSyncAutoRun } from '~/atoms/photo-sync' @@ -11,7 +12,27 @@ import { runPhotoSync } from '../../api' import type { RunPhotoSyncPayload } from '../../types' import { usePhotoSyncController } from './PhotoSyncControllerContext' +const photoSyncActionKeys = { + toastSuccessPreview: 'photos.sync.actions.toast.preview-success', + toastSuccessApply: 'photos.sync.actions.toast.apply-success', + toastSuccessDescription: 'photos.sync.actions.toast.success-description', + toastErrorTitle: 'photos.sync.actions.toast.error-title', + toastErrorDescription: 'photos.sync.actions.toast.error-description', + buttonPreview: 'photos.sync.actions.button.preview', + buttonApply: 'photos.sync.actions.button.apply', +} as const satisfies Record< + | 'toastSuccessPreview' + | 'toastSuccessApply' + | 'toastSuccessDescription' + | 'toastErrorTitle' + | 'toastErrorDescription' + | 'buttonPreview' + | 'buttonApply', + I18nKeys +> + export function PhotoSyncActions() { + const { t } = useTranslation() const { onCompleted, onProgress, onError } = usePhotoSyncController() const { setHeaderActionState } = useMainPageLayout() const [pendingMode, setPendingMode] = useState<'dry-run' | 'apply' | null>(null) @@ -45,15 +66,23 @@ export function PhotoSyncActions() { onSuccess: (data, variables) => { onCompleted(data, { dryRun: variables.dryRun ?? false }) const { inserted, updated, conflicts, errors } = data.summary - toast.success(variables.dryRun ? '同步预览完成' : '照片同步完成', { - description: `新增 ${inserted} · 更新 ${updated} · 冲突 ${conflicts} · 错误 ${errors}`, - }) + toast.success( + variables.dryRun ? t(photoSyncActionKeys.toastSuccessPreview) : t(photoSyncActionKeys.toastSuccessApply), + { + description: t(photoSyncActionKeys.toastSuccessDescription, { + inserted, + updated, + conflicts, + errors, + }), + }, + ) }, onError: (error) => { - const normalizedError = error instanceof Error ? error : new Error('照片同步失败,请稍后重试。') + const normalizedError = error instanceof Error ? error : new Error(t(photoSyncActionKeys.toastErrorDescription)) const message = getRequestErrorMessage(error, normalizedError.message) - toast.error('同步失败', { description: message }) + toast.error(t(photoSyncActionKeys.toastErrorTitle), { description: message }) onError(normalizedError) }, onSettled: () => { @@ -97,7 +126,7 @@ export function PhotoSyncActions() { isLoading={isPending && pendingMode === 'dry-run'} onClick={() => handleSync(true)} > - 预览同步 + {t(photoSyncActionKeys.buttonPreview)}
) diff --git a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncConflictsPanel.tsx b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncConflictsPanel.tsx index c9e1600f..54fa9a0e 100644 --- a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncConflictsPanel.tsx +++ b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncConflictsPanel.tsx @@ -11,6 +11,137 @@ import { getConflictTypeLabel, PHOTO_CONFLICT_TYPE_CONFIG } from '../../constant import type { PhotoSyncConflict, PhotoSyncResolution, PhotoSyncSnapshot } from '../../types' import { BorderOverlay, MetadataSnapshot } from './PhotoSyncResultPanel' +const photoSyncConflictsKeys = { + title: 'photos.sync.conflicts.title', + description: 'photos.sync.conflicts.description', + total: 'photos.sync.conflicts.total', + selection: { + selected: 'photos.sync.conflicts.selection.selected', + none: 'photos.sync.conflicts.selection.none', + clear: 'photos.sync.conflicts.selection.clear', + }, + strategy: { + storage: 'photos.sync.conflicts.strategy.storage', + database: 'photos.sync.conflicts.strategy.database', + }, + actions: { + selectedStorage: 'photos.sync.conflicts.actions.selected-storage', + selectedDatabase: 'photos.sync.conflicts.actions.selected-database', + allStorage: 'photos.sync.conflicts.actions.all-storage', + allDatabase: 'photos.sync.conflicts.actions.all-database', + preferStorage: 'photos.sync.conflicts.actions.prefer-storage', + preferDatabase: 'photos.sync.conflicts.actions.prefer-database', + viewDetails: 'photos.sync.conflicts.actions.view-details', + hideDetails: 'photos.sync.conflicts.actions.hide-details', + clearSelection: 'photos.sync.conflicts.actions.clear-selection', + openStorage: 'photos.sync.conflicts.actions.open-storage', + viewOriginal: 'photos.sync.conflicts.actions.view-original', + }, + prompts: { + title: 'photos.sync.conflicts.prompts.title', + confirm: 'photos.sync.conflicts.prompts.confirm', + cancel: 'photos.sync.conflicts.prompts.cancel', + scopeAll: 'photos.sync.conflicts.prompts.scope-all', + scopeSelected: 'photos.sync.conflicts.prompts.scope-selected', + bulk: 'photos.sync.conflicts.prompts.bulk', + single: 'photos.sync.conflicts.prompts.single', + }, + toast: { + selectRequired: 'photos.sync.conflicts.toast.select-required', + none: 'photos.sync.conflicts.toast.none', + noOriginal: 'photos.sync.conflicts.toast.no-original', + openStorageFailed: 'photos.sync.conflicts.toast.open-storage-failed', + }, + info: { + lastUpdated: 'photos.sync.conflicts.info.last-updated', + firstDetected: 'photos.sync.conflicts.info.first-detected', + storageKey: 'photos.sync.conflicts.info.storage-key', + conflictKey: 'photos.sync.conflicts.info.conflict-key', + photoIdFallback: 'photos.sync.conflicts.info.photo-id-fallback', + }, + preview: { + databaseTitle: 'photos.sync.conflicts.preview.database.title', + databaseEmpty: 'photos.sync.conflicts.preview.database.empty', + storageTitle: 'photos.sync.conflicts.preview.storage.title', + storageKey: 'photos.sync.conflicts.preview.storage.key', + idLabel: 'photos.sync.conflicts.preview.common.id', + dimensions: 'photos.sync.conflicts.preview.common.dimensions', + size: 'photos.sync.conflicts.preview.common.size', + updatedAt: 'photos.sync.conflicts.preview.common.updated-at', + }, + metadata: { + database: 'photos.sync.metadata.database', + storage: 'photos.sync.metadata.storage', + size: 'photos.sync.metadata.size', + etag: 'photos.sync.metadata.etag', + updatedAt: 'photos.sync.metadata.updated-at', + hash: 'photos.sync.metadata.hash', + unknown: 'photos.sync.metadata.unknown', + none: 'photos.sync.metadata.none', + }, +} as const satisfies { + title: I18nKeys + description: I18nKeys + total: I18nKeys + selection: { selected: I18nKeys; none: I18nKeys; clear: I18nKeys } + strategy: { storage: I18nKeys; database: I18nKeys } + actions: { + selectedStorage: I18nKeys + selectedDatabase: I18nKeys + allStorage: I18nKeys + allDatabase: I18nKeys + preferStorage: I18nKeys + preferDatabase: I18nKeys + viewDetails: I18nKeys + hideDetails: I18nKeys + clearSelection: I18nKeys + openStorage: I18nKeys + viewOriginal: I18nKeys + } + prompts: { + title: I18nKeys + confirm: I18nKeys + cancel: I18nKeys + scopeAll: I18nKeys + scopeSelected: I18nKeys + bulk: I18nKeys + single: I18nKeys + } + toast: { + selectRequired: I18nKeys + none: I18nKeys + noOriginal: I18nKeys + openStorageFailed: I18nKeys + } + info: { + lastUpdated: I18nKeys + firstDetected: I18nKeys + storageKey: I18nKeys + conflictKey: I18nKeys + photoIdFallback: I18nKeys + } + preview: { + databaseTitle: I18nKeys + databaseEmpty: I18nKeys + storageTitle: I18nKeys + storageKey: I18nKeys + idLabel: I18nKeys + dimensions: I18nKeys + size: I18nKeys + updatedAt: I18nKeys + } + metadata: { + database: I18nKeys + storage: I18nKeys + size: I18nKeys + etag: I18nKeys + updatedAt: I18nKeys + hash: I18nKeys + unknown: I18nKeys + none: I18nKeys + } +} + type PhotoSyncConflictsPanelProps = { conflicts?: PhotoSyncConflict[] isLoading?: boolean @@ -21,14 +152,6 @@ type PhotoSyncConflictsPanelProps = { onRequestStorageUrl?: (storageKey: string) => Promise } -function formatDate(value: string) { - try { - return new Date(value).toLocaleString() - } catch { - return value - } -} - export function PhotoSyncConflictsPanel({ conflicts, isLoading, @@ -38,7 +161,26 @@ export function PhotoSyncConflictsPanel({ onResolveBatch, onRequestStorageUrl, }: PhotoSyncConflictsPanelProps) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' + const dateTimeFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }), + [locale], + ) + const formatDate = (value: string | null | undefined) => { + if (!value) { + return t('common.unknown') + } + try { + return dateTimeFormatter.format(new Date(value)) + } catch { + return value + } + } const sortedConflicts = useMemo(() => { if (!conflicts) return [] return conflicts.toSorted((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) @@ -121,19 +263,20 @@ export function PhotoSyncConflictsPanel({ const url = await onRequestStorageUrl(storageKey) window.open(url, '_blank', 'noopener,noreferrer') } catch (error) { - const message = getRequestErrorMessage(error, '无法打开存储对象') - toast.error('无法打开存储对象', { description: message }) + const fallback = t(photoSyncConflictsKeys.toast.openStorageFailed) + const message = getRequestErrorMessage(error, fallback) + toast.error(fallback, { description: message }) } } const handleOpenManifest = (manifest?: PhotoSyncConflict['manifest']['data']) => { if (!manifest) { - toast.info('当前记录没有原图链接') + toast.info(t(photoSyncConflictsKeys.toast.noOriginal)) return } const candidate = manifest.originalUrl ?? manifest.thumbnailUrl if (!candidate) { - toast.info('当前记录没有原图链接') + toast.info(t(photoSyncConflictsKeys.toast.noOriginal)) return } window.open(candidate, '_blank', 'noopener,noreferrer') @@ -145,7 +288,7 @@ export function PhotoSyncConflictsPanel({ shouldClearSelection: boolean, ) => { if (targets.length === 0) { - toast.info('请先选择需要处理的冲突条目') + toast.info(t(photoSyncConflictsKeys.toast.selectRequired)) return } @@ -156,7 +299,7 @@ export function PhotoSyncConflictsPanel({ setSelectedIds(new Set()) } } catch { - // 错误提示交由上层处理 + // Error handling handled by caller } return } @@ -176,10 +319,10 @@ export function PhotoSyncConflictsPanel({ const confirmAction = (message: string, onConfirm: () => void | Promise) => { Prompt.prompt({ - title: '确认操作', + title: t(photoSyncConflictsKeys.prompts.title), description: message, - onConfirmText: '确认', - onCancelText: '取消', + onConfirmText: t(photoSyncConflictsKeys.prompts.confirm), + onCancelText: t(photoSyncConflictsKeys.prompts.cancel), onConfirm: async () => { await onConfirm() }, @@ -187,21 +330,32 @@ export function PhotoSyncConflictsPanel({ } const getStrategyLabel = (strategy: PhotoSyncResolution) => - strategy === 'prefer-storage' ? '以存储为准' : '以数据库为准' + strategy === 'prefer-storage' + ? t(photoSyncConflictsKeys.strategy.storage) + : t(photoSyncConflictsKeys.strategy.database) const buildBulkConfirmMessage = (strategy: PhotoSyncResolution, scope: 'all' | 'selected', count: number) => { - const scopeLabel = scope === 'all' ? '全部待处理冲突' : `选中的 ${count} 个冲突` - return `确认要将${scopeLabel}${getStrategyLabel(strategy)}处理吗?` + const scopeLabel = + scope === 'all' + ? t(photoSyncConflictsKeys.prompts.scopeAll) + : t(photoSyncConflictsKeys.prompts.scopeSelected, { count }) + return t(photoSyncConflictsKeys.prompts.bulk, { + scope: scopeLabel, + strategy: getStrategyLabel(strategy), + }) } const buildSingleConfirmMessage = (strategy: PhotoSyncResolution, conflict: PhotoSyncConflict) => { const identifier = conflict.photoId ?? conflict.id - return `确认要将冲突 ${identifier}${getStrategyLabel(strategy)}处理吗?` + return t(photoSyncConflictsKeys.prompts.single, { + identifier, + strategy: getStrategyLabel(strategy), + }) } const handleAcceptSelected = async (strategy: PhotoSyncResolution) => { if (selectedConflicts.length === 0) { - toast.info('请先选择需要处理的冲突条目') + toast.info(t(photoSyncConflictsKeys.toast.selectRequired)) return } @@ -212,7 +366,7 @@ export function PhotoSyncConflictsPanel({ const handleAcceptAll = async (strategy: PhotoSyncResolution) => { if (sortedConflicts.length === 0) { - toast.info('当前没有待处理的冲突条目') + toast.info(t(photoSyncConflictsKeys.toast.none)) return } @@ -247,12 +401,12 @@ export function PhotoSyncConflictsPanel({
-

待处理冲突

-

- 这些冲突需要手动确认处理方式,可以批量选择以提升处理效率。 -

+

{t(photoSyncConflictsKeys.title)}

+

{t(photoSyncConflictsKeys.description)}

- 总计:{sortedConflicts.length} + + {t(photoSyncConflictsKeys.total, { count: sortedConflicts.length })} +
{isLoading ? ( @@ -271,11 +425,13 @@ export function PhotoSyncConflictsPanel({ onCheckedChange={(checked) => toggleAllSelection(Boolean(checked))} /> - {hasSelection ? `已选 ${selectedIds.size} 项` : '未选择条目'} + {hasSelection + ? t(photoSyncConflictsKeys.selection.selected, { count: selectedIds.size }) + : t(photoSyncConflictsKeys.selection.none)} {hasSelection ? ( ) : null}
@@ -289,7 +445,7 @@ export function PhotoSyncConflictsPanel({ disabled={isProcessing} onClick={() => void handleAcceptSelected('prefer-storage')} > - 选中存储为准 + {t(photoSyncConflictsKeys.actions.selectedStorage)} ) : ( @@ -310,7 +466,7 @@ export function PhotoSyncConflictsPanel({ disabled={isProcessing || sortedConflicts.length === 0} onClick={() => void handleAcceptAll('prefer-storage')} > - 全部以存储为准 + {t(photoSyncConflictsKeys.actions.allStorage)} )} @@ -359,25 +515,31 @@ export function PhotoSyncConflictsPanel({ {typeLabel} - {conflict.photoId ?? '未绑定 Photo ID'} + + {conflict.photoId ?? t(photoSyncConflictsKeys.info.photoIdFallback)} + {typeConfig ? ( {t(typeConfig.descriptionKey)} ) : null}
- 上次更新:{formatDate(conflict.updatedAt)} - 首次检测:{formatDate(conflict.syncedAt)} + + {t(photoSyncConflictsKeys.info.lastUpdated, { time: formatDate(conflict.updatedAt) })} + + + {t(photoSyncConflictsKeys.info.firstDetected, { time: formatDate(conflict.syncedAt) })} +
- 存储 Key: + {t(photoSyncConflictsKeys.info.storageKey)} {conflict.storageKey} {payload?.incomingStorageKey ? ( - 冲突 Key: + {t(photoSyncConflictsKeys.info.conflictKey)} {payload.incomingStorageKey} ) : null} @@ -400,11 +562,11 @@ export function PhotoSyncConflictsPanel({
-

元数据(数据库)

+

{t(photoSyncConflictsKeys.metadata.database)}

-

元数据(存储)

+

{t(photoSyncConflictsKeys.metadata.storage)}

@@ -419,7 +581,7 @@ export function PhotoSyncConflictsPanel({ disabled={isResolving || isProcessing} onClick={() => void handleResolve(conflict, 'prefer-storage')} > - 以存储为准 + {t(photoSyncConflictsKeys.actions.preferStorage)}
@@ -462,19 +626,33 @@ function ConflictManifestPreview({ disabled?: boolean onOpenOriginal?: () => void }) { + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' + const dateTimeFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }), + [locale], + ) if (!manifest) { return (
-

数据库记录

-

暂无数据库记录

+

{t(photoSyncConflictsKeys.preview.databaseTitle)}

+

{t(photoSyncConflictsKeys.preview.databaseEmpty)}

) } - const dimensions = manifest.width && manifest.height ? `${manifest.width} × ${manifest.height}` : '未知' + const dimensions = manifest.width && manifest.height ? `${manifest.width} × ${manifest.height}` : t('common.unknown') const sizeMB = - typeof manifest.size === 'number' && manifest.size > 0 ? `${(manifest.size / (1024 * 1024)).toFixed(2)} MB` : '未知' - const updatedAt = manifest.lastModified ? new Date(manifest.lastModified).toLocaleString() : '未知' + typeof manifest.size === 'number' && manifest.size > 0 + ? `${(manifest.size / (1024 * 1024)).toLocaleString(locale, { maximumFractionDigits: 2 })} MB` + : t('common.unknown') + const updatedAt = manifest.lastModified + ? dateTimeFormatter.format(new Date(manifest.lastModified)) + : t('common.unknown') return (
@@ -483,28 +661,28 @@ function ConflictManifestPreview({ {manifest.id} ) : null}
-

数据库记录

+

{t(photoSyncConflictsKeys.preview.databaseTitle)}

- ID: + {t(photoSyncConflictsKeys.preview.idLabel)} {manifest.id}
- 尺寸: + {t(photoSyncConflictsKeys.preview.dimensions)} {dimensions}
- 大小: + {t(photoSyncConflictsKeys.preview.size)} {sizeMB}
- 更新时间: + {t(photoSyncConflictsKeys.preview.updatedAt)} {updatedAt}
{onOpenOriginal ? ( ) : null} @@ -522,18 +700,19 @@ function ConflictStoragePreview({ disabled?: boolean onOpenStorage?: () => void }) { + const { t } = useTranslation() return (
-

存储对象

+

{t(photoSyncConflictsKeys.preview.storageTitle)}

{onOpenStorage ? ( ) : null}

- Key: + {t(photoSyncConflictsKeys.preview.storageKey)} {storageKey}

diff --git a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncProgressPanel.tsx b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncProgressPanel.tsx index 0b39b833..37278d54 100644 --- a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncProgressPanel.tsx +++ b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncProgressPanel.tsx @@ -1,28 +1,105 @@ import { Spring } from '@afilmory/utils' import { m } from 'motion/react' - -import { getI18n } from '~/i18n' +import { useTranslation } from 'react-i18next' import { getActionTypeMeta, getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants' import type { PhotoSyncAction, PhotoSyncLogLevel, PhotoSyncProgressStage, PhotoSyncProgressState } from '../../types' import { BorderOverlay } from './PhotoSyncResultPanel' -const STAGE_CONFIG: Record = { +const photoSyncProgressKeys = { + heading: { + error: 'photos.sync.progress.heading.error', + preview: 'photos.sync.progress.heading.preview', + running: 'photos.sync.progress.heading.running', + }, + subtitle: { + error: 'photos.sync.progress.subtitle.error', + preview: 'photos.sync.progress.subtitle.preview', + running: 'photos.sync.progress.subtitle.running', + }, + status: { + error: 'photos.sync.progress.status.error', + running: 'photos.sync.progress.status.running', + }, + stages: { + missing: { + label: 'photos.sync.progress.stages.missing.label', + description: 'photos.sync.progress.stages.missing.description', + }, + orphan: { + label: 'photos.sync.progress.stages.orphan.label', + description: 'photos.sync.progress.stages.orphan.description', + }, + conflicts: { + label: 'photos.sync.progress.stages.conflicts.label', + description: 'photos.sync.progress.stages.conflicts.description', + }, + reconciliation: { + label: 'photos.sync.progress.stages.reconciliation.label', + description: 'photos.sync.progress.stages.reconciliation.description', + }, + }, + stageStatus: { + pending: 'photos.sync.progress.stage-status.pending', + running: 'photos.sync.progress.stage-status.running', + completed: 'photos.sync.progress.stage-status.completed', + }, + logs: { + title: 'photos.sync.progress.logs.title', + recent: 'photos.sync.progress.logs.recent', + }, + logLevels: { + info: 'photos.sync.progress.logs.level.info', + success: 'photos.sync.progress.logs.level.success', + warn: 'photos.sync.progress.logs.level.warn', + error: 'photos.sync.progress.logs.level.error', + }, + logDetails: { + result: 'photos.sync.progress.logs.detail.result', + manifest: 'photos.sync.progress.logs.detail.manifest', + manifestAbsent: 'photos.sync.progress.logs.detail.manifest-absent', + livePhoto: 'photos.sync.progress.logs.detail.live-photo', + livePhotoAbsent: 'photos.sync.progress.logs.detail.live-photo-absent', + error: 'photos.sync.progress.logs.detail.error', + }, + recent: { + title: 'photos.sync.progress.recent.title', + progress: 'photos.sync.progress.recent.progress', + noFurther: 'photos.sync.progress.recent.no-further', + }, +} as const satisfies { + heading: Record<'error' | 'preview' | 'running', I18nKeys> + subtitle: Record<'error' | 'preview' | 'running', I18nKeys> + status: Record<'error' | 'running', I18nKeys> + stages: { + missing: { label: I18nKeys; description: I18nKeys } + orphan: { label: I18nKeys; description: I18nKeys } + conflicts: { label: I18nKeys; description: I18nKeys } + reconciliation: { label: I18nKeys; description: I18nKeys } + } + stageStatus: Record<'pending' | 'running' | 'completed', I18nKeys> + logs: { title: I18nKeys; recent: I18nKeys } + logLevels: Record<'info' | 'success' | 'warn' | 'error', I18nKeys> + logDetails: Record<'result' | 'manifest' | 'manifestAbsent' | 'livePhoto' | 'livePhotoAbsent' | 'error', I18nKeys> + recent: Record<'title' | 'progress' | 'noFurther', I18nKeys> +} + +const STAGE_CONFIG: Record = { 'missing-in-db': { - label: '导入新照片', - description: '将存储中新对象同步至数据库', + labelKey: photoSyncProgressKeys.stages.missing.label, + descriptionKey: photoSyncProgressKeys.stages.missing.description, }, 'orphan-in-db': { - label: '识别孤立记录', - description: '标记数据库中缺失存储对象的条目', + labelKey: photoSyncProgressKeys.stages.orphan.label, + descriptionKey: photoSyncProgressKeys.stages.orphan.description, }, 'metadata-conflicts': { - label: '校验元数据', - description: '检测存储与数据库之间的元数据差异', + labelKey: photoSyncProgressKeys.stages.conflicts.label, + descriptionKey: photoSyncProgressKeys.stages.conflicts.description, }, 'status-reconciliation': { - label: '状态对齐', - description: '更新记录状态以匹配最新元数据', + labelKey: photoSyncProgressKeys.stages.reconciliation.label, + descriptionKey: photoSyncProgressKeys.stages.reconciliation.description, }, } @@ -33,17 +110,29 @@ const STAGE_ORDER: PhotoSyncProgressStage[] = [ 'status-reconciliation', ] -const STATUS_LABEL: Record = { - pending: '等待中', - running: '进行中', - completed: '已完成', +const STATUS_LABEL: Record = { + pending: photoSyncProgressKeys.stageStatus.pending, + running: photoSyncProgressKeys.stageStatus.running, + completed: photoSyncProgressKeys.stageStatus.completed, } -const LOG_LEVEL_CONFIG: Record = { - info: { label: '信息', className: 'border border-sky-500/30 bg-sky-500/10 text-sky-200' }, - success: { label: '成功', className: 'border border-emerald-500/30 bg-emerald-500/10 text-emerald-200' }, - warn: { label: '警告', className: 'border border-amber-500/30 bg-amber-500/10 text-amber-200' }, - error: { label: '错误', className: 'border border-rose-500/30 bg-rose-500/10 text-rose-200' }, +const LOG_LEVEL_CONFIG: Record = { + info: { + labelKey: photoSyncProgressKeys.logLevels.info, + className: 'border border-sky-500/30 bg-sky-500/10 text-sky-200', + }, + success: { + labelKey: photoSyncProgressKeys.logLevels.success, + className: 'border border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + }, + warn: { + labelKey: photoSyncProgressKeys.logLevels.warn, + className: 'border border-amber-500/30 bg-amber-500/10 text-amber-200', + }, + error: { + labelKey: photoSyncProgressKeys.logLevels.error, + className: 'border border-rose-500/30 bg-rose-500/10 text-rose-200', + }, } const SUMMARY_FIELDS: Array<{ @@ -84,14 +173,19 @@ function formatActionLabel(action: PhotoSyncAction) { } export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps) { + const { t } = useTranslation() const isErrored = Boolean(progress.error) - const heading = isErrored ? '同步失败' : progress.dryRun ? '同步预览进行中' : '同步进行中' - const subtitle = isErrored - ? '同步过程中发生错误,请查看错误信息后重试。' + const heading = isErrored + ? t(photoSyncProgressKeys.heading.error) : progress.dryRun - ? '当前正在模拟同步操作,结果仅用于预览,数据库不会发生变更。' - : '正在对齐存储与数据库数据,请保持页面打开,稍后将展示同步结果。' - const statusText = isErrored ? '已终止' : '进行中' + ? t(photoSyncProgressKeys.heading.preview) + : t(photoSyncProgressKeys.heading.running) + const subtitle = isErrored + ? t(photoSyncProgressKeys.subtitle.error) + : progress.dryRun + ? t(photoSyncProgressKeys.subtitle.preview) + : t(photoSyncProgressKeys.subtitle.running) + const statusText = isErrored ? t(photoSyncProgressKeys.status.error) : t(photoSyncProgressKeys.status.running) const stageItems = STAGE_ORDER.map((stage) => { const stageState = progress.stages[stage] @@ -111,9 +205,8 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps } }) - const i18n = getI18n() const summaryItems = SUMMARY_FIELDS.map((field) => ({ - label: i18n.t(field.labelKey), + label: t(field.labelKey), value: progress.summary[field.key], })) @@ -143,10 +236,10 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
-

{item.config.label}

-

{item.config.description}

+

{t(item.config.labelKey)}

+

{t(item.config.descriptionKey)}

- {STATUS_LABEL[item.status]} + {t(STATUS_LABEL[item.status])}
- {item.total > 0 ? `${item.processed} / ${item.total}` : '无需处理'} + {item.total > 0 + ? t('photos.sync.progress.stages.progress', { processed: item.processed, total: item.total }) + : t(photoSyncProgressKeys.recent.noFurther)}
))} @@ -176,8 +271,10 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps
-

构建日志

- 最新 {recentLogs.length} 条 +

{t(photoSyncProgressKeys.logs.title)}

+ + {t(photoSyncProgressKeys.logs.recent, { count: recentLogs.length })} +
{recentLogs.map((log) => { @@ -198,14 +295,22 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps const detailSegments: string[] = [] if (photoId) detailSegments.push(`ID ${photoId}`) - if (resultType) detailSegments.push(`结果 ${resultType}`) + if (resultType) detailSegments.push(t(photoSyncProgressKeys.logDetails.result, { value: resultType })) if (typeof hasExisting === 'boolean') { - detailSegments.push(hasExisting ? '包含历史 manifest' : '无历史 manifest') + detailSegments.push( + hasExisting + ? t(photoSyncProgressKeys.logDetails.manifest) + : t(photoSyncProgressKeys.logDetails.manifestAbsent), + ) } if (typeof hasLivePhotoMap === 'boolean') { - detailSegments.push(hasLivePhotoMap ? '包含 Live Photo' : '无 Live Photo') + detailSegments.push( + hasLivePhotoMap + ? t(photoSyncProgressKeys.logDetails.livePhoto) + : t(photoSyncProgressKeys.logDetails.livePhotoAbsent), + ) } - if (error) detailSegments.push(`错误 ${error}`) + if (error) detailSegments.push(t(photoSyncProgressKeys.logDetails.error, { value: error })) return (
{formatLogTimestamp(log.timestamp)} - {levelConfig.label} + {t(levelConfig.labelKey)} {log.message} {log.storageKey ? {log.storageKey} : null} - {log.stage ? {STAGE_CONFIG[log.stage].label} : null} + {log.stage ? {t(STAGE_CONFIG[log.stage].labelKey)} : null} {detailSegments.length > 0 ? ( {detailSegments.join(' · ')} ) : null} @@ -232,16 +337,18 @@ export function PhotoSyncProgressPanel({ progress }: PhotoSyncProgressPanelProps {lastAction ? (
-

最近处理

+

{t(photoSyncProgressKeys.recent.title)}

{formatActionLabel(lastAction.action)} {lastAction.action.storageKey} - {STAGE_CONFIG[lastAction.stage].label} + {t(STAGE_CONFIG[lastAction.stage].labelKey)}

- {lastAction.total > 0 ? `进度:${lastAction.index} / ${lastAction.total}` : '无需进一步处理'} + {lastAction.total > 0 + ? t(photoSyncProgressKeys.recent.progress, { processed: lastAction.index, total: lastAction.total }) + : t(photoSyncProgressKeys.recent.noFurther)}

) : null} diff --git a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncResultPanel.tsx b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncResultPanel.tsx index a03bbbee..231e9519 100644 --- a/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncResultPanel.tsx +++ b/be/apps/dashboard/src/modules/photos/components/sync/PhotoSyncResultPanel.tsx @@ -1,7 +1,9 @@ import { Button } from '@afilmory/ui' import { Spring } from '@afilmory/utils' import { m } from 'motion/react' -import { useMemo, useState } from 'react' +import { useCallback,useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { getActionTypeMeta, getConflictTypeLabel, PHOTO_ACTION_TYPE_CONFIG } from '../../constants' import type { @@ -48,35 +50,88 @@ function SummaryCard({ label, value, tone }: SummaryCardProps) { ) } -const DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', { - dateStyle: 'medium', - timeStyle: 'short', -}) +const photoSyncResultSummaryLabelKeys = { + storageObjects: 'photos.sync.result.summary.labels.storage-objects', + databaseRecords: 'photos.sync.result.summary.labels.database-records', + inserted: 'photos.sync.result.summary.labels.inserted', + updated: 'photos.sync.result.summary.labels.updated', + deleted: 'photos.sync.result.summary.labels.deleted', + conflicts: 'photos.sync.result.summary.labels.conflicts', + errors: 'photos.sync.result.summary.labels.errors', + skipped: 'photos.sync.result.summary.labels.skipped', + completed: 'photos.sync.result.summary.labels.completed', + pending: 'photos.sync.result.summary.labels.pending', +} as const satisfies Record -function formatDateTimeLabel(value: string): string { - const date = new Date(value) - if (Number.isNaN(date.getTime())) { - return value - } - return DATE_FORMATTER.format(date) -} - -function formatDurationLabel(start: string, end: string): string { - const startedAt = new Date(start) - const completedAt = new Date(end) - const duration = completedAt.getTime() - startedAt.getTime() - if (!Number.isFinite(duration) || duration <= 0) { - return '不足 1 秒' - } - const totalSeconds = Math.max(Math.round(duration / 1000), 1) - const minutes = Math.floor(totalSeconds / 60) - const seconds = totalSeconds % 60 - const parts: string[] = [] - if (minutes > 0) { - parts.push(`${minutes} 分`) - } - parts.push(`${seconds} 秒`) - return parts.join(' ') +const photoSyncResultKeys = { + duration: { + lessThanSecond: 'photos.sync.result.duration.less-than-second', + minutes: 'photos.sync.result.duration.minutes', + seconds: 'photos.sync.result.duration.seconds', + }, + summary: { + heading: 'photos.sync.result.summary.heading', + descriptionLatest: 'photos.sync.result.summary.description.latest', + descriptionPreview: 'photos.sync.result.summary.description.preview', + descriptionLive: 'photos.sync.result.summary.description.live', + }, + history: { + heading: 'photos.sync.result.history.heading', + completedAt: 'photos.sync.result.history.completed-at', + duration: 'photos.sync.result.history.duration', + modePreview: 'photos.sync.result.history.mode.preview', + modeLive: 'photos.sync.result.history.mode.live', + operations: 'photos.sync.result.history.operations', + }, + status: { + loadingTitle: 'photos.sync.result.status.loading.title', + loadingDescription: 'photos.sync.result.status.loading.description', + emptyTitle: 'photos.sync.result.status.empty.title', + emptyDescription: 'photos.sync.result.status.empty.description', + }, + operations: { + count: 'photos.sync.result.operations.count', + filterLabel: 'photos.sync.result.operations.filter-label', + }, + table: { + title: 'photos.sync.result.table.title', + modePreview: 'photos.sync.result.table.mode.preview', + modeLive: 'photos.sync.result.table.mode.live', + emptyFiltered: 'photos.sync.result.table.empty.filtered', + emptyNone: 'photos.sync.result.table.empty.none', + }, + filters: { + all: 'photos.sync.result.filters.all', + }, + info: { + photoId: 'photos.sync.result.info.photo-id', + conflictType: 'photos.sync.result.info.conflict-type', + storageKey: 'photos.sync.result.info.storage-key', + }, + actions: { + applied: 'photos.sync.result.actions.applied', + pending: 'photos.sync.result.actions.pending', + expand: 'photos.sync.result.actions.expand', + collapse: 'photos.sync.result.actions.collapse', + }, + alerts: { + openOriginalFailed: 'photos.sync.result.alerts.open-original-failed', + }, + manifest: { + empty: 'photos.sync.result.manifest.empty', + }, +} as const satisfies { + duration: Record<'lessThanSecond' | 'minutes' | 'seconds', I18nKeys> + summary: Record<'heading' | 'descriptionLatest' | 'descriptionPreview' | 'descriptionLive', I18nKeys> + history: Record<'heading' | 'completedAt' | 'duration' | 'modePreview' | 'modeLive' | 'operations', I18nKeys> + status: Record<'loadingTitle' | 'loadingDescription' | 'emptyTitle' | 'emptyDescription', I18nKeys> + operations: Record<'count' | 'filterLabel', I18nKeys> + table: Record<'title' | 'modePreview' | 'modeLive' | 'emptyFiltered' | 'emptyNone', I18nKeys> + filters: Record<'all', I18nKeys> + info: Record<'photoId' | 'conflictType' | 'storageKey', I18nKeys> + actions: Record<'applied' | 'pending' | 'expand' | 'collapse', I18nKeys> + alerts: Record<'openOriginalFailed', I18nKeys> + manifest: Record<'empty', I18nKeys> } type PhotoSyncResultPanelProps = { @@ -102,31 +157,72 @@ export function PhotoSyncResultPanel({ isSyncStatusLoading, onRequestStorageUrl, }: PhotoSyncResultPanelProps) { + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' + const dateTimeFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }), + [locale], + ) + const formatDateTimeLabel = useCallback( + (value: string): string => { + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return value + } + return dateTimeFormatter.format(date) + }, + [dateTimeFormatter], + ) + const formatDurationLabel = useCallback( + (start: string, end: string): string => { + const startedAt = new Date(start) + const completedAt = new Date(end) + const duration = completedAt.getTime() - startedAt.getTime() + if (!Number.isFinite(duration) || duration <= 0) { + return t(photoSyncResultKeys.duration.lessThanSecond) + } + const totalSeconds = Math.max(Math.round(duration / 1000), 1) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + const parts: string[] = [] + if (minutes > 0) { + parts.push(t(photoSyncResultKeys.duration.minutes, { count: minutes })) + } + parts.push(t(photoSyncResultKeys.duration.seconds, { count: seconds })) + return parts.join(' ') + }, + [t], + ) const isAwaitingStatus = isSyncStatusLoading && !lastSyncRun const summaryItems = useMemo(() => { + const label = (key: keyof typeof photoSyncResultSummaryLabelKeys) => t(photoSyncResultSummaryLabelKeys[key]) if (result) { return [ - { label: '存储对象', value: result.summary.storageObjects }, - { label: '数据库记录', value: result.summary.databaseRecords }, + { label: label('storageObjects'), value: result.summary.storageObjects }, + { label: label('databaseRecords'), value: result.summary.databaseRecords }, { - label: '新增照片', + label: label('inserted'), value: result.summary.inserted, tone: 'accent' as const, }, - { label: '更新记录', value: result.summary.updated }, - { label: '删除记录', value: result.summary.deleted }, + { label: label('updated'), value: result.summary.updated }, + { label: label('deleted'), value: result.summary.deleted }, { - label: '冲突条目', + label: label('conflicts'), value: result.summary.conflicts, tone: result.summary.conflicts > 0 ? ('warning' as const) : ('muted' as const), }, { - label: '错误条目', + label: label('errors'), value: result.summary.errors, tone: result.summary.errors > 0 ? ('warning' as const) : ('muted' as const), }, { - label: '跳过条目', + label: label('skipped'), value: result.summary.skipped, tone: 'muted' as const, }, @@ -135,27 +231,27 @@ export function PhotoSyncResultPanel({ if (lastSyncRun) { return [ - { label: '存储对象', value: lastSyncRun.summary.storageObjects }, - { label: '数据库记录', value: lastSyncRun.summary.databaseRecords }, + { label: label('storageObjects'), value: lastSyncRun.summary.storageObjects }, + { label: label('databaseRecords'), value: lastSyncRun.summary.databaseRecords }, { - label: '新增照片', + label: label('inserted'), value: lastSyncRun.summary.inserted, tone: lastSyncRun.summary.inserted > 0 ? ('accent' as const) : undefined, }, - { label: '更新记录', value: lastSyncRun.summary.updated }, - { label: '删除记录', value: lastSyncRun.summary.deleted }, + { label: label('updated'), value: lastSyncRun.summary.updated }, + { label: label('deleted'), value: lastSyncRun.summary.deleted }, { - label: '冲突条目', + label: label('conflicts'), value: lastSyncRun.summary.conflicts, tone: lastSyncRun.summary.conflicts > 0 ? ('warning' as const) : ('muted' as const), }, { - label: '错误条目', + label: label('errors'), value: lastSyncRun.summary.errors, tone: lastSyncRun.summary.errors > 0 ? ('warning' as const) : ('muted' as const), }, { - label: '跳过条目', + label: label('skipped'), value: lastSyncRun.summary.skipped, tone: 'muted' as const, }, @@ -164,15 +260,15 @@ export function PhotoSyncResultPanel({ if (baselineSummary) { return [ - { label: '数据库记录', value: baselineSummary.total }, - { label: '同步完成', value: baselineSummary.synced }, + { label: label('databaseRecords'), value: baselineSummary.total }, + { label: label('completed'), value: baselineSummary.synced }, { - label: '冲突条目', + label: label('conflicts'), value: baselineSummary.conflicts, tone: baselineSummary.conflicts > 0 ? ('warning' as const) : ('muted' as const), }, { - label: '待处理', + label: label('pending'), value: baselineSummary.pending, tone: baselineSummary.pending > 0 ? ('accent' as const) : ('muted' as const), }, @@ -180,7 +276,7 @@ export function PhotoSyncResultPanel({ } return [] - }, [result, lastSyncRun, baselineSummary]) + }, [baselineSummary, lastSyncRun, result, t]) const lastSyncRunMeta = useMemo(() => { if (!lastSyncRun) { @@ -215,7 +311,7 @@ export function PhotoSyncResultPanel({ return [ { type: 'all' as const, - label: '全部', + label: t(photoSyncResultKeys.filters.all), count: result ? result.actions.length : 0, }, ...Object.entries(actionTypeConfig).map(([type]) => { @@ -227,7 +323,7 @@ export function PhotoSyncResultPanel({ } }), ] - }, [result]) + }, [result, t]) const filteredActions = useMemo(() => { if (!result) { @@ -268,8 +364,9 @@ export function PhotoSyncResultPanel({ 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 fallback = t(photoSyncResultKeys.alerts.openOriginalFailed) + const description = error instanceof Error ? error.message : String(error) + toast.error(fallback, { description }) } } @@ -285,7 +382,11 @@ export function PhotoSyncResultPanel({ applied, } = action const resolutionLabel = - resolution === 'prefer-storage' ? '以存储为准' : resolution === 'prefer-database' ? '以数据库为准' : null + resolution === 'prefer-storage' + ? t('photos.sync.conflicts.strategy.storage') + : resolution === 'prefer-database' + ? t('photos.sync.conflicts.strategy.database') + : null const conflictTypeLabel = action.type === 'conflict' ? getConflictTypeLabel(conflictPayload?.type) : null return ( @@ -297,10 +398,14 @@ export function PhotoSyncResultPanel({ {label} {storageKey} - {photoId ? Photo ID:{photoId} : null} + {photoId ? ( + + {t(photoSyncResultKeys.info.photoId)} {photoId} + + ) : null}
- {applied ? '已应用' : '未应用'} + {applied ? t(photoSyncResultKeys.actions.applied) : t(photoSyncResultKeys.actions.pending)} {resolutionLabel ? · {resolutionLabel} : null}
@@ -309,10 +414,14 @@ export function PhotoSyncResultPanel({ {conflictTypeLabel || conflictPayload?.incomingStorageKey ? (
- {conflictTypeLabel ? 冲突类型:{conflictTypeLabel} : null} + {conflictTypeLabel ? ( + + {t(photoSyncResultKeys.info.conflictType)} {conflictTypeLabel} + + ) : null} {conflictPayload?.incomingStorageKey ? ( - 存储 Key: + {t(photoSyncResultKeys.info.storageKey)} {conflictPayload.incomingStorageKey} ) : null} @@ -321,9 +430,9 @@ export function PhotoSyncResultPanel({ {(beforeManifest || afterManifest) && (
- + handleOpenOriginal(action)} /> @@ -334,13 +443,13 @@ export function PhotoSyncResultPanel({
{action.snapshots.before ? (
-

元数据(数据库)

+

{t('photos.sync.metadata.database')}

) : null} {action.snapshots.after ? (
-

元数据(存储)

+

{t('photos.sync.metadata.storage')}

) : null} @@ -357,16 +466,20 @@ export function PhotoSyncResultPanel({
-

最近一次同步完成

+

{t(photoSyncResultKeys.history.heading)}

- 完成于 {lastSyncRunMeta.completedLabel} + {t(photoSyncResultKeys.history.completedAt, { time: lastSyncRunMeta.completedLabel })} · - 耗时 {lastSyncRunMeta.durationLabel} + {t(photoSyncResultKeys.history.duration, { duration: lastSyncRunMeta.durationLabel })} · - {lastSyncRun.dryRun ? '预览模式 · 未写入数据库' : '实时模式 · 已写入数据库'} + + {lastSyncRun.dryRun + ? t(photoSyncResultKeys.history.modePreview) + : t(photoSyncResultKeys.history.modeLive)} +

- 共 {lastSyncRun.actionsCount} 个操作 + {t(photoSyncResultKeys.history.operations, { count: lastSyncRun.actionsCount })}

{summaryItems.length > 0 ? ( @@ -389,12 +502,12 @@ export function PhotoSyncResultPanel({

- {isAwaitingStatus ? '正在加载同步状态' : '尚未执行同步'} + {isAwaitingStatus ? t(photoSyncResultKeys.status.loadingTitle) : t(photoSyncResultKeys.status.emptyTitle)}

{isAwaitingStatus - ? '正在查询最近一次同步记录,请稍候…' - : '请在系统设置中配置并激活存储提供商,然后使用右上角的按钮执行同步操作。预览模式不会写入数据,可用于安全检查。'} + ? t(photoSyncResultKeys.status.loadingDescription) + : t(photoSyncResultKeys.status.emptyDescription)}

{showSkeleton ? ( @@ -419,20 +532,20 @@ export function PhotoSyncResultPanel({
-

同步摘要

+

{t(photoSyncResultKeys.summary.heading)}

{lastWasDryRun === null - ? '以下为最新同步结果。' + ? t(photoSyncResultKeys.summary.descriptionLatest) : lastWasDryRun - ? '最近执行了预览模式,数据库未发生变更。' - : '最近一次同步结果已写入数据库。'} + ? t(photoSyncResultKeys.summary.descriptionPreview) + : t(photoSyncResultKeys.summary.descriptionLive)}

- 操作数:{filteredActions.length} + {t(photoSyncResultKeys.operations.count, { count: filteredActions.length })} {result && selectedActionType !== 'all' ? ( - · 筛选: + {t(photoSyncResultKeys.operations.filterLabel)} {activeFilter?.label ?? ''} ) : null} @@ -456,9 +569,9 @@ export function PhotoSyncResultPanel({

-

同步操作明细

+

{t(photoSyncResultKeys.table.title)}

- {lastWasDryRun ? '预览模式 · 未应用变更' : '实时模式 · 结果已写入'} + {lastWasDryRun ? t(photoSyncResultKeys.table.modePreview) : t(photoSyncResultKeys.table.modeLive)}
@@ -483,7 +596,7 @@ export function PhotoSyncResultPanel({ {filteredActions.length === 0 ? (

- {result ? '当前筛选下没有需要查看的操作。' : '同步完成,未检测到需要处理的对象。'} + {result ? t(photoSyncResultKeys.table.emptyFiltered) : t(photoSyncResultKeys.table.emptyNone)}

) : (
@@ -492,9 +605,9 @@ export function PhotoSyncResultPanel({ const { label, badgeClass } = getActionTypeMeta(action.type) const resolutionLabel = action.resolution === 'prefer-storage' - ? '以存储为准' + ? t('photos.sync.conflicts.strategy.storage') : action.resolution === 'prefer-database' - ? '以数据库为准' + ? t('photos.sync.conflicts.strategy.database') : null const { conflictPayload } = action const conflictTypeLabel = @@ -524,11 +637,17 @@ export function PhotoSyncResultPanel({ {action.storageKey} {action.photoId ? ( - Photo ID:{action.photoId} + + {t(photoSyncResultKeys.info.photoId)} {action.photoId} + ) : null}
- {action.applied ? '已应用' : '未应用'} + + {action.applied + ? t(photoSyncResultKeys.actions.applied) + : t(photoSyncResultKeys.actions.pending)} + {resolutionLabel ? · {resolutionLabel} : null}
@@ -545,10 +666,14 @@ export function PhotoSyncResultPanel({ {conflictTypeLabel || incomingKey ? (
- {conflictTypeLabel ? 冲突类型:{conflictTypeLabel} : null} + {conflictTypeLabel ? ( + + {t(photoSyncResultKeys.info.conflictType)} {conflictTypeLabel} + + ) : null} {incomingKey ? ( - 存储 Key: + {t(photoSyncResultKeys.info.storageKey)} {incomingKey} ) : null} @@ -572,27 +697,42 @@ export function PhotoSyncResultPanel({ } function ManifestPreview({ - title, + variant, manifest, onOpenOriginal, }: { - title: string + variant: 'database' | 'storage' manifest: PhotoSyncAction['manifestAfter'] | PhotoSyncAction['manifestBefore'] onOpenOriginal?: () => void }) { + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' + const titleKey = + variant === 'database' + ? 'photos.sync.conflicts.preview.database.title' + : 'photos.sync.conflicts.preview.storage.title' + const emptyLabel = + variant === 'database' ? 'photos.sync.conflicts.preview.database.empty' : photoSyncResultKeys.manifest.empty if (!manifest) { return (
-

{title}

-

暂无数据

+

{t(titleKey)}

+

{t(emptyLabel)}

) } - const dimensions = manifest.width && manifest.height ? `${manifest.width} × ${manifest.height}` : '未知' + const dimensions = + manifest.width && manifest.height ? `${manifest.width} × ${manifest.height}` : t('photos.sync.metadata.unknown') const sizeMB = - typeof manifest.size === 'number' && manifest.size > 0 ? `${(manifest.size / (1024 * 1024)).toFixed(2)} MB` : '未知' - const updatedAt = manifest.lastModified ? new Date(manifest.lastModified).toLocaleString() : '未知' + typeof manifest.size === 'number' && manifest.size > 0 + ? `${(manifest.size / (1024 * 1024)).toLocaleString(locale, { maximumFractionDigits: 2 })} MB` + : t('photos.sync.metadata.unknown') + const updatedAt = manifest.lastModified + ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format( + new Date(manifest.lastModified), + ) + : t('photos.sync.metadata.unknown') return (
@@ -601,28 +741,28 @@ function ManifestPreview({ {manifest.id} ) : null}
-

{title}

+

{t(titleKey)}

- ID: + {t('photos.sync.conflicts.preview.common.id')} {manifest.id}
- 尺寸: + {t('photos.sync.conflicts.preview.common.dimensions')} {dimensions}
- 大小: + {t('photos.sync.conflicts.preview.common.size')} {sizeMB}
- 更新时间: + {t('photos.sync.conflicts.preview.common.updated-at')} {updatedAt}
{onOpenOriginal ? ( ) : null}
@@ -634,26 +774,33 @@ type MetadataSnapshotProps = { } export function MetadataSnapshot({ snapshot }: MetadataSnapshotProps) { + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' if (!snapshot) return null + const sizeLabel = + snapshot.size !== null + ? `${(snapshot.size / 1024 / 1024).toLocaleString(locale, { maximumFractionDigits: 2 })} MB` + : t('photos.sync.metadata.unknown') + const etagLabel = snapshot.etag ?? t('photos.sync.metadata.unknown') + const updatedAtLabel = snapshot.lastModified ?? t('photos.sync.metadata.unknown') + const hashLabel = snapshot.metadataHash ?? t('photos.sync.metadata.none') return (
-
大小
-
- {snapshot.size !== null ? `${(snapshot.size / 1024 / 1024).toFixed(2)} MB` : '未知'} -
+
{t('photos.sync.metadata.size')}
+
{sizeLabel}
-
ETag
-
{snapshot.etag ?? '未知'}
+
{t('photos.sync.metadata.etag')}
+
{etagLabel}
-
更新时间
-
{snapshot.lastModified ?? '未知'}
+
{t('photos.sync.metadata.updated-at')}
+
{updatedAtLabel}
-
元数据摘要
-
{snapshot.metadataHash ?? '无'}
+
{t('photos.sync.metadata.hash')}
+
{hashLabel}
) diff --git a/be/apps/dashboard/src/modules/photos/components/tabs/PhotoSyncTab.tsx b/be/apps/dashboard/src/modules/photos/components/tabs/PhotoSyncTab.tsx index 9bd11591..48d03d90 100644 --- a/be/apps/dashboard/src/modules/photos/components/tabs/PhotoSyncTab.tsx +++ b/be/apps/dashboard/src/modules/photos/components/tabs/PhotoSyncTab.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from '@tanstack/react-query' import type { ReactNode } from 'react' import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { getRequestErrorMessage } from '~/lib/errors' @@ -52,6 +53,7 @@ function createInitialStages(totals: PhotoSyncProgressState['totals']): PhotoSyn } export function PhotoSyncTab() { + const { t } = useTranslation() const queryClient = useQueryClient() const [result, setResult] = useState(null) const [lastWasDryRun, setLastWasDryRun] = useState(null) @@ -229,17 +231,19 @@ export function PhotoSyncTab() { id: conflict.id, strategy, }) - toast.success('冲突已处理', { + toast.success(t('photos.sync.toasts.conflict-resolved'), { description: action.reason ?? - (strategy === 'prefer-storage' ? '已以存储数据覆盖数据库记录。' : '已保留数据库记录并忽略存储差异。'), + (strategy === 'prefer-storage' + ? t('photos.sync.toasts.conflict-storage') + : t('photos.sync.toasts.conflict-database')), }) void conflictsQuery.refetch() void summaryQuery.refetch() void queryClient.invalidateQueries({ queryKey: PHOTO_ASSET_LIST_QUERY_KEY }) } catch (error) { - const message = getRequestErrorMessage(error, '处理冲突失败,请稍后重试。') - toast.error('处理冲突失败', { description: message }) + const message = getRequestErrorMessage(error, t('photos.sync.toasts.conflict-error-desc')) + toast.error(t('photos.sync.toasts.conflict-error'), { description: message }) } finally { setResolvingConflictId(null) } @@ -250,7 +254,7 @@ export function PhotoSyncTab() { const handleResolveConflictsBatch = useCallback( async (conflicts: PhotoSyncConflict[], strategy: PhotoSyncResolution) => { if (!strategy || conflicts.length === 0) { - toast.info('请选择至少一个冲突条目') + toast.info(t('photos.sync.toasts.conflict-select')) return } @@ -267,7 +271,7 @@ export function PhotoSyncTab() { }) processed += 1 } catch (error) { - errors.push(getRequestErrorMessage(error, '处理冲突失败,请稍后重试。')) + errors.push(getRequestErrorMessage(error, t('photos.sync.toasts.conflict-error-desc'))) } } } finally { @@ -275,11 +279,20 @@ export function PhotoSyncTab() { } if (processed > 0) { - toast.success(`${strategy === 'prefer-storage' ? '以存储为准' : '以数据库为准'}处理 ${processed} 个冲突`) + const strategyLabel = + strategy === 'prefer-storage' + ? t('photos.sync.conflicts.strategy.storage') + : t('photos.sync.conflicts.strategy.database') + toast.success( + t('photos.sync.toasts.conflict-batch-success', { + strategy: strategyLabel, + count: processed, + }), + ) } if (errors.length > 0) { - toast.error('部分冲突处理失败', { + toast.error(t('photos.sync.toasts.conflict-batch-error'), { description: errors[0], }) } diff --git a/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx b/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx index 43c3061b..6b1801a0 100644 --- a/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx +++ b/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx @@ -1,5 +1,6 @@ import { Button } from '@afilmory/ui' import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' import { LinearBorderPanel } from '~/components/common/GlassPanel' @@ -13,14 +14,65 @@ type PhotoUsagePanelProps = { onRefresh?: () => void } -const NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN') -const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', { - dateStyle: 'medium', - timeStyle: 'short', -}) -const RELATIVE_FORMATTER = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }) +const photoUsageI18nKeys = { + summary: { + title: 'photos.usage.summary.title', + description: 'photos.usage.summary.description', + refresh: 'photos.usage.summary.refresh', + }, + events: { + title: 'photos.usage.events.title', + description: 'photos.usage.events.description', + total: 'photos.usage.events.total', + emptyTitle: 'photos.usage.events.empty.title', + emptyDescription: 'photos.usage.events.empty.description', + unitLabel: 'photos.usage.events.unit.label', + unitByte: 'photos.usage.events.unit.byte', + unitCount: 'photos.usage.events.unit.count', + metadataEmpty: 'photos.usage.events.metadata.empty', + metadataMore: 'photos.usage.events.metadata.more', + metadataValueUnknown: 'photos.usage.events.metadata.value-unknown', + }, +} as const satisfies { + summary: { + title: I18nKeys + description: I18nKeys + refresh: I18nKeys + } + events: { + title: I18nKeys + description: I18nKeys + total: I18nKeys + emptyTitle: I18nKeys + emptyDescription: I18nKeys + unitLabel: I18nKeys + unitByte: I18nKeys + unitCount: I18nKeys + metadataEmpty: I18nKeys + metadataMore: I18nKeys + metadataValueUnknown: I18nKeys + } +} export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }: PhotoUsagePanelProps) { + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' + const numberFormatter = useMemo(() => new Intl.NumberFormat(locale), [locale]) + const dateTimeFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }), + [locale], + ) + const relativeFormatter = useMemo( + () => + new Intl.RelativeTimeFormat(locale, { + numeric: 'auto', + }), + [locale], + ) const summaryItems = useMemo(() => { const totals = overview?.totals ?? [] const totalMap = new Map(totals.map((entry) => [entry.eventType, entry.totalQuantity])) @@ -46,8 +98,8 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
-

用量概览

-

按事件类型统计的累计用量。

+

{t(photoUsageI18nKeys.summary.title)}

+

{t(photoUsageI18nKeys.summary.description)}

@@ -71,6 +123,7 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }: description={item.description} value={item.value} tone={item.tone} + numberFormatter={numberFormatter} /> ))}
@@ -79,10 +132,12 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
-

最近用量事件

-

展示最新的计费用量明细,包含上传、删除和同步操作。

+

{t(photoUsageI18nKeys.events.title)}

+

{t(photoUsageI18nKeys.events.description)}

- {events.length > 0 &&

共 {events.length} 条记录

} + {events.length > 0 && ( +

{t(photoUsageI18nKeys.events.total, { count: events.length })}

+ )}
{isLoading ? ( @@ -93,13 +148,19 @@ export function PhotoUsagePanel({ overview, isLoading, isFetching, onRefresh }:
) : isEmpty ? (
-

暂无用量记录

-

成功上传照片或运行同步后,将在此展示计费相关事件。

+

{t(photoUsageI18nKeys.events.emptyTitle)}

+

{t(photoUsageI18nKeys.events.emptyDescription)}

) : (
{events.map((event) => ( - + ))}
)} @@ -113,15 +174,16 @@ type SummaryCardProps = { description: string value: number tone: 'accent' | 'warning' | 'muted' + numberFormatter: Intl.NumberFormat } -function SummaryCard({ label, description, value, tone }: SummaryCardProps) { +function SummaryCard({ label, description, value, tone, numberFormatter }: SummaryCardProps) { const toneClass = tone === 'accent' ? 'text-emerald-400' : tone === 'warning' ? 'text-rose-400' : 'text-text' return (

{label}

-

{NUMBER_FORMATTER.format(value)}

+

{numberFormatter.format(value)}

{description}

) @@ -141,12 +203,26 @@ type UsageEventRowProps = { event: BillingUsageEvent } -function UsageEventRow({ event }: UsageEventRowProps) { +type UsageEventRowFormatters = { + numberFormatter: Intl.NumberFormat + dateTimeFormatter: Intl.DateTimeFormat + relativeTimeFormatter: Intl.RelativeTimeFormat +} + +function UsageEventRow({ + event, + numberFormatter, + dateTimeFormatter, + relativeTimeFormatter, +}: UsageEventRowProps & UsageEventRowFormatters) { + const { t } = useTranslation() const label = getUsageEventLabel(event.eventType) const description = getUsageEventDescription(event.eventType) const quantityClass = event.quantity >= 0 ? 'text-emerald-400' : 'text-rose-400' - const dateLabel = formatDateLabel(event.occurredAt) - const relativeLabel = formatRelativeLabel(event.occurredAt) + const dateLabel = formatDateLabel(event.occurredAt, dateTimeFormatter) + const relativeLabel = formatRelativeLabel(event.occurredAt, relativeTimeFormatter) + const unitLabel = + event.unit === 'byte' ? t(photoUsageI18nKeys.events.unitByte) : t(photoUsageI18nKeys.events.unitCount) return (
@@ -156,8 +232,8 @@ function UsageEventRow({ event }: UsageEventRowProps) {
-

{NUMBER_FORMATTER.format(event.quantity)}

-

单位:{event.unit === 'byte' ? '字节' : '次数'}

+

{numberFormatter.format(event.quantity)}

+

{t(photoUsageI18nKeys.events.unitLabel, { unit: unitLabel })}

{dateLabel}

@@ -168,17 +244,19 @@ function UsageEventRow({ event }: UsageEventRowProps) { } function MetadataBadges({ metadata }: { metadata: Record | null }) { + const { t } = useTranslation() if (!metadata) { - return

+ return

{t(photoUsageI18nKeys.events.metadataEmpty)}

} const entries = Object.entries(metadata).filter(([, value]) => value != null) if (entries.length === 0) { - return

+ return

{t(photoUsageI18nKeys.events.metadataEmpty)}

} const visibleEntries = entries.slice(0, 4) const remaining = entries.length - visibleEntries.length + const valueFallback = t(photoUsageI18nKeys.events.metadataValueUnknown) return (
@@ -187,10 +265,14 @@ function MetadataBadges({ metadata }: { metadata: Record | null key={key} className="rounded-full border border-border/50 bg-background/60 px-2 py-0.5 text-xs text-text-secondary" > - {key}: {formatMetadataValue(value)} + {key}: {formatMetadataValue(value, valueFallback)} ))} - {remaining > 0 && +{remaining} 更多} + {remaining > 0 && ( + + {t(photoUsageI18nKeys.events.metadataMore, { count: remaining })} + + )}
) } @@ -209,9 +291,9 @@ function UsageEventSkeleton() { ) } -function formatMetadataValue(value: unknown): string { +function formatMetadataValue(value: unknown, fallback: string): string { if (value == null) { - return '无' + return fallback } if (typeof value === 'string') { @@ -229,15 +311,15 @@ function formatMetadataValue(value: unknown): string { } } -function formatDateLabel(value: string): string { +function formatDateLabel(value: string, formatter: Intl.DateTimeFormat): string { const date = new Date(value) if (Number.isNaN(date.getTime())) { return value } - return DATE_TIME_FORMATTER.format(date) + return formatter.format(date) } -function formatRelativeLabel(value: string): string | null { +function formatRelativeLabel(value: string, formatter: Intl.RelativeTimeFormat): string | null { const date = new Date(value) if (Number.isNaN(date.getTime())) { return null @@ -245,12 +327,12 @@ function formatRelativeLabel(value: string): string | null { const diffMs = date.getTime() - Date.now() const diffMinutes = Math.round(diffMs / (1000 * 60)) if (Math.abs(diffMinutes) < 60) { - return RELATIVE_FORMATTER.format(diffMinutes, 'minute') + return formatter.format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return RELATIVE_FORMATTER.format(diffHours, 'hour') + return formatter.format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return RELATIVE_FORMATTER.format(diffDays, 'day') + return formatter.format(diffDays, 'day') } diff --git a/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx b/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx index 6dc20920..372f2e14 100644 --- a/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx +++ b/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx @@ -2,6 +2,7 @@ import { Button } from '@afilmory/ui' import { Spring } from '@afilmory/utils' import { m } from 'motion/react' import { startTransition, useCallback, useEffect, useId, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { LinearBorderPanel } from '~/components/common/GlassPanel' import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout' @@ -12,6 +13,27 @@ import { collectFieldNodes } from '../../schema-form/utils' import { useSiteSettingUiSchemaQuery, useUpdateSiteSettingsMutation } from '../hooks' import type { SiteSettingEntryInput, SiteSettingUiSchemaResponse, SiteSettingValueState } from '../types' +const siteSettingsKeys = { + button: { + saving: 'site.settings.button.saving', + save: 'site.settings.button.save', + }, + errors: { + unknown: 'site.settings.error.unknown', + loadPrefix: 'site.settings.error.load-prefix', + }, + banner: { + fail: 'site.settings.banner.fail', + success: 'site.settings.banner.success', + dirty: 'site.settings.banner.dirty', + synced: 'site.settings.banner.synced', + }, +} as const satisfies { + button: { saving: I18nKeys; save: I18nKeys } + errors: { unknown: I18nKeys; loadPrefix: I18nKeys } + banner: { fail: I18nKeys; success: I18nKeys; dirty: I18nKeys; synced: I18nKeys } +} + function coerceInitialValue(field: UiFieldNode, rawValue: string | null): SchemaFormValue { const { component } = field @@ -67,6 +89,7 @@ function serializeValue(field: UiFieldNode, value: SchemaFormValue | und } export function SiteSettingsForm() { + const { t } = useTranslation() const { data, isLoading, isError, error } = useSiteSettingUiSchemaQuery() const updateSettingsMutation = useUpdateSiteSettingsMutation() const { setHeaderActionState } = useMainPageLayout() @@ -151,7 +174,7 @@ export function SiteSettingsForm() { updateSettingsMutation.isError && updateSettingsMutation.error ? updateSettingsMutation.error instanceof Error ? updateSettingsMutation.error.message - : '未知错误' + : t(siteSettingsKeys.errors.unknown) : null useEffect(() => { @@ -175,11 +198,11 @@ export function SiteSettingsForm() { form={formId} disabled={changedEntries.length === 0} isLoading={updateSettingsMutation.isPending} - loadingText="保存中…" + loadingText={t(siteSettingsKeys.button.saving)} variant="primary" size="sm" > - 保存修改 + {t(siteSettingsKeys.button.save)} ) @@ -209,7 +232,10 @@ export function SiteSettingsForm() {
- {`无法加载站点设置:${error instanceof Error ? error.message : '未知错误'}`} + + {t(siteSettingsKeys.errors.loadPrefix)}{' '} + {error instanceof Error ? error.message : t(siteSettingsKeys.errors.unknown)} +
@@ -238,12 +264,12 @@ export function SiteSettingsForm() {
{mutationErrorMessage - ? `保存失败:${mutationErrorMessage}` + ? `${t(siteSettingsKeys.banner.fail)} ${mutationErrorMessage}` : updateSettingsMutation.isSuccess && changedEntries.length === 0 - ? '保存成功,站点设置已同步' + ? t(siteSettingsKeys.banner.success) : changedEntries.length > 0 - ? `有 ${changedEntries.length} 项设置待保存` - : '所有设置已同步'} + ? t(siteSettingsKeys.banner.dirty, { count: changedEntries.length }) + : t(siteSettingsKeys.banner.synced)}
diff --git a/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx b/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx index fb5d9ff4..72b5a5b4 100644 --- a/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx +++ b/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx @@ -1,7 +1,8 @@ import { Button, FormHelperText, Input, Label } from '@afilmory/ui' import { Spring } from '@afilmory/utils' import { m } from 'motion/react' -import { startTransition, useEffect, useId, useMemo, useState } from 'react' +import { startTransition, useCallback, useEffect, useId, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { LinearBorderPanel } from '~/components/common/GlassPanel' @@ -12,6 +13,56 @@ import { getRequestErrorMessage } from '~/lib/errors' import { useSiteAuthorProfileQuery, useUpdateSiteAuthorProfileMutation } from '../hooks' import type { SiteAuthorProfile, UpdateSiteAuthorPayload } from '../types' +const siteUserKeys = { + toastSuccess: 'site.user.toast.success', + toastError: 'site.user.toast.error', + toastErrorDescription: 'site.user.toast.error-description', + loadingError: 'site.user.error.loading', + blocker: { + title: 'site.user.blocker.title', + description: 'site.user.blocker.description', + confirm: 'site.user.blocker.confirm', + cancel: 'site.user.blocker.cancel', + }, + button: { + saving: 'site.user.button.saving', + save: 'site.user.button.save', + }, + header: { + badge: 'site.user.header.badge', + title: 'site.user.header.title', + description: 'site.user.header.description', + }, + preview: { + fallbackName: 'site.user.preview.fallback', + avatarAlt: 'site.user.preview.avatar-alt', + lastUpdated: 'site.user.preview.last-updated', + neverUpdated: 'site.user.preview.never-updated', + }, + form: { + name: { + label: 'site.user.form.name.label', + placeholder: 'site.user.form.name.placeholder', + helper: 'site.user.form.name.helper', + }, + display: { + label: 'site.user.form.display.label', + placeholder: 'site.user.form.display.placeholder', + helper: 'site.user.form.display.helper', + }, + username: { + label: 'site.user.form.username.label', + placeholder: 'site.user.form.username.placeholder', + helper: 'site.user.form.username.helper', + }, + avatar: { + label: 'site.user.form.avatar.label', + placeholder: 'site.user.form.avatar.placeholder', + helper: 'site.user.form.avatar.helper', + }, + }, +} as const + type UserFormState = { name: string displayUsername: string @@ -49,14 +100,9 @@ function buildPayload(state: UserFormState): UpdateSiteAuthorPayload { } } -function formatTimestamp(iso: string | undefined) { - if (!iso) return '' - const date = new Date(iso) - if (Number.isNaN(date.getTime())) return '' - return date.toLocaleString() -} - export function SiteUserProfileForm() { + const { t, i18n } = useTranslation() + const locale = i18n.language ?? i18n.resolvedLanguage ?? 'en' const { data, isLoading, isError, error } = useSiteAuthorProfileQuery() const updateMutation = useUpdateSiteAuthorProfileMutation() const { setHeaderActionState } = useMainPageLayout() @@ -86,6 +132,20 @@ export function SiteUserProfileForm() { const canSubmit = Boolean(data) && !isLoading && isDirty + const formatTimestamp = useCallback( + (iso: string | undefined | null) => { + if (!iso) return '' + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return '' + try { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(date) + } catch { + return date.toLocaleString() + } + }, + [locale], + ) + useEffect(() => { setHeaderActionState({ disabled: !canSubmit, @@ -115,10 +175,10 @@ export function SiteUserProfileForm() { const payload = buildPayload(formState) await updateMutation.mutateAsync(payload) setInitialState(formState) - toast.success('用户信息已更新') + toast.success(t(siteUserKeys.toastSuccess)) } catch (mutationError) { - toast.error('保存用户信息失败', { - description: getRequestErrorMessage(mutationError, '请检查输入内容后重试。'), + toast.error(t(siteUserKeys.toastError), { + description: getRequestErrorMessage(mutationError, t(siteUserKeys.toastErrorDescription)), }) } } @@ -132,19 +192,19 @@ export function SiteUserProfileForm() { size="sm" disabled={!canSubmit} isLoading={updateMutation.isPending} - loadingText="保存中…" + loadingText={t(siteUserKeys.button.saving)} > - 保存修改 + {t(siteUserKeys.button.save)} ) useBlock({ when: isDirty, - title: '离开前请保存设置', - description: '当前用户信息尚未保存,离开页面会丢失这些更改,确定要继续吗?', - confirmText: '继续离开', - cancelText: '留在此页', + title: t(siteUserKeys.blocker.title), + description: t(siteUserKeys.blocker.description), + confirmText: t(siteUserKeys.blocker.confirm), + cancelText: t(siteUserKeys.blocker.cancel), }) if (isLoading && !data) { return ( @@ -174,7 +234,7 @@ export function SiteUserProfileForm() {
- {getRequestErrorMessage(error, '无法加载用户信息')} + {getRequestErrorMessage(error, t(siteUserKeys.loadingError))}
@@ -192,16 +252,21 @@ export function SiteUserProfileForm() {
-

用户资料

-

展示在前台的作者身份

-

- 这些信息将用于站点头部、RSS Feed 与社交分享卡片,推荐保持与作者个人品牌一致。 +

+ {t(siteUserKeys.header.badge)}

+

{t(siteUserKeys.header.title)}

+

{t(siteUserKeys.header.description)}

{avatarPreview ? ( - 用户头像预览 + {t(siteUserKeys.preview.avatarAlt)} ) : (
{previewInitial} @@ -209,10 +274,14 @@ export function SiteUserProfileForm() { )}
-

{formState.displayUsername || formState.name || '作者'}

+

+ {formState.displayUsername || formState.name || t(siteUserKeys.preview.fallbackName)} +

{profile?.email}

- 最近更新:{formatTimestamp(profile?.updatedAt) || '尚未更新'} + {t(siteUserKeys.preview.lastUpdated, { + time: formatTimestamp(profile?.updatedAt) || t(siteUserKeys.preview.neverUpdated), + })}

@@ -228,49 +297,49 @@ export function SiteUserProfileForm() { >
- + handleChange('name')(event.currentTarget.value)} - placeholder="例如:Innei" + placeholder={t(siteUserKeys.form.name.placeholder)} required /> - 用于前台显示与 RSS 作者/编辑字段。 + {t(siteUserKeys.form.name.helper)}
- + handleChange('displayUsername')(event.currentTarget.value)} - placeholder="可选,例如:innei.photo" + placeholder={t(siteUserKeys.form.display.placeholder)} /> - 留空则使用作者名称,可用于展示更个性化的昵称。 + {t(siteUserKeys.form.display.helper)}
- + handleChange('username')(event.currentTarget.value)} - placeholder="例如:innei" + placeholder={t(siteUserKeys.form.username.placeholder)} /> - 用于后台识别作者账号,不会直接展示在前台。 + {t(siteUserKeys.form.username.helper)}
- + handleChange('avatar')(event.currentTarget.value)} - placeholder="https://cdn.example.com/avatar.png" + placeholder={t(siteUserKeys.form.avatar.placeholder)} /> - 支持 http(s) 或以 // 开头的链接,留空则使用首字母。 + {t(siteUserKeys.form.avatar.helper)}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx index 3710597e..ba5e6bb4 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderCard.tsx @@ -2,37 +2,39 @@ import { Button } from '@afilmory/ui' import { clsxm } from '@afilmory/utils' import { DynamicIcon } from 'lucide-react/dynamic' import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { storageProvidersI18nKeys } from '../constants' import type { StorageProvider } from '../types' const providerTypeConfig = { s3: { icon: 'database', - label: 'AWS S3', + labelKey: storageProvidersI18nKeys.types.s3, color: 'text-orange-500', bgColor: 'bg-orange-500/10', }, github: { icon: 'github', - label: 'GitHub', + labelKey: storageProvidersI18nKeys.types.github, color: 'text-purple-500', bgColor: 'bg-purple-500/10', }, local: { icon: 'folder', - label: 'Local Storage', + labelKey: storageProvidersI18nKeys.types.local, color: 'text-blue-500', bgColor: 'bg-blue-500/10', }, minio: { icon: 'server', - label: 'MinIO', + labelKey: storageProvidersI18nKeys.types.minio, color: 'text-red-500', bgColor: 'bg-red-500/10', }, eagle: { icon: 'image', - label: 'Eagle', + labelKey: storageProvidersI18nKeys.types.eagle, color: 'text-amber-500', bgColor: 'bg-amber-500/10', }, @@ -46,6 +48,7 @@ type ProviderCardProps = { } export const ProviderCard: FC = ({ provider, isActive, onEdit, onToggleActive }) => { + const { t } = useTranslation() const config = providerTypeConfig[provider.type as keyof typeof providerTypeConfig] || providerTypeConfig.s3 // Extract preview info based on provider type @@ -53,14 +56,14 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit const cfg = provider.config switch (provider.type) { case 's3': { - return cfg.region || cfg.bucket || 'Not configured' + return cfg.region || cfg.bucket || t(storageProvidersI18nKeys.card.notConfigured) } case 'github': { - return cfg.repo || 'Not configured' + return cfg.repo || t(storageProvidersI18nKeys.card.notConfigured) } default: { - return 'Storage provider' + return t(storageProvidersI18nKeys.card.fallback) } } } @@ -83,7 +86,7 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit
- Active + {t(storageProvidersI18nKeys.card.active)}
)} @@ -97,8 +100,10 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit {/* Provider Info */}
-

{provider.name || '未命名存储'}

-

{config.label}

+

+ {provider.name || t(storageProvidersI18nKeys.card.untitled)} +

+

{t(config.labelKey)}

{getPreviewInfo()}

@@ -113,7 +118,7 @@ export const ProviderCard: FC = ({ provider, isActive, onEdit onClick={onToggleActive} > - Make Inactive + {t(storageProvidersI18nKeys.card.makeInactive)} ) : ( )}
diff --git a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx index d7a3ef9e..01f66c5e 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/ProviderEditModal.tsx @@ -18,8 +18,13 @@ import { DynamicIcon } from 'lucide-react/dynamic' import { m } from 'motion/react' import { nanoid } from 'nanoid' import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' -import { STORAGE_PROVIDER_FIELD_DEFINITIONS, STORAGE_PROVIDER_TYPE_OPTIONS } from '../constants' +import { + STORAGE_PROVIDER_FIELD_DEFINITIONS, + STORAGE_PROVIDER_TYPE_OPTIONS, + storageProvidersI18nKeys, +} from '../constants' import type { StorageProvider, StorageProviderType } from '../types' type ProviderEditModalProps = ModalComponentProps & { @@ -36,6 +41,7 @@ export function ProviderEditModal({ dismiss, }: ProviderEditModalProps) { + const { t } = useTranslation() const [formData, setFormData] = useState(provider) const [isDirty, setIsDirty] = useState(false) @@ -106,12 +112,14 @@ export function ProviderEditModal({

- {isNewProvider ? 'Add Storage Provider' : 'Edit Provider'} + {t(isNewProvider ? storageProvidersI18nKeys.modal.createTitle : storageProvidersI18nKeys.modal.editTitle)}

- {isNewProvider - ? 'Configure a new storage provider for your photos' - : 'Update provider configuration and credentials'} + {t( + isNewProvider + ? storageProvidersI18nKeys.modal.createDescription + : storageProvidersI18nKeys.modal.editDescription, + )}

@@ -129,32 +137,32 @@ export function ProviderEditModal({ > {/* Basic Information */}
-

Basic Information

+

{t(storageProvidersI18nKeys.modal.sections.basic)}

- + handleNameChange(e.currentTarget.value)} - placeholder="e.g., Production S3" + placeholder={t(storageProvidersI18nKeys.modal.fields.namePlaceholder)} className="bg-background/60" />
- +